Development
This guide is for developers who want to fix bugs, add commands, or understand how ufi is wired. It covers the repo layout, the build/test loop, the schema-snapshot gate, the rules for adding a command, and the contribution workflow.
Prerequisites
Section titled “Prerequisites”Go 1.25 or later. No CGO required — CGO_ENABLED=0 produces a fully static binary.
git clone https://github.com/rnwolfe/ufi && cd ufigo build ./...go vet ./...go test ./...go run ./cmd/ufi --helpRepository layout
Section titled “Repository layout”cmd/ufi/ main.go # os.Exit(cli.Run(...)); all logic is in internal/
internal/ cli/ root.go # kong grammar, global flags, Runtime, Guard, emitError helpers.go # listEnvelope — the stable list envelope constructor runtime.go # Runtime helper methods: siteList/siteGet/siteAction/siteDelete, # configWrite/readData, fenceItems/fenceValue, emitList device.go # device noun (list, get, stats, restart, port-cycle) core.go # info, site, client, wifi, voucher nouns config.go # network, firewall, acl, dns, traffic-list, apply (declarative config) cloud.go # hidden stub — returns UNSUPPORTED (exit 11) + issue pointer misc.go # auth, doctor, schema, agent, version suggest.go # "did you mean" typo suggestion cli_test.go # offline contract tests (mutation gate, dry-run, hash, cloud stub…) schema_golden_test.go # contract-stability snapshot gate integration_test.go # mock-console integration tests (envelope shape, fencing, idempotent delete…) testdata/schema.json # committed schema snapshot (the golden file)
unifi/ client.go # HTTP client: X-API-KEY auth, self-signed TLS, offset→cursor pagination, # error classification, generic camelCase→snake_case conversion client_test.go # toSnake, cursor round-trip, pagination, error-classification unit tests
auth/ auth.go # env → OS keyring → 0600-file credential resolution
plan/ plan.go # declarative-config plan persistence ($XDG_STATE_HOME/ufi/plans/<hash>.json)
output/ output.go # Writer: stdout=data / stderr=warnings, --format, --select, --limit
errs/ errs.go # stable exit-code constants + CLIError type + MutationBlocked/NotFound constructors
version/ version.go # version check against GitHub releases (fail-silent, UFI_NO_UPDATE_CHECK=1)
skill/ SKILL.md # embedded agent contract — `ufi agent` prints this
integration-openapi.json # console's own OpenAPI 3.1 spec (authoritative path/field/enum reference)spec.md # single source of truth for the ufi contractThe cloud.go stub is intentionally hidden from --help and schema. The cloud surface is
deferred; see Local-only scope below.
Build, vet, format, and test
Section titled “Build, vet, format, and test”Run these before every commit — CI enforces all four:
go build ./... # must succeedgo vet ./... # must be cleangofmt -l . # must print nothing (no unformatted files)go test ./... # must pass (includes the schema-snapshot gate)go test ./... runs three suites without any network access:
- Offline contract tests (
internal/cli/cli_test.go): mutation gate,--dry-run, config preview hash,applywith unknown hash,--writealias,UFI_HELP=agent, cloud stub, schema structure, “did you mean”,version --check. - Mock-console integration tests (
internal/cli/integration_test.go): a realhttptest.Serveremulates the Integration API base path (/proxy/network/integration/v1). These verify the list envelope shape, snake_case conversion, untrusted-text fencing, idempotent voucher delete, EMPTY exit code, and auth status. unificlient unit tests (internal/unifi/client_test.go):toSnake, cursor round-trip, offset-based pagination with a live cursor, and error-classification for zone-based-firewall (UNSUPPORTED, exit 11) and 401 (AUTH, exit 4).
The schema-snapshot gate
Section titled “The schema-snapshot gate”internal/cli/testdata/schema.json is a committed golden file of the full ufi schema
output (minus the volatile version field). TestSchemaGolden diffs the live schema
against it on every go test run. If they diverge, the test fails with:
schema drift vs testdata/schema.json — review the diff; if intended, regenerate with UFI_UPDATE_GOLDEN=1This is intentional: a silent rename or removal of a command, flag, or exit code is a breaking change. The gate forces you to review the diff before landing it.
When you intentionally change the command surface, regenerate the snapshot and commit the diff as part of your PR:
UFI_UPDATE_GOLDEN=1 go test ./internal/cli -run TestSchemaGoldengit diff internal/cli/testdata/schema.json # review carefullyThe schema output is also the source for ufi schema at runtime — agents depend on it
for self-description. Treat it like a public API. Output fields are append-only: adding
a field is non-breaking; renaming or removing one is breaking and needs discussion.
integration-openapi.json — the authoritative field reference
Section titled “integration-openapi.json — the authoritative field reference”integration-openapi.json is the UniFi console’s own OpenAPI 3.1 spec (verified on
Network 10.4.57). It is the canonical reference for:
- API paths and HTTP methods
- Request/response field names (camelCase as returned by the API)
- Enum values (device states, action types, etc.)
When the console’s API behavior and spec.md ever disagree, trust integration-openapi.json
and update spec.md. Never invent a field or endpoint that isn’t in the spec.
Adding a command
Section titled “Adding a command”Follow this checklist to keep the contract intact. CI will catch most misses through the schema-snapshot gate and the test suite.
1. Wire the grammar in root.go
Section titled “1. Wire the grammar in root.go”Add your noun or verb to the CLI struct and register it with kong. Follow the existing
noun-verb convention (DeviceCmd → device list, device restart, …). Read-only commands
live in the “read-first core” block; declarative-config commands go in the “declarative
config” block.
2. Implement in the appropriate file
Section titled “2. Implement in the appropriate file”| Kind | File |
|---|---|
| Read-only device/client/wifi/site/info | core.go or device.go |
| Declarative config (network, firewall, acl, dns, traffic-list) | config.go |
| Auth / doctor / schema / agent / version | misc.go |
3. Every mutation calls Guard first
Section titled “3. Every mutation calls Guard first”The read-only-by-default gate is enforced by rt.Guard(op) in internal/cli/root.go. Call
it as the very first line of any state-changing Run method, before any API call:
func (c *DeviceRestartCmd) Run(rt *Runtime) error { if err := rt.Guard("device restart"); err != nil { return err } // ... then honor --dry-run ...}A missing Guard call is a contract violation — the mutation gate is part of the
safety model.
4. Single-target mutations support --dry-run
Section titled “4. Single-target mutations support --dry-run”After Guard passes, check rt.Cfg.DryRun. If set, emit a preview object and return
without calling the API:
if rt.Cfg.DryRun { return rt.Out.Emit(map[string]any{ "dry_run": true, "action": "RESTART", "id": c.ID, })}5. Declarative config goes through configWrite / apply <hash>
Section titled “5. Declarative config goes through configWrite / apply <hash>”Never issue a direct write for declarative-config commands. Use the thin wrappers in
internal/cli/config.go (configCreate, configUpdate, configDelete, configReorder) —
they parse --data, build the request body, then delegate to rt.configWrite in
internal/cli/runtime.go, which computes a plan and 12-hex hash, persists it under
$XDG_STATE_HOME/ufi/plans/<hash>.json, and emits the plan summary. The operator then
runs ufi apply <hash> --allow-mutations to execute exactly that plan. This closes the
TOCTOU gap.
See Config for the full user-facing flow.
6. List commands emit the stable list envelope
Section titled “6. List commands emit the stable list envelope”Wrap results in listEnvelope(items, count, nextCursor) from internal/cli/helpers.go.
The shape { schemaVersion, items, count, nextCursor } is a contract — agents parse it.
For most list commands use rt.emitList(res) (defined in internal/cli/runtime.go), which
handles the envelope and automatically sets rt.ExitCode = errs.ExitEmpty (exit 3) when
res.Count == 0, so the envelope is always emitted even on empty results:
res, err := c.List(ctx, "/sites/"+site+"/"+subpath, rt.listOpts())if err != nil { return err}return rt.emitList(res)See Output for the full envelope spec.
7. Fence network-controlled free text
Section titled “7. Fence network-controlled free text”If a field in your response comes from network-controlled free text (device name, client
hostname/note, voucher name), wrap it when rt.Fence is true. See
internal/cli/runtime.go for the fenceItems and fenceValue helpers, or pass the
untrusted key names as variadic arguments to rt.siteList / rt.siteGet. Operator-set
fields (site name, network name) are NOT fenced. See Agents for the
prompt-injection fencing details.
8. Errors use internal/errs
Section titled “8. Errors use internal/errs”Return errs.NotFound(kind, id), errs.New(exit, code, msg, remediation), or a wrapped
upstream *errs.CLIError. Never fmt.Errorf alone for user-facing errors — structured
errors carry machine-readable codes and remediation hints. See Errors and
Exit Codes.
9. Update the schema snapshot, SKILL.md, docs, and site in the same PR
Section titled “9. Update the schema snapshot, SKILL.md, docs, and site in the same PR”This is the most commonly missed step. When your PR changes any command, flag, exit code, or value proposition:
- Regenerate the schema golden file:
UFI_UPDATE_GOLDEN=1 go test ./internal/cli -run TestSchemaGolden - Update
internal/skill/SKILL.md(the embedded agent contract thatufi agentprints). - Update or add the relevant doc page under
site/src/content/docs/. - Update
site/src/pages/index.astroandREADME.mdif the command surface or headline examples changed. - Rebuild the site:
cd site && pnpm build(regeneratesllms.txt). - Regenerate OG cards if any page title changed:
cd site && node scripts/gen-og.mjs
The site lives in site/ (Astro + Starlight). One shared token source
(site/src/styles/tokens.css) styles both the landing page and the docs — never
hand-copy tokens.
10. Add tests
Section titled “10. Add tests”- Offline contract test in
cli_test.gofor gate behavior, exit codes, and output shape. - Mock-console integration test in
integration_test.gofor real HTTP round-trips usingmockConsole(t). - Unit test in
internal/unifi/client_test.gofor new API client behavior or error classification.
Output contract
Section titled “Output contract”All output routing flows through internal/output.Writer:
- stdout — data only (list envelopes, single-object responses, plan previews).
- stderr — everything else: warnings, error objects, the
--insecureTLS warning. - JSON — 2-space indented, HTML-escaping off (
SetEscapeHTML(false)— so URLs survive). - Field names — always snake_case. The
unificlient converts camelCase API fields generically:macAddress → mac_address,uptimeSec → uptime_sec, etc.
Output fields are append-only. Adding a new field to an existing command is safe and does not require a schema-version bump. Renaming or removing a field is breaking.
The schemaVersion field on the list envelope is bumped only for breaking output changes,
not for additive ones.
Error classification
Section titled “Error classification”internal/unifi/client.go classifies upstream UniFi error responses
({statusCode, statusName, code, message}) into *errs.CLIError values with the
appropriate exit code. When adding a new command that hits a new endpoint, check whether
the console can return any novel error shapes and add classification coverage in
client_test.go.
Notable classifications:
| UniFi response | ufi code | exit |
|---|---|---|
| HTTP 401 | AUTH_REQUIRED |
4 |
| HTTP 404 | NOT_FOUND |
5 |
| HTTP 429 | RATE_LIMITED |
7 |
api.firewall.zone-based-firewall-not-configured |
UNSUPPORTED |
11 |
Blocked without --allow-mutations |
MUTATION_BLOCKED |
12 |
See Exit Codes and Errors for the full table.
Local-only scope
Section titled “Local-only scope”ufi targets Ubiquiti’s official local Network Integration API
(https://{host}/proxy/network/integration/v1). The Site Manager cloud surface is
intentionally deferred — cloud.go is a hidden stub that returns UNSUPPORTED (exit 11)
with a pointer to the issue tracker. It is absent from --help and ufi schema output
(nodeToMap skips hidden nodes), and TestCloudStubUnsupported in cli_test.go guards
this contract.
Please open an issue before adding cloud, legacy, or unofficial-API behavior. The client
groundwork (unifi.NewCloud, CloudBase) exists in the codebase for future re-enabling
once a cloud key path is validated.
Conventional Commits and DCO
Section titled “Conventional Commits and DCO”Use Conventional Commits — they drive the changelog and version bumps:
feat(cli): add traffic-list reorder commandfix(client): retry on 503 before returning RETRYABLEdocs: document the apply flowchore: bump go.mod to 1.25Sign off every commit (DCO — no CLA):
git commit -s -m "feat(cli): add wifi broadcast get command"Pull request checklist
Section titled “Pull request checklist”Before opening a PR:
-
go build ./... && go vet ./... && gofmt -l . && go test ./...all pass - Schema golden regenerated if the command surface changed
-
internal/skill/SKILL.mdupdated if the agent contract changed - Docs page updated or added under
site/src/content/docs/ - Landing and README updated if the command surface or examples changed
- Site rebuilt (
cd site && pnpm build) - No build artifacts,
dist/,node_modules/, or.vercel/committed - Secrets never appear in argv or logs
Related pages: Safety Model · Output · Exit Codes · Errors · Agents · Config