Product-local commands in the generated CLI
| Field | Value |
|---|---|
| Status | Accepted |
| Accepted | 2026-11-07, explicitly approved by the maintainer |
| Authors | commixon (commixon), with Claude |
| Created | 2026-11-07 |
| Linear | COM-258; implementation COM-259 (venom-std), adoption COM-260 (venomx) |
| Supersedes | None |
| Superseded by | None |
Summary
This RFC lets a product declare local commands: commands that run
in-process in the product’s own binary, like the shared serve and mcp
bootstraps, instead of being sent to the daemon. The first one is
venomx attach. Local commands are declared in the compiled bootstrap
extension that COM-254 introduced, so the closed format-1 manifest
(RFC-0004) does not change. The generator includes them in CLI parsing,
--help, Markdown and man docs, completions and the complete-schema
digest. They are never daemon verbs or MCP tools, and an interactive
command is left out of the skill. venom-std (COM-259) implements the
generator and runtime; venomx (COM-260) moves attach onto them. The RFC
also settles the question RFC-0007 section 8 deferred to COM-223: key-bound
presentation actions inside a client need no generator extension.
Motivation
RFC-0004 section 7 lets only shared bundles dispatch locally (“product
format-1 verbs always dispatch to the daemon”). RFC-0006 and RFC-0007 make
venomx attach a presentation client in the same binary that is
explicitly not a daemon verb. COM-223 therefore shipped it as an argv
check in main.zig, in front of the generated CLI. The result breaks the
agent contract’s one-schema rule (RFC-0001): venomx --help, venomx docs
and the shell completions do not mention attach, its flags are parsed by
hand, and its usage errors do not follow the generated --json and
exit-code conventions.
More presentation entries are planned: raw attach (COM-224), venom-desk, and venom’s own TUI. Without a shared mechanism, each would add another hand-written intercept. RFC-0007 section 8 requires any shared generator extension for presentation clients to be specified and reviewed first; the maintainer chose to do that in COM-223’s review.
Waiting on this RFC: COM-259 (venom-std), then COM-260 (venomx).
Design
1. Example
venomx declares attach next to its manifest:
const model = cli.manifest.normalize(@import("verbs.zon"));
const App = cli.WithBootstrap(model, cli.manifest.examples(@import("verbs.zon")), .{
.local = .{.{
.name = "attach",
.summary = "Attach this terminal to a session (its lowest-ID window) or one pane. Give exactly one of session or pane.",
.interactive = true,
.params = .{
.{ .name = "session", .summary = "Session ID", .type = .{ .kind = .string } },
.{ .name = "pane", .summary = "Pane ID", .type = .{ .kind = .string } },
.{ .name = "history", .summary = "Scrollback rows to backfill per pane", .type = .{ .kind = .integer, .min = 0 }, .default = "10000" },
},
.result = .{ .kind = .object, .fields = .{} },
.errors = .{
.{ .code = "invalid_params.selector", .summary = "Neither or both of session and pane were given" },
.{ .code = "not_found.session", .summary = "No such session" },
.{ .code = "not_found.pane", .summary = "No such pane" },
.{ .code = "denied.read", .summary = "The caller cannot read the target" },
},
}},
});
It registers the handler at run time:
const status = try App.run(gpa, argv, .{
.stdout = out, .stderr = err, .environ = environ,
.bootstrap = cli.Bootstrap.init(&daemon),
.local = cli.Local.init(&presentation),
});
From a shell:
venomx attach --session s0
venomx attach --pane p3 --history 0
venomx attach --json
{"error":{"code":"invalid_params.selector","message":"give exactly one of --session or --pane","data":{}}}
venomx --help lists attach with the other commands. venomx docs
documents it with “MCP tool: none (local command)” and “Interactive: needs
a terminal on stdin and stdout”. venomx completions zsh completes
attach --session. venomx mcp does not list it, and venomx skill does
not mention it.
2. Declaration
The cli.WithBootstrap extension (COM-254) gains an optional member
local: a tuple of local command records. A record has the same members
as an RFC-0004 section 2 verb record, with these differences:
| Member | Rule |
|---|---|
name |
Required. An RFC-0004 wire name. It MUST NOT equal a shared verb, a product verb, or another local command, and MUST NOT be a dotted prefix of a remote verb or have one as a prefix (CLI paths would collide). Shared reserved-name rules apply. |
summary, params, result, errors, exit, examples |
As in RFC-0004, validated by the same normalizer |
grant |
Optional; MUST be none if given, and normalizes to none. Metadata never authorizes (RFC-0004 section 2): any daemon request the command makes is authorized by the daemon in the normal way. |
streams, item |
Not allowed. A local command does not emit RFC-0002 stream items. |
interactive |
Optional boolean, default false. true means the command needs a terminal on stdin and stdout (section 4) and is for humans (section 3). |
Unknown members MUST fail the build, as in format 1. The manifest file itself is unchanged: local commands exist only in the compiled extension, so format-1 consumers never see a member they don’t know.
3. Generated surfaces
| Surface | Local command |
|---|---|
CLI parsing and --help |
Yes. Same flag syntax, validation, defaults and global options as remote verbs (RFC-0004 section 4). |
| Markdown and man docs | Yes, sorted with every other verb. “MCP tool: none (local command)” as for serve; interactive commands also get “Interactive: needs a terminal on stdin and stdout”. |
| Completions (zsh, bash, fish) | Yes |
| Complete-schema digest | Yes. The normalized record has grant: "none", interactive, and dispatch: "local". Remote product verbs gain no member, so their digests do not change. |
App.schema (daemon dispatch) |
No. The daemon never binds a handler for it. A request naming it fails like any unknown verb (unsupported, exit 10). |
| MCP | No. tools/list omits it; tools/call rejects it as it rejects serve. |
| Skill | Non-interactive commands appear in a “Local commands” section. Interactive commands MUST be omitted: agents use verbs and events, not presentation clients (RFC-0006 agent-contract impact, RFC-0007 section 9). |
The hello.schema digest a daemon advertises is computed from the same
complete model, so a CLI/daemon pair built from the same sources still
matches.
4. Dispatch and outcomes
After parsing and validating parameters, the runtime calls the registered local handler in-process, with:
- the command’s wire name and its validated parameter object, with defaults inserted (borrowed for the call);
- the selected socket options and the environment (the same values remote dispatch would use);
- stdout, stderr, and the interruption flag.
For an interactive command, the runtime first checks that stdin and stdout
are terminals. If not, it fails with unsupported.terminal (exit 10)
without calling the handler. A daemon is never started for a local command;
the startup hook (COM-254) applies only to its eligible remote verb.
The handler returns one of:
- result: a value that MUST validate against
result. The runtime renders it as for a remoteok:--jsonprints it, human mode prints it unless it is an empty object. - failure:
{code, message, data}. The code MUST be declared inerrorsor be a shared class code (invalid_params,unavailable,cancelled, …). The runtime renders it exactly like a remote error, including--jsonand the RFC-0002 class exit code. - status: an exit code. This is used when the command has already reported to the user itself, for example after leaving a full-screen terminal UI. Codes follow the RFC-0002 class table.
An interactive handler owns the terminal while it runs and MUST restore it
before returning, so the runtime’s output lands on the normal screen.
Before entering the UI it SHOULD return failures (not found, denied,
unavailable) as failure outcomes, so --json callers and scripts get
the standard error object.
The build MUST fail if a declared local command has no handler, or if
Local is registered without the declarations it serves (mirroring
RFC-0004’s missing/extra handler rule). Registering no Local is allowed
only for a product that declares no local commands.
5. Presentation actions (RFC-0007 section 8)
Key-bound actions inside a presentation client (scroll, focus, window choice, selection, detach) are not commands of the generated CLI. They have no wire name, no flags and no daemon dispatch, and they never cross a process boundary. Those that change daemon state MUST already be schema verbs sent with the client’s own identity, as RFC-0007 section 8 requires. RFC-0007 also requires the local actions to be typed named actions shared by bindings and in-client command entry; that is product code. No shared generator extension is required for them. A product SHOULD document them in the local command’s help text or its README. Generating them from data is left as an open question.
6. Compatibility
- Format 1 and its consumers are unchanged; no manifest file changes.
- Products without local commands keep identical generated output and digests.
- A product that adds a local command changes its digest.
doctor’s schema-skew check (RFC-0004 section 7) reports that until the daemon is restarted from the same build, as for any schema change. - Adding a local command is a compatible change. Removing or renaming one, or changing its parameters incompatibly, follows RFC-0004’s breaking-change rules.
Agent-contract impact
- Verbs: no new daemon verbs. A local command is a CLI command from the
same verb model, like
serve. --json/ exits: local commands use the generated parsing,--jsonerror objects and RFC-0002 exit classes. New shared errorunsupported.terminal(exit 10).- Events: none.
skill: interactive local commands are omitted; non-interactive ones are listed as local.doctor: unchanged (the digest covers local commands).- Config: none.
- Capability grants: local commands declare
grant = noneand are not authorized by metadata. Every daemon request they make is checked by the daemon with the caller’s resolved identity (RFC-0005). - Headless / OSC: non-interactive local commands work headless; interactive ones refuse without a terminal instead of misbehaving. No OSC impact.
Alternatives
- Add a
dispatchmember to format-1 verb records. This puts local behaviour into the interchange manifest that other tools read, and needs a format bump or a normative RFC-0004 change. COM-254 already chose compiled extensions for local bootstrap metadata; this RFC follows it. - Keep per-product intercepts in
main()(COM-223 as shipped). This costs nothing now, but every presentation client would bypass help, docs, completions and the standard error rendering. That violates the one-schema rule. - Make
attacha daemon verb. RFC-0006 and RFC-0007 rule this out: the stream needs the caller’s terminal, and the daemon must not drive a TUI. - List interactive commands in the skill with a “humans only” note. The skill exists to tell agents what to run, and RFC-0007 forbids recommending agent attachment, so leaving them out is the safer rule.
- Do nothing.
attachstays undiscoverable from generated documentation, and COM-224, venom-desk and venom repeat the intercept.
Open questions
All three were resolved by maintainer acceptance on 2026-11-07, as proposed.
- Mutually exclusive parameters. Resolved: the handler validates
“exactly one of” constraints and declares an
invalid_params.*error (section 1). A genericone_ofconstraint would be a separate RFC-0004 change. - Terminal check error. Resolved:
unsupported.terminal(exit 10). COM-260 moves venomxattachfrom its interim exit 2 to this code. - Documenting presentation actions. Resolved: deferred. Key-bound actions stay in product documentation (section 5) until a second presentation client needs a shared, typed action table.