Files
probo/contrib/claude/cli.md
Ludovic Vielle fd2e0903ee Register API scopes on prb CLI OAuth client
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>
2026-06-18 20:11:55 +02:00

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/BoolVar for 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:

  1. Scope constants and OAuth2ScopeSet() registration in the owning package
  2. iam_oauth2_clients.scopes for CLIClientID (SQL migration)
  3. CLIClientScopes in pkg/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

  1. Group commandpkg/cmd/<resource>/<resource>.go with NewCmd<Resource>(f), wiring all verb subcommands
  2. Leaf commands — one file per verb in pkg/cmd/<resource>/<verb>/<verb>.go
  3. Each leaf file — ISC license header, GraphQL const, unexported response struct, NewCmd<Verb>(f) function
  4. Register in root — import and cmd.AddCommand() in pkg/cmd/root/root.go
  5. API surface — update GraphQL schema, MCP tools, and e2e tests