Conventions for contributors and agents working on vector-cli.
Exported NewXxxCmd() *cobra.Command for top-level command groups and nested
subgroups that are referenced from another file (e.g., NewSiteSSHKeyCmd() is
called from site.go). Unexported newXxxCmd() for leaf commands that live in
the same file. The rule: exported = referenced cross-file, unexported = same file
only.
Group constructors build the command, call cmd.AddCommand(...) for each
subcommand, and return. They never set RunE.
Leaf constructors build the command, set RunE (inline closure or factory
function), register local flags, and return.
Leaf commands follow a canonical sequence. Not every step applies to every command, but the order is fixed:
requireApp(cmd)— extract App from context, verify auth token- Read flags / build request body
- Confirmation prompt (destructive operations only)
- API call (
app.Client.Get/Post/Put/Delete) defer func() { _ = resp.Body.Close() }()io.ReadAll(resp.Body)parseResponseData(body)orparseResponseWithMeta(body)- JSON branch —
if app.Output.Format() == output.JSON { return app.Output.JSON(...) } json.Unmarshalintomap[string]anyor[]map[string]any- Format and output (
app.Output.Table,app.Output.KeyValue,app.Output.Message) return nil
For repetitive site actions, use the siteActionRunE and sitePostActionRunE
factory functions instead of duplicating the sequence.
Format detection follows --json > --no-json > TTY auto-detect (TTY → Table,
piped → JSON). The --jq flag forces JSON mode and is mutually exclusive with
--no-json.
Writer methods for formatted output:
app.Output.JSON(v)— pretty-printed JSON (applies jq filter if set)app.Output.Table(headers, rows)— tabular output via tabwriterapp.Output.KeyValue(pairs)— right-aligned key: value pairs (show commands)app.Output.Pagination(page, lastPage, total)— "Page X of Y (Z total)"app.Output.Message(msg)— plain text line
Standalone Print* helpers (PrintTable, PrintJSON, PrintKeyValue,
PrintPagination, PrintMessage, PrintError) are used when no Writer is
available (e.g., printLogEntries writing to cmd.OutOrStdout()).
Wrap errors with fmt.Errorf("failed to <verb> <noun>: %w", err). Use a
consistent action phrase per command (e.g., "failed to list sites",
"failed to create site").
Exit codes are mapped from HTTP status in api.exitCodeForStatus:
| HTTP Status | Exit Code | Meaning |
|---|---|---|
| 401, 403 | 2 | Authentication/authorization |
| 422 | 3 | Validation error |
| 404 | 4 | Not found |
| 5xx | 5 | Server error |
| other | 1 | General error |
Return *api.APIError directly for client-side validation failures (e.g.,
missing required flag), setting ExitCode to match the equivalent HTTP status
category.
All leaf command flags are local (cmd.Flags()). Persistent flags live only
on the root command: --token, --json, --no-json, --jq.
Flag names use kebab-case (--customer-id, --php-version, --cache-tag).
Use cmd.Flags().Changed("flag-name") to distinguish "flag not passed" from
"flag passed with zero value" — required for optional PATCH/PUT fields that
should only be included in the request body when explicitly set.
Token precedence: --token flag > VECTOR_API_KEY env > OS keyring.
Config directory: VECTOR_CONFIG_DIR env > XDG_CONFIG_HOME/vector >
~/.config/vector (Linux/macOS) or %APPDATA%/vector (Windows).
Keyring disabled via VECTOR_NO_KEYRING env (any non-empty value).
Group commands (resource nouns like site, env, waf, backup) must not
set RunE. Bare invocation shows help automatically via Cobra.
The root command is the only exception — it sets RunE to handle --version.
One file per command group in internal/commands/. Nested subgroups get their
own file named with underscores: site_ssh_key.go, waf_blocked_ip.go,
env_secret.go.
Tests mirror source files: site_test.go, waf_blocked_ip_test.go.
Shared helpers live in helpers.go (requireApp, pagination, parsing,
formatting, confirm).
Declare a package-level const at the top of each file:
const sitesBasePath = "/api/v1/vector/sites"For nested resource paths, use helper functions:
func wafBlockedIPsPath(siteID string) string {
return sitesBasePath + "/" + siteID + "/waf/blocked-ips"
}Three groups separated by blank lines, each alphabetically sorted:
- Standard library
- Third-party modules
- Project-internal (
github.com/built-fast/vector-cli/...)
goimports enforces this.
Within a command file, declarations follow this order:
- Package-level constants (base paths)
- Package-level variables (if any)
- Exported group constructor (
NewXxxCmd) - Unexported leaf constructors (in the same order as
AddCommandcalls) - Private helper functions
Same-package tests (package commands). Test files follow these patterns:
Command builder: buildXxxCmd(baseURL, token, format) returns
(*cobra.Command, *bytes.Buffer, *bytes.Buffer) — root command wired with App
context, stdout buffer, stderr buffer.
Test server: newXxxTestServer(validToken) returns *httptest.Server with
a method + path switch dispatching fixture responses.
Fixture variables: Package-level var xxxResponse = map[string]any{...} for
each endpoint response.
Confirm override: Replace confirmReader with strings.NewReader(...) and
restore via t.Cleanup.
Keyring: Call keyring.MockInit() per test (not TestMain). Use
t.Setenv("VECTOR_CONFIG_DIR", t.TempDir()) to isolate config.
Assertions: require for preconditions and fatal checks, assert for
outcome verification. Prefer require.NoError / require.Error for error
checks that guard subsequent assertions.
E2E tests: BATS scripts in e2e/ using a Prism mock server against the
OpenAPI spec (e2e/openapi.yaml).