From fb56e1046d05cead42e72b9c8ae261cf0107e7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 1 Oct 2026 10:26:51 -0700 Subject: [PATCH] docs: the CLI had no page, and a domain's completeness rule had no home Audited every subject the add-a-domain skill cites against the corpus. 25 of 30 had a home. Five did not, and four of those were the same hole: there was no page for the CLI at all, while the provider, the agent, domains, the SDK, the UI, exec, audit, permissions, the transport and the job system each have one. cli.md states what the layer is: a shell over the SDK that parses flags, calls one method and renders, holding no logic the SDK does not. One command per endpoint in a file named after the path it serves. Identifiers as required flags rather than positional arguments. Two flags declared once and inherited, --json on the root and --target on client node. JSON returning the response's raw bytes before anything is formatted, so a new field reaches script consumers without a command changing. Four shared renderers rather than per-command formatting. One error handler, called by all 120 commands that can fail. The fifth was the cross-layer rule, that a domain is a provider, a processor, a spec, a handler, an SDK service, commands, docs and permission tables, and missing one of those is a bug rather than a smaller domain. That is a rule about building a domain, so it goes in domains.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c --- components/osapi/README.md | 1 + components/osapi/cli.md | 95 +++++++++++++++++++++++++++++++++++++ components/osapi/domains.md | 21 ++++++++ 3 files changed, 117 insertions(+) create mode 100644 components/osapi/cli.md diff --git a/components/osapi/README.md b/components/osapi/README.md index 87b3c91..6f957aa 100644 --- a/components/osapi/README.md +++ b/components/osapi/README.md @@ -49,6 +49,7 @@ A handler validates and delegates. It never touches the operating system. | [Building a domain](domains.md) | What a new endpoint touches, in what order, and what is forced by tooling | | [The embedded UI](ui.md) | The dashboard compiled into the binary, and what it does not verify | | [The Go SDK](sdk.md) | What a service owes, the five naming rules, and the seven methods that break them | +| [The CLI](cli.md) | A thin shell over the SDK: one command per endpoint, two inherited flags, and four shared renderers | | [Running commands](exec.md) | The five ways to run one, why ten minutes is a ceiling, and where a secret goes | | [Permissions](permissions.md) | The 37 permissions, the three roles, and why a direct permission overrides them | | [The audit trail](audit.md) | What is recorded, why redaction is a denylist, and what a read does not capture | diff --git a/components/osapi/cli.md b/components/osapi/cli.md new file mode 100644 index 0000000..b43addc --- /dev/null +++ b/components/osapi/cli.md @@ -0,0 +1,95 @@ +# The CLI + +`osapi client ...` is a thin shell over the SDK. It parses flags, calls one SDK +method, and renders the answer. No command talks to the controller directly, and +no command holds logic the SDK does not. + +That thinness is the point. A command that decided anything would be a second +place the decision lives, and the SDK is the one consumers other than the CLI +already use. + +## One command per endpoint + +A domain gets a parent command and one subcommand per endpoint, in files named +after the path they serve: `cmd/client_node_sysctl_get.go` serves +`GET /api/node/{hostname}/sysctl`. There were 156 such files in October 2026. + +The file layout is the routing table. A reader looking for what +`osapi client node sysctl get` does finds it by name rather than by searching. + +## Values arrive as flags, never as positional arguments + +An identifier is a named flag, marked required, not the first bare word after +the subcommand: + +``` +osapi client node sysctl get --key net.ipv4.ip_forward +``` + +Positional arguments read fine with one of them and stop reading at two, and a +flag can be made required by the parser rather than by a length check somebody +has to write. Required-ness is declared, so the error for omitting it is the +parser's and is the same everywhere. + +## Two flags every command inherits + +`--json`, `-j` is global, declared once on the root command. + +`--target`, `-T` is declared once on `client node` and defaults to `_all`. It +accepts a hostname, the reserved values `_any` and `_all`, or a label selector +such as `group:web.dev`. Nothing per-domain redeclares it, which is why its +meaning cannot drift between domains. + +## JSON returns before anything is formatted + +When `--json` is set, the command prints the response's raw JSON and returns. It +does not render a table and then serialize it. + +```go +if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return +} +``` + +What a script sees is therefore what the API sent, not a reconstruction of it. A +field added to a response reaches `--json` consumers without any command +changing. + +## Rendering is four shared helpers, not per-command formatting + +They live in `internal/cli`: + +| Helper | For | +| ------------------- | ----------------------------------------------- | +| `PrintKV` | a single labelled value, such as the job ID | +| `PrintCompactTable` | rows, including the broadcast result table | +| `PrintErrors` | per-row errors beneath the rows | +| `PrintRawOutput` | output that is already text, such as a log tail | + +`BuildBroadcastTable` turns `[]ResultRow` into headers and rows, so a one-host +answer and a forty-host answer render through the same path. A domain that +formatted its own table would be the one that looks different. + +## Errors go through one handler + +`cli.HandleError` unwraps an `APIError`, logs the status code and message, and +exits non-zero. 120 of the commands call it, which is every command that can +fail. + +A command does not decide what an error means or print its own message. The +status code the API declared is the status code the user is told about. + +## Where this connects + +What the CLI calls, and the envelope every method returns, is [the SDK](sdk.md). + +What `_any`, `_all` and a label selector resolve to is +[the job system](job-system.md). + +What a contributor adds when a new domain needs commands, alongside the seven +other artifacts, is [building a domain](domains.md). + +______________________________________________________________________ + +Written from `cmd/client_*.go` and `internal/cli/`. diff --git a/components/osapi/domains.md b/components/osapi/domains.md index d32682e..3f3ab66 100644 --- a/components/osapi/domains.md +++ b/components/osapi/domains.md @@ -144,6 +144,27 @@ A new one has to be added in four places, and [permissions](permissions.md) says what each omission costs. **A permission that exists in the specification and in no role reaches nobody**, and nothing reports it. +## A domain is in every layer, or it is not done + +A domain is not one artifact. It is a provider, an agent processor and its +registration, a spec and the code generated from it, a handler and its route, an +SDK service, CLI commands, and the documentation pages and permission tables +that name it. + +Missing one of those is not a smaller domain. It is a domain that works until +somebody reaches it the way the missing layer would have been reached, and the +gap shows up as a bug rather than as an absence. + +The check is to pick a finished domain and search for its name across the +repository, then run the same search for the new one. The two lists should have +the same shape. A name that appears in eighty files and a name that appears in +sixty is the answer. + +```bash +grep -rl 'sysctl\|Sysctl' --include='*.go' --include='*.yaml' --include='*.md' . \ + | grep -vE '/gen/|/node_modules/|docs/docs/gen' +``` + ## Where this connects What the provider you are adding must implement, and the three boundary rules it