CLISpec) installed on the
workspace by name and separate from the mounts: an account CLI
initializes from its own config, consults no mount, and takes no operand
path. git is the credential-free tier, so it takes no config at all and
reads the repository -C names through the mount ops. The shell
dispatches a line to a CLI when its first word matches an installed
name.
clis: section:
--help, unknown verbs fail with git’s
wording (exit 1), and missing required flags fail with argparse’s
wording (exit 2). Two installs under different head words are two
accounts.
Authoring your own CLI
A CLI is code you point at, the same way a mount points at a resource. In application code you build aCLISpec, and each leaf takes the
line’s one CLIInvocation:
argv,
stdin, env) and the parsed view (config, paths, texts,
flags).
In YAML, cli: references that spec by builtin or registered name, by
package/module:ATTR specifier, or by ./file.mjs:ATTR path. A relative
path resolves next to the config file, the same build-context rule
script: follows.
The referenced file is imported by Node, so it is JavaScript or
TypeScript, not Python: .mjs is the spelling with no caveats, a .js
file follows its nearest package.json type, and a .ts file rides
Node’s own type stripping. Stripping is not compiling, so a .ts spec
using enum, a parameter property, a runtime namespace or a legacy
decorator is refused at load; ship those precompiled or as .mjs.
Unlike Python’s loader this resolution happens in the config layer rather
than in cliSpecFor, because that function lives in core, which has no
filesystem and is synchronous. Installing a spec from code is still
registerCliSpec or workspace.registerCli.
A CLI as a script
script: points at an ordinary program instead of a spec tree, so a CLI
can be authored with no mirage import at all:
.py on the first Python runtime, .js/.mjs on the first JavaScript
one. What it gets is what a native binary would get. The words after the
head arrive verbatim, piped input arrives on standard input, the
install’s config arrives as MIRAGE_CLI_CONFIG in the environment as JSON,
and the workspace mounts are visible as ordinary files through the
runtime’s bridge. Its exit code becomes the line’s $? and its stderr
reaches the shell.
A Python program reads that variable either way the language spells it,
os.getenv('MIRAGE_CLI_CONFIG') or os.environ['MIRAGE_CLI_CONFIG'], on
every Python runtime and on both host languages.
How arguments and input are spelled is the runtime’s own contract, the
same one the python3 and node commands follow:
Slot 0 is the installed name, so
pager --width 80 reads as
['pager', '--width', '80'] and a program’s own messages can say
pager: like any other tool. Two installs of one program are told apart
the same way. Arguments therefore start at index 1 on every runtime. The
exception is local, where a real CPython runs the code as -c and
defines sys.argv[0] itself; mirage cannot fill that slot, so it stays
-c.
A script CLI is one program rather than a verb tree, so it parses its
own arguments, and mirage stays out of the way: it recognizes no flags
of its own, so pager --width 80 reaches the program instead of being
refused, and pager --help is the program’s to answer. A spec that
declares options or positional opts back in, and then mirage parses
the line, renders --help and man from the declaration, and refuses
an undeclared flag; that is only reachable in code, since a YAML entry
declares no grammar.
runtime: pins which entry runs it, and runtime: local
(from @struktoai/mirage-node) escalates a Python script to the host
interpreter, where third-party packages are available. The sandboxed
runtimes are files plus compute: no sockets, and no Node builtins on
quickjs.
Snapshots carry a script CLI by value: the embedded program travels in
the snapshot and Workspace.load rebuilds the install from it, because
there is no name for the loading process to resolve. Its config travels
verbatim, since a script CLI declares no config model and so declares
no secrets; keep credentials in the environment rather than the install
config, or supply them through clis on load.
Discovering an installed CLI
An install is discoverable from inside the shell, so an agent that was never told about it can still find it. This works for your own registered CLI exactly as for a builtin one: every page is rendered from the spec, so there is nothing extra to write.type -t prints one of keyword, function, cli or builtin.
which prints the bare name rather than a path, since mirage has no
PATH, and reports a miss through exit 1 with no output, like GNU
which.
A shell function may shadow a head word, exactly as in bash. It is
reversible with unset -f, bypassable with command linear ..., and
type -a linear lists both layers. Installing and uninstalling a CLI is
a host-side API only (ws.registerCli / ws.unregisterCli): there is no shell
verb for it, so an agent cannot uninstall the tools it was given.
Builtin CLIs
Reading stays on the mount (
cat, grep, jq over the virtual
files); acting goes through the CLI. The mounted tree’s
<name>__<id> path segments supply the IDs the CLI flags take.