Generate or update CLI commands in cmd/api/ from the OpenAPI spec and Go SDK. Use when the API spec changes or new endpoints are added.
You generate and maintain CLI command files in cmd/api/ that wrap the Unkey Go SDK.
Each API endpoint from the OpenAPI spec becomes a CLI subcommand.
Modes (determined by the argument passed after /gen-api-cli):
keys, apis): generate/update that group onlyall or no argument: generate/update all groupscheck: audit mode — do NOT write any files. Instead, compare all existing commands against the OpenAPI spec and report discrepanciesWhen the argument is check:
cmd/api/Before generating anything, update the Go SDK to the latest version:
go get github.com/unkeyed/sdks/api/go/v2@latest
go mod tidy
svc/api/openapi/openapi-generated.yaml — the source of truth for all endpoints.go.mod under github.com/unkeyed/sdks/api/go/v2, then read the SDK source from the Go module cache at ~/go/pkg/mod/github.com/unkeyed/sdks/api/go/v2@<version>/. You MUST read the actual SDK files to verify struct names, field names, and field types. Do not guess.cmd/api/util/ (shared helpers — do NOT modify), cmd/api/root.go, and any existing subpackages to understand the current state.Parse all paths from the OpenAPI spec. They look like /v2/{group}.{action}.
Group them by resource:
apis — endpoints matching apis.*keys — endpoints matching keys.*identities — endpoints matching identities.*permissions — endpoints matching permissions.*ratelimit — endpoints matching ratelimit.*analytics — endpoints matching analytics.*Skip deploy.* (already handled by cmd/deploy) and liveness (GET health check, not useful as CLI).
Each group is a subpackage under cmd/api/. Each command is its own file:
cmd/api/
├── root.go # Cmd, imports and registers group subpackages
├── util/
│ ├── client.go # CreateClient, APIAction
│ ├── output.go # Output
│ ├── errors.go # FormatError
│ └── flags.go # RootKeyFlag, APIURLFlag, ConfigFlag, OutputFlag
├── apis/
│ ├── root.go # Cmd (group command), registers leaf commands
│ ├── create_api.go
│ ├── delete_api.go
│ └── ...
├── keys/
│ ├── root.go
│ ├── create_key.go
│ ├── verify_key.go
│ └── ...
package {group}
import "github.com/unkeyed/unkey/pkg/cli"
var Cmd = &cli.Command{
Name: "{group}",
Usage: "...",
Description: "...",
Commands: []*cli.Command{
{leafCmd1},
{leafCmd2},
},
}
Each leaf command lives in its own file named with the kebab-case action using underscores: create_api.go, verify_key.go, list_overrides.go.
The variable name is unexported: createAPICmd, verifyKeyCmd, listOverridesCmd.
After creating a new group subpackage, import it in cmd/api/root.go and add {group}.Cmd to the Commands slice. Only add entries that don't already exist.
Every group root.go must append util.Disclaimer to its Description:
Description: "Create, read, and delete API namespaces." + util.Disclaimer,
Leaf commands must also append util.Disclaimer to their Description, after the docs link:
Description: `...
For full documentation, see https://www.unkey.com/docs/api-reference/v2/...` + util.Disclaimer,
Command name: Convert the action part of the operationId to kebab-case.
createApi → create-apilistKeys → list-keysverifyKey → verify-keyaddPermissions → add-permissionslimit → limitmultiLimit → multi-limitgetVerifications → get-verificationsFile name: kebab-case action with underscores: create_api.go, verify_key.go
Variable name: camelCase with SDK acronym rules: createAPICmd, verifyKeyCmd
The SDK applies Go naming conventions with acronym uppercasing:
Operation ID {group}.{action} maps to:
apis → Apis, ratelimit → RatelimitcreateApi → CreateAPI, verifyKey → VerifyKeyAcronym rules — these substrings get uppercased when followed by uppercase or end-of-string:
Api → API (but Apis stays Apis)Id → ID (but Identity stays Identity)Url → URLType names:
$ref — e.g., V2ApisCreateApiRequestBody → V2ApisCreateAPIRequestBodyV2ApisCreateAPIResponseBodyCRITICAL: Always verify type and field names by reading the actual SDK source files in the Go module cache. grep for the type name to confirm. If a name is wrong, the code won't compile.
This is extremely important. Descriptions must be copied from the OpenAPI spec as closely as possible.
Usage (short one-liner)Use the first sentence of the OpenAPI path description field.
Description (full help text)Copy the entire OpenAPI path description field verbatim, with these adjustments:
**text** → text, `code` → codeRequired permissions:
- api.*.create_api
- api.<api_id>.create_api
Examples field instead (see below)https://www.unkey.com/docs/api-reference/v2/{group}/{slug} but the slugs don't always match the operation ID (e.g., keys.createKey → /v2/keys/create-api-key). Always verify the URL exists. Format as:
For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/create-api-key
ExamplesUse the Examples field ([]string) on the Command struct. Each entry is one example invocation.
These are rendered in a separate EXAMPLES section at the bottom of --help output.
Always use --flag=value syntax (not --flag value) in examples for clarity.
Use a short, one-sentence summary of the OpenAPI property description. Since every command links to full docs, flag descriptions should be concise — just enough to know what the flag does. Do NOT copy the full multi-sentence OpenAPI description.
Every leaf command MUST include these four flags first:
util.RootKeyFlag(),
util.APIURLFlag(),
util.ConfigFlag(),
util.OutputFlag(),
Then map OpenAPI request body properties to CLI flags:
| OpenAPI type | SDK Go type | CLI flag | Read value |
|---|---|---|---|
string (required) | string | cli.String("name", "desc", cli.Required()) | cmd.String("name") |
string (optional) | *string | cli.String("name", "desc") | check non-empty, then &v |
integer (required) | int64 | cli.Int64("name", "desc", cli.Required()) | cmd.Int64("name") |
integer (optional) | *int64 | cli.Int64("name", "desc") | check non-zero, then &v |
boolean (optional) | *bool | cli.Bool("name", "desc", cli.Default(X)) | ptr.P(cmd.Bool("name")) — use pkg/ptr for the pointer |
Boolean flag defaults: Look up default: in the OpenAPI spec. Use cli.Default(true) or cli.Default(false) accordingly. For partial-update endpoints where omitting a boolean means "don't change" (no default: in spec), do NOT set a default — use cmd.FlagIsSet("name") to check if the user explicitly passed it:
if cmd.FlagIsSet("enabled") {
req.Enabled = ptr.P(cmd.Bool("enabled"))
}
| array of strings | []string | cli.StringSlice("name", "desc") | cmd.StringSlice("name") |
| object / map / nested | complex | cli.String("name-json", "JSON: ...") | json.Unmarshal |
JSON flags: For complex types exposed as --*-json flags, keep the flag description focused on what the field does. Show the JSON shape in the command's Examples field instead, with realistic values. Every command that has JSON flags MUST include at least one example showing their usage.
Flag naming: Convert camelCase property names to kebab-case: apiId → api-id, externalId → external-id.
Every leaf command uses a plain func(ctx, cmd) error action. No wrappers — each command
explicitly creates the client, times the call, formats errors, and prints output:
Action: func(ctx context.Context, cmd *cli.Command) error {
client, err := util.CreateClient(cmd)
if err != nil {
return err
}
// Build request from flags
req := components.V2ApisCreateAPIRequestBody{
Name: cmd.String("name"), // required: assign directly
}
// Optional string:
if v := cmd.String("prefix"); v != "" {
req.Prefix = &v
}
// Optional int64:
if v := cmd.Int64("limit"); v != 0 {
req.Limit = &v
}
// Optional bool — look up the default in the OpenAPI spec (check `default:` on the property).
// Use cli.Bool with cli.Default and ptr.P from pkg/ptr:
req.Enabled = ptr.P(cmd.Bool("enabled")) // default: true in spec
req.Decrypt = ptr.P(cmd.Bool("decrypt")) // default: false in spec
// For partial-update endpoints where omitting a bool means "don't change":
if cmd.FlagIsSet("enabled") {
req.Enabled = ptr.P(cmd.Bool("enabled"))
}
// String slice:
if v := cmd.StringSlice("permissions"); len(v) > 0 {
req.Permissions = v
}
// JSON field (complex object):
if v := cmd.String("meta-json"); v != "" {
var meta map[string]any
if err := json.Unmarshal([]byte(v), &meta); err != nil {
return fmt.Errorf("invalid JSON for --meta-json: %w", err)
}
req.Meta = meta
}
// Call SDK and handle errors
start := time.Now()
res, err := client.Apis.CreateAPI(ctx, req)
if err != nil {
return fmt.Errorf("%s", util.FormatError(err))
}
return util.Output(cmd, res.V2ApisCreateAPIResponseBody, time.Since(start))
},
See cmd/api/apis/create_api.go for a complete working example. Follow this pattern exactly for all new commands.
The project uses strict linters via bazel nogo. Watch out for:
nil for pointer fields you're not setting:
req := components.V2ApisListKeysRequestBody{
APIID: cmd.String("api-id"),
Limit: nil,
Cursor: nil,
}
Write, Fprintln, Close, etc.For each command, generate a documentation page at docs/product/cli/{group}/{command-name}.mdx.
Each command gets its own .mdx file using Mintlify components:
---