incident ack or ticket close, while using the VFS and ordinary shell tools
to discover and inspect state.
The authoring contract is public: import CLISpec, CLIInvocation, Option,
Operand, and registerCliSpec from @struktoai/mirage-node (or from
@struktoai/mirage-core in a runtime-neutral host). A leaf receives exactly one
CLIInvocation; it does not depend on Mirage’s executor internals.
Start from the worked example
examples/typescript/cli/pager.ts
is the runnable reference. It defines a fictional, in-memory incident service
so the example needs no account, credentials, or network access. A production
CLI would construct its client from inv.config and keep the same program-tree
shape.
- One immutable
CLISpecis installed twice, with independently validated account config and different head words. - The mutating leaf declares
write: true; policy can classify it as a write. Operand({ type: 'str' })keeps an incident ID textual instead of treating it as a VFS path.- A missing
__proto__identifier exercises the error path. The lookup usesObject.hasOwn, so an untrusted service ID cannot resolve through JavaScript’s prototype chain. - Help, discovery, success, failure, mutation, and cross-account isolation are pinned by one shared truth file in CI for Python and TypeScript.
Define the program tree
ACLISpec extends CommandSpec with identity, nesting, and behavior. A node
has exactly one execution shape:
- A leaf has
fnand may declare options, positional operands, andrest. - A group has
subcommandsand may declare options inherited by its leaves. - A script root has
script; the program parses its own argv.
fn(inv). The invocation contains
both the process view (argv, stdin, env) and the parsed view (config,
paths, texts, flags). Use the parsed view for typed trees. Use argv when
wrapping an API whose vocabulary is easier to preserve verbatim.
configModel is optional. Declare a Zod object schema on the root for an
account or service CLI, and narrow the already-validated inv.config at the
handler boundary. Omit it for a credential-free CLI whose subject is a
workspace file tree. A missing model is not an untyped-config mode: the install
must have no config and inv.config is null.
Mark every mutating leaf write: true. This is policy metadata; it does not
perform the mutation, grant access, or invalidate a service cache by itself.
The handler still owns the service call and its consistency behavior. When a
leaf’s mutation depends on argv, new IOResult({ mutated: ... }) may refine
cache invalidation for that run; it does not change the leaf’s static policy
class.
Install it
Installing a tree directly is the shortest path:./file.mjs:ATTR and package references
such as my-clis/specs:PAGER. Relative files resolve next to the YAML file. A
.js file follows its nearest package.json type; .mjs is unambiguous. A
.ts file uses Node’s type stripping, not compilation, so syntax requiring
transformation must be built first.
Registered names and builtins win over direct references. A name collision
fails rather than silently replacing another program. TypeScript has no Python
entry-point discovery equivalent; use a package reference or call
registerCliSpec from the host.
Registration and installation are host-side lifecycle operations. There is no
shell install or uninstall command, so an agent can use a CLI it was given
but cannot remove it. A deployment that must pin the head word should enforce
that with policy; shell functions can otherwise shadow it like they do in bash.
Validate and discover it
Construction is the first validation boundary. Constructing an invalid tree throws immediately:- Names and aliases are non-empty single words.
- A node has exactly one of
fn,subcommands, orscript. - Group operands belong on leaves; child names and aliases share one namespace.
configModelandscriptare root-only;runtimerequiresscript.- Every inherited option/operand grammar compiles, including duplicate option spelling and ancestor/descendant flag collision checks.
configModel validates each install’s
config before the workspace can execute it. Do not repeat either check in every
leaf.
The spec is also the agent-facing discovery surface:
man and --help use the same renderer, so descriptions, options, operands,
and subcommands cannot disagree. Before handing a CLI to an agent, inspect both
the root help and every write leaf’s help; terse names without descriptions are
technically valid but not reliably discoverable.
The VFS and CLI loop
For an account CLI, the service client comes frominv.config; the CLI does not
consult a mount that mirrors the same account. That would create two sources of
truth. The expected loop is:
- Discover records with
find,grep,cat, orjqover a mount. - Take the stable ID rendered in that record or path.
- Pass the ID to a CLI write verb.
- Read the mount again to verify the new state.
inv.doors; do not
assume those doors exist outside a workspace.
If a CLI and a Mirage resource back the same service, root serves identifies
the resource kinds whose cached reads must be invalidated after a mutating leaf
runs. A write that throws may already have reached the service, so Mirage also
invalidates on that path. Use only registered ResourceName values. A fully
third-party resource name is not yet an open shared-spec extension point; do
not widen CLISpec, Option, or Operand just to route around that
constraint.
Snapshots and credentials
A named typed CLI is saved by spec name. A Zod objectconfigModel doubles as
the redaction schema; mark string credentials with secretStr(). Loading a
snapshot with redacted fields must provide fresh install config through clis;
Mirage refuses to restore redacted credentials as if they were usable values.
configModel, but its result is
opaque to snapshot redaction and is serialized as-is. Prefer a Zod object for
credential-bearing config. The loading process must also make the spec name
resolvable, or pass [spec, config] as that install’s clis override.
Workspace.copy() carries the live spec and config together, including a tree
installed directly in code.
A script CLI is different: the script source is embedded in the snapshot and
its opaque config is serialized verbatim because there is no config model to
identify secrets. Keep credentials in the environment, or provide fresh script
config through clis when loading.
Script CLIs
Usescript: when the program should own its grammar or should have no Mirage
imports:
MIRAGE_CLI_CONFIG. The
program’s stdout, stderr, and exit status become the shell result. A script root
cannot declare configModel, subcommands, or a function leaf; it parses and
documents its own options, including --help.
Slot 0 is the installed name on pyodide, monty, and quickjs, so two installs can
identify themselves and user-facing errors can name the invoked program. The
Node-only local runtime runs CPython with -c, which owns sys.argv[0];
arguments still begin at index 1. A YAML script declares no Mirage grammar, so
every option reaches the program unchanged. A script spec constructed in code
may declare options or operands to opt back into Mirage parsing and generated
help.
Runtime argument spellings differ:
The sandboxed runtimes provide files plus compute, not arbitrary networking or
Node builtins. The Node-only
local runtime selects the host interpreter when
that broader authority is intentional.
Authoring reference
The public constructors and their source comments are authoritative for exact types and defaults:CLISpec and CLIInvocation,
and Option and Operand.
The shared worked-example gate compiles these public imports and pins their
runtime behavior; source-adjacent constructor tests pin the validation rules.
The tables below explain how an author should use that surface.
CLISpec
Option
Operand
CLIInvocation
Checklist for agents
When asking an agent to add a task-specific CLI, give it this page and the worked example, then require these outcomes:- Use only the documented package-root imports; do not copy builtin internals.
- Reuse one
CLISpecacross installs and put account identity in validated per-install config. - Give every node, option, and operand enough description/name information for
manto be useful. - Keep the one-argument leaf signature and read only declared invocation fields.
- Mark writes, test at least one refusal, and verify two installs cannot mutate each other’s state.
- Treat service IDs as untrusted input and use own-key lookup for mapping-backed fakes or clients.
- Keep account service access in config; use
doorsonly for explicitly named workspace files. - Verify snapshot restore with redacted credentials or document why the CLI has no secrets.
- Add or extend a runnable example with shared Python/TypeScript truth coverage when the authoring surface changes.
CLISpec, Option, or Operand field for one program’s
unusual parser behavior. These types are also the command grammar for the whole
repository. Prefer the existing argparse/POSIX-shaped fields, handle a truly
program-specific rule in the leaf, or use a script CLI when the whole program
must parse its own argv.