Agent-First Data v0.27.0: If It Isn't Registered, It Doesn't Run

by Agent-First Kit Contributors

A CLI that accepts each flag individually will accept combinations that mean nothing, and then discovers that at runtime — or doesn't. v0.27.0 replaces the help layer with a closed-world compiler: one registry decides argv parsing, legal combinations, output contracts, and help, and an invocation runs only when it matches exactly one registered shape.

A conventional CLI parser validates arguments one at a time. Each flag is recognised, each value has a type, and if every argument checks out the parser hands you a struct. Whether those arguments mean anything together is left to the program.

So every tool grows a second layer: a run of if statements after parsing that rejects the combinations the parser was happy to accept. --takeover with --render none. --skills-dir without a single named --agent. --dry-run alongside --stream-rows. That layer is invisible to --help, which lists arguments rather than the shapes they legally form, so an agent reading the help cannot tell a supported call from one that will be rejected on contact.

The failure mode is worse than rejection. An argument that is accepted, parsed, and then quietly ignored — --context when --from-source is absent, --inline-max-rows while streaming — produces no error at all. The caller believes it configured something. It didn’t.

The registry is the parser

v0.27.0 replaces help-v1 with a closed-world compiler. A tool registers the finite set of shapes it accepts — each one a partition of that command’s arguments into fixed, required, and optional — and a single cli-spec-v1 registry becomes the source for argv parsing, typed values, legal combinations, output contracts, help, and the offline reference.

An invocation runs only when it matches exactly one registered shape. Zero matches is cli_unregistered_combination. Two matches fails the build, not the call: shapes that overlap are a registry bug, and the compiler refuses to produce a binary that could be ambiguous at runtime.

This inverts what --help can promise. It no longer enumerates arguments and leave the caller to solve the constraint problem; it prints the shapes:

afdata set --help

returns the distinct set-value, set-null, and set-secret shapes, each complete. There is no second level to request. The one thing a second level could omit is the optional arguments — and a caller that stopped at the first would have neither them nor any knowledge they existed. Registered but undiscoverable is the exact defect the design exists to remove, so --help-combination is gone.

What it deleted

We migrated seven tools onto it, which is the part that taught us something. The registry did not just relocate the hand-written checks — it made most of them unnecessary, and revealed that some had never been reachable.

agent-first-mail deleted three files: a dry-run/confirm check, an eleven-table map of hand-maintained error hints, and an allow-list enumerating which commands supported --request-id. The last one is now simply the set of commands that declare the argument. agent-first-psql deleted two hand-rolled scanners that walked raw argv before the real parser ran. agent-first-http deleted its mirror enums — the copies of domain types that existed only to satisfy the parser — and now carries the domain types directly.

agent-first-terminal is the compact illustration. Its ui command had conflicts_with on seven arguments plus a runtime check that the session options require a session id. Both are structural: attaching to a running API cannot configure a session it did not create, and there is nothing to configure without a session. As three shapes, the parser rejects those mixes and one --help shows all three.

And in this crate’s own CLI, a validation of the log level turned out to be unreachable — the registry had already declared level a closed enum, so the parser rejected a bad value long before the check could run. It had been dead for as long as the enum had existed. That is the quiet cost of the two-layer design: you cannot tell, by reading it, which half of your validation is load bearing.

Errors name themselves

CLI failures used to carry a generic cli_error with the actual classification in a second field beside it. Every other error in this crate spells its classification straight into the code — document_path_not_found, document_write_would_corrupt — and the standing instruction to agents is to branch on error.code. CLI errors were the one family that asked you to branch somewhere else.

They now name themselves: cli_unknown_argument, cli_unknown_command, cli_missing_argument_value, cli_invalid_argument_value, cli_duplicate_argument, cli_unexpected_positional, cli_unregistered_combination, cli_invalid_utf8. One key to read, and cli_unregistered_combination in particular tells a caller something precise: every argument was known, the mixture was not, so read the shapes rather than dropping arguments at random.

The exit codes stay a two-way split worth relying on. Exit 2 means the call was never made, so retrying it unchanged cannot help. Exit 1 means it ran and failed.

Secrets, and the difference between naming one and carrying one

Three redaction leaks are closed in all four languages. A _secret parameter in a URL fragment was printed in the clear while the same name in the query was masked — which is exactly where the OAuth implicit flow puts a credential. The redaction policy meant three different things depending on the path it reached. And plain rendering stripped the _secret suffix unconditionally, so turning redaction off printed the live credential and deleted the only mark saying it was one.

Redaction is also path-aware now: a _secret name marks its whole subtree, so credentials_secret.password no longer walks past the marked node.

The subtler fix is about what the suffix means. --reveal-secret is a boolean flag; it asks to reveal a secret, it does not carry one. Deriving sensitivity from the name alone marked it as a credential — putting the bit on something that structurally cannot leak, which reads as a real secret to anything downstream that trusts the bit. A flag has no value to redact, so flags are never marked, and marking one explicitly now fails the build.

Idempotence should not depend on path depth

Document::unset returned false for a missing leaf but errored on a missing ancestor. Both describe the same fact — the key is not there — and which one you hit depended only on how many segments you had to write to name the same nothing. It is idempotent at any depth now. A path that no document could satisfy, like traversing through a scalar, is still an error.

Upgrading

This is a breaking release. Tools built on the removed help-v1 helpers (cli_handle_version_or_help_or_continue, HelpConfig) and the cli-help / cli-help-markdown features must move to the registry; the whole CLI surface is now behind the default-on cli feature. --help --recursive --output markdown is replaced by --docs, which renders the entire registry as Markdown.

The compiler cannot express short flags, so a migrating tool loses them. That is a real cost and worth stating plainly: the convention still permits short flags, but this compiler does not implement them.

The smallest complete example is rust/examples/agent_cli.rs — 174 lines, covering the whole flow from registry to dispatch. The contracts are cli-spec-v1 and cli-help-v2.