Stage 7 · Master
Self-Service Platform API & CLI
Platform CLI Design (Cobra/Go)
Build a developer CLI: command structure, output formats, shell completion, auth, config profiles, and plugin architecture.
Every internal platform eventually needs a command-line interface, because not every workflow belongs in a web portal. Developers live in a terminal — they want to create a service, tail its logs, and roll back a bad deploy without leaving their shell. A well-designed CLI is the fastest, most scriptable front door to the platform API, and its design choices (verbs, output shape, auth model) quietly teach developers how to think about the platform itself. Get the CLI wrong and every workflow built on top of it — CI scripts, onboarding docs, muscle memory — inherits that mistake.
CLI Design Principles
The single biggest mistake teams make is designing the CLI around the infrastructure instead of the developer's intent. A command like platform k8s deployment create forces the developer to already know it's Kubernetes underneath — which defeats the point of a platform. platform service create describes what the developer wants, and lets the platform decide how to realize it (today that might be a Kubernetes Deployment; next year it could be something else entirely, and the CLI's surface area never has to change).
- Intent-driven:
platform service createnotplatform k8s deployment create - Consistent verbs: create, get, list, update, delete, logs, status
- Global flags: --output, --config, --profile, --verbose, --help
- Discoverable:
platform --help,platform service --help, command groups - Scriptable: JSON output, exit codes, no interactive prompts in CI mode
- Fast: <200ms for simple commands, async for long operations
Think of the CLI as a grammar: nouns are resources (service, database, environment), verbs are the same handful of operations repeated across every noun (create, get, list, update, delete). Once a developer learns the verbs for one resource, they already know them for all the others — that consistency is worth more than any single clever command.
Command Structure
Applying that grammar across the whole platform produces a predictable tree: each top-level noun (service, database, environment, template, config, auth) owns the same small set of CRUD and operational verbs. A developer who has never touched platform database can still guess most of its commands correctly just from having used platform service before.
platform
├── service
│ ├── create # Scaffold + provision
│ ├── get / list # Show / list services
│ ├── update / delete
│ ├── logs # Stream/fetch logs
│ ├── status / scale / restart
│ └── env # Manage env vars
├── database
│ ├── create / get / list / delete
│ ├── connect # Open psql tunnel
│ └── backup # Trigger/list/restore backups
├── environment # Preview environments
│ ├── create / get / list / delete
│ └── extend # Extend TTL
├── template # Golden path templates
│ ├── list / get / validate
├── config # Local CLI config
│ ├── view / set / get / profiles / contexts
├── auth
│ ├── login / logout / token / whoami
├── completion / version
└── plugin
├── list / install / uninstall / update
Every resource group repeats the same verb vocabulary. The tree grows wide (more resources) but never deep — no command is more than two levels from the root.
Output Formats
A CLI has two very different audiences reading the same output: a human scanning a terminal, and a script piping that output into jq or a CI step. Trying to serve both with one format always compromises one of them — colorized tables are unreadable to jq, raw JSON is unreadable to a human skimming a list. The fix is to keep one format as the default (readable) and make every other format opt-in via --output.
- Default: Human-readable table (colorized, truncated)
- --output json: Full JSON for scripting
- --output yaml: YAML for GitOps
- --output wide: Table with all columns
- --output name: Just resource names (for piping)
- --no-headers / --fields: fine-tune table output for scripts
Auth & Config Profiles
Most developers work against more than one environment — production, staging, and a local sandbox — and each has its own API endpoint and identity provider. Rather than passing --endpoint and --token on every invocation, the CLI stores named profiles (endpoint + auth + defaults) in a config file, and a context layers a specific cluster/namespace on top of a profile. Switching environments becomes platform config use-profile staging instead of re-typing flags.
current-profile: production
profiles:
production:
api-endpoint: https://api.platform.example.com
auth: { type: oidc, issuer: https://auth.platform.example.com, client-id: platform-cli }
default-output: table
staging:
api-endpoint: https://api-staging.platform.example.com
auth: { type: oidc, issuer: https://auth-staging.platform.example.com }
local:
api-endpoint: http://localhost:8080
auth: { type: none }
contexts:
production-east: { profile: production, cluster: production-east, namespace: platform }
production-west: { profile: production, cluster: production-west, namespace: platform }
Profile = where and how to authenticate. Context = profile + a specific cluster/namespace. Most teams only need profiles; contexts matter once a service runs in multiple regions.
Shell Completion
Discoverability matters more than documentation for a CLI that's used daily. Tab completion — including *dynamic* completion that calls the platform API to suggest real service names — turns "I don't remember the exact command" into a non-issue. Cobra (the Go CLI framework most platform teams use) generates static shell completion scripts for free; dynamic completion just needs a function that queries the API and returns matching names.
Plugin Architecture
As a platform grows, teams inevitably want commands the core CLI shouldn't own — a security team's compliance checker, a data team's migration helper. Rather than merging every request into one binary (and coupling its release cycle to every team), the CLI adopts the same pattern kubectl and git use: plugins are separate executables named platform-<name> on the PATH, discovered at startup and invoked over a small RPC protocol. A crashing plugin never takes down the main CLI.
Best Practices
The last mile of CLI design is the set of small conventions that make a tool feel professional and CI-friendly rather than like a script someone hacked together. None of these are hard to implement individually, but skipping them is exactly what makes a CLI painful to script against.
- Exit codes: 0=success, 1=generic error, 2=usage error, 3=auth error, 4=not found, 5=server error
- No interactive prompts in CI:
--non-interactiveorCI=trueenv var - Progress indicators for long ops: spinner + elapsed time
- Structured logging:
--verbosefor debug,--quietfor only errors - Config precedence: flags > env vars > config file > defaults
- Telemetry: opt-in anonymous usage stats (command, duration, success/error)
- Auto-update:
platform updatechecks GitHub Releases, verifies checksum, replaces binary
Once a CI pipeline branches on platform service create; if [ $? -eq 3 ]; then ..., that exit code is a public API — changing its meaning later is a breaking change just like renaming a REST field. Define the exit code table early and treat it as versioned.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.