The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
265 lines
7.5 KiB
Markdown
265 lines
7.5 KiB
Markdown
# CLI Command Patterns
|
|
|
|
CLI commands use [cobra](https://github.com/spf13/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:
|
|
|
|
```go
|
|
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]`
|
|
|
|
```go
|
|
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:**
|
|
```go
|
|
if *flagOutput == cmdutil.OutputJSON {
|
|
return cmdutil.PrintJSON(f.IOStreams.Out, resources)
|
|
}
|
|
```
|
|
|
|
**Table output:**
|
|
```go
|
|
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:**
|
|
```go
|
|
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):
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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 command** — `pkg/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** — MIT 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
|