Device logins only requested OIDC scopes while the authorizer now gates API calls on v1:* scopes. Register the full scope set on the well-known prb client, request it at login via CLIClientScopes, and cover the device flow in e2e. Collapse API scopes under an accordion on the consent screen and document scope sync for future namespace additions. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
7.5 KiB
CLI Command Patterns
CLI commands use cobra with pkg/cmd/cmdutil.Factory for shared dependencies. Each resource gets a group command with verb subcommands (list, create, view, update, delete).
Directory structure
cmd/prb/main.go # Binary entry point
pkg/cmd/root/ # Root command, registers all subcommands
pkg/cmd/<resource>/<resource>.go # Group command, wires verbs
pkg/cmd/<resource>/list/list.go # List verb
pkg/cmd/<resource>/create/create.go # Create verb
pkg/cmd/<resource>/view/view.go # View verb
pkg/cmd/<resource>/update/update.go # Update verb
pkg/cmd/<resource>/delete/delete.go # Delete verb
pkg/cmd/cmdutil/ # Factory, flags, output helpers
pkg/cmd/iostreams/ # Terminal I/O abstraction
pkg/cli/api/ # GraphQL client, pagination
pkg/cli/config/ # Config file management (hosts, tokens, default org)
Register group commands in pkg/cmd/root/root.go with cmd.AddCommand().
Leaf command pattern
Every leaf command follows this structure:
package list
const listQuery = `query($id: ID!, $first: Int, $after: CursorKey) { ... }`
type listResponse struct { ... } // unexported, shaped to match GraphQL response
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOutput *string
)
cmd := &cobra.Command{
Use: "list",
Short: "List resources",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// 1. Validate output flag
// 2. Load config, get host + token
// 3. Create api.Client
// 4. Resolve --org (flag or config default)
// 5. Call API
// 6. Output results
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of items")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}
Pagination with api.Paginate[T]
resources, totalCount, err := api.Paginate(
client,
listQuery,
variables,
flagLimit,
func(data json.RawMessage) (*api.Connection[resource], error) {
var resp struct {
Node *struct {
Typename string `json:"__typename"`
Resources api.Connection[resource] `json:"resources"`
} `json:"node"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
if resp.Node == nil {
return nil, fmt.Errorf("organization %s not found", flagOrg)
}
return &resp.Node.Resources, nil
},
)
Output formatting
JSON output:
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resources)
}
Table output:
rows := make([][]string, 0, len(resources))
for _, r := range resources {
rows = append(rows, []string{r.ID, r.Name})
}
t := cmdutil.NewTable("ID", "NAME").Rows(rows...)
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
View detail output with lipgloss:
bold := lipgloss.NewStyle().Bold(true)
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(r.Name))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), r.ID)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(r.CreatedAt))
Truncation message (stderr, not stdout):
if totalCount > len(resources) {
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "\nShowing %d of %d resources\n", len(resources), totalCount)
}
Interactive prompts with charmbracelet/huh
Gate all prompts behind interactivity check, then validate:
if f.IOStreams.IsInteractive() {
if flagName == "" {
err := huh.NewInput().Title("Resource name").Value(&flagName).Run()
if err != nil {
return err
}
}
if flagCategory == "" {
err := huh.NewSelect[string]().
Title("Category").
Options(
huh.NewOption("Cloud Provider", "CLOUD_PROVIDER"),
huh.NewOption("SaaS", "SAAS"),
).
Value(&flagCategory).Run()
if err != nil {
return err
}
}
}
if flagName == "" {
return fmt.Errorf("name is required; pass --name or run interactively")
}
Available prompt types: huh.NewInput() (text), huh.NewText() (multiline), huh.NewSelect[T]() (dropdown), huh.NewConfirm() (yes/no).
Update commands
Only include fields that were explicitly changed:
input := map[string]any{"id": args[0]}
if cmd.Flags().Changed("name") {
input["name"] = flagName
}
if cmd.Flags().Changed("description") {
input["description"] = flagDescription
}
if len(input) == 1 {
return fmt.Errorf("at least one field must be specified for update")
}
Delete commands
Require confirmation via --yes flag or interactive prompt:
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot delete resource: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().Title(fmt.Sprintf("Delete %s?", args[0])).Value(&confirmed).Run()
if err != nil {
return err
}
if !confirmed {
return nil
}
}
Organization resolution
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
Flag conventions
- Use kebab-case:
--order-by,--inherent-likelihood - Short flags for common options:
-L(limit),-o(output),-q(query),-y(yes) StringVar/IntVar/BoolVarfor flags, positional args only for IDs in view/update/delete- Use
cmd.MarkFlagRequired()for mandatory flags
IOStreams
f.IOStreams.Out // stdout — primary output
f.IOStreams.ErrOut // stderr — status messages, truncation info
f.IOStreams.IsInteractive() // true if TTY and not forced non-interactive
Environment variables: PROBO_NO_INTERACTIVE=1, CI=true, TERM=dumb (non-interactive), NO_COLOR (disable color).
OAuth device login (prb auth login)
The well-known CLI OAuth client (config.CLIClientID) requests config.CLIClientScopes at device authorization. Keep three places in sync when adding v1:* scopes:
- Scope constants and
OAuth2ScopeSet()registration in the owning package iam_oauth2_clients.scopesforCLIClientID(SQL migration)CLIClientScopesinpkg/cli/config/config.go
After scope changes ship, users must re-authenticate so new tokens carry the updated scopes:
prb auth logout --hostname <host>
prb auth login --hostname <host>
Existing tokens may be backfilled by migration; fresh logins need the client row and CLIClientScopes updated.
Local development against probod
make stack-up
make build
make dev-config
bin/probod -cfg-file cfg/dev.yaml # API + OAuth at http://localhost:8080
bin/prb auth login --hostname http://localhost:8080
Use http:// explicitly — hosts without a scheme default to HTTPS. Config is stored under the OS user config dir (prb/config.yaml). Override with PROBO_HOST and PROBO_TOKEN for scripting; see contrib/seed.sh for a personal API key bootstrap.
New resource command checklist
- Group command —
pkg/cmd/<resource>/<resource>.gowithNewCmd<Resource>(f), wiring all verb subcommands - Leaf commands — one file per verb in
pkg/cmd/<resource>/<verb>/<verb>.go - Each leaf file — ISC license header, GraphQL const, unexported response struct,
NewCmd<Verb>(f)function - Register in root — import and
cmd.AddCommand()inpkg/cmd/root/root.go - API surface — update GraphQL schema, MCP tools, and e2e tests