Skip to main content
A custom CLI is a typed program tree installed under a workspace head word. Use one when an agent should act on a service through verbs such as 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.
The full example deliberately demonstrates details a smaller hello-world would hide:
  • One immutable CLISpec is 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 uses Object.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

A CLISpec extends CommandSpec with identity, nesting, and behavior. A node has exactly one execution shape:
  • A leaf has fn and may declare options, positional operands, and rest.
  • A group has subcommands and may declare options inherited by its leaves.
  • A script root has script; the program parses its own argv.
Every function leaf has the fixed signature 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:
The installed name is the dispatch key and does not have to equal the spec’s root name. This is what makes one tree usable for several accounts. To resolve a bare name from workspace YAML, register the tree before loading the workspace:
The Node config loader also accepts ./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, or script.
  • Group operands belong on leaves; child names and aliases share one namespace.
  • configModel and script are root-only; runtime requires script.
  • Every inherited option/operand grammar compiles, including duplicate option spelling and ancestor/descendant flag collision checks.
Installation is the second boundary: 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 from inv.config; the CLI does not consult a mount that mirrors the same account. That would create two sources of truth. The expected loop is:
  1. Discover records with find, grep, cat, or jq over a mount.
  2. Take the stable ID rendered in that record or path.
  3. Pass the ID to a CLI write verb.
  4. Read the mount again to verify the new state.
A verb may read an unrelated workspace file explicitly named by the user, such as an attachment. That is different from treating a mount as the CLI’s service client. Such a leaf opts into workspace access through 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 object configModel 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.
A normalizer function is also accepted as 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

Use script: when the program should own its grammar or should have no Mirage imports:
The file is embedded at load. Words after the head arrive as argv, piped input arrives on stdin, and config arrives as JSON in 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 CLISpec across installs and put account identity in validated per-install config.
  • Give every node, option, and operand enough description/name information for man to 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 doors only 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.
Do not add a shared 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.