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 register_cli_spec from mirage. A leaf receives exactly one
CLIInvocation; it does not depend on Mirage’s executor internals.
Start from the worked example
examples/python/cli/pager.py
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 TypeScript twin uses an own-property check 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 is a CommandSpec plus 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.
config_model is optional. Declare it on the root for an account or service
CLI, and read the validated model from inv.config. 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
None.
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, 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:cli: may instead point directly at module:ATTR or ./file.py:ATTR. Relative
files resolve next to the YAML file. A packaged Python CLI can publish the same
tree without import side effects:
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. Importing an invalid tree raises 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.
config_modelandscriptare root-only;runtimerequiresscript.- Every inherited option/operand grammar compiles, including duplicate option spelling and ancestor/descendant flag collision checks.
config_model 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. Fields declared as Pydantic secrets inconfig_model are redacted. Loading that snapshot must provide fresh install
config through clis=; Mirage refuses to restore redacted credentials as if
they were usable values. The loading process must also make the spec name
resolvable, or pass (spec, config) as that install’s clis= override. A tree
installed directly in live code survives Workspace.copy, which carries the
live spec and revealed config together.
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 config_model, subcommands, or a function leaf; it parses and
documents its own options, including --help.
Slot 0 is the installed name on monty and quickjs, so two installs can identify
themselves and user-facing errors can name the invoked program. Wasi and local
run CPython with -c, which owns sys.argv[0]; arguments still begin at index
- 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.
The sandboxed runtimes provide files plus compute, not arbitrary networking or
third-party packages.
runtime: local selects the host interpreter when that
broader authority is intentional.
Authoring reference
The public constructors and their source docstrings 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 public 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.