feat(shell): discover installed CLIs through man, type and which (#707)

* feat(shell): discover installed CLIs through man, type and which

man renders an installed CLI from its own spec, type reports it as a cli, and which is new. Precedence now lives in one lazy layers() generator that serves both route (the winner) and route_all (every layer, which type -a prints).

* refactor(shell): share one option scanner across the bash builtins

command, type and which each hand-rolled the same non-permuting letter
scan. They now share scan_options/last_of, which pins bash's grammar in
one place: a long spelling refuses on its second dash, and a mutually
exclusive group resolves to the last letter typed.

type -f is a filter over the layer list instead of a pop-and-restore on
the session function table, and man takes the install it already looked
up rather than fetching it again.

* fix(shell): keep the layers under a reserved word visible

type -a stopped at the keyword, so a function sharing a reserved word's
name never showed. bash prints both lines (function time { :; }; type -a
time), and mirage's parser lets any reserved word be a function name, so
the shadow was reachable and hidden.

time and coproc leave the keyword table with it: mirage implements
neither, so type called them keywords while running one reported command
not found. which now drops the keyword layer before picking a winner
rather than after, so it reports the function underneath instead of
nothing.

* refactor(shell): split lookup into a package the way route and condition are

types.py holds NameKind, constants.py the consumer and description
tables, classify.py the layer walk, handle.py the two builtins. The
keyword pool moves to the name-pool leaf beside SHELL_NAMES, where the
CLI registry can read it: installing a CLI under a reserved word was
allowed and would never have been reachable, and is now refused.

command.py and lookup build their result triples with the shared
helpers instead of by hand, and man renders its missing-description
placeholder in one place.

* fix(shell): keep command -V's diagnostics when another name resolved

The shared-helper refactor routed the any-found case through ok(),
which drops stderr, so command -V ls nope printed the found line and
swallowed 'command: nope: not found'. bash prints both and exits 0
(pinned), and the TypeScript sibling always did.

All three handlers now build one result with a computed exit code, so
the diagnostics can never ride on the status again, and the mixed case
is a test in both languages.
This commit is contained in:
Zecheng Zhang
2026-08-04 20:34:14 -07:00
committed by GitHub
parent ba0ee03c00
commit 7730eb3c37
57 changed files with 2884 additions and 702 deletions
+29
View File
@@ -46,6 +46,35 @@ Command history is a recording, not a command log. A hidden `Observer` records e
- **Snapshots.** History is captured as events into snapshot state and restored on load.
- **Format is GNU bash, not zsh** (`#<epoch>`, not `: <ts>:<dur>;<cmd>`).
## CLIs
An installed CLI is a typed program tree (`CLISpec`) bound to a head word on the
workspace. It is **dispatched by name, never by operand path**, and consults no
mount: the VFS is how an agent discovers state, the CLI is how it acts.
- **The lifecycle is host-side only.** `register_cli`/`unregister_cli`
(`workspace.py`, `workspace.ts`) are called by the embedding program, never by
a line the agent types, and there is no `install`/`uninstall` shell builtin.
Keep it that way: an agent must not be able to take away the tools it was
given. Shadowing is the one thing it can do (define a shell function with the
same name), which is bash's own rule, reversible with `unset -f`, bypassable
with `command <name>`, and visible in `type -a`. A deployment that needs a head
word pinned enforces that in the policy layer's `pre_execute`, not in the CLI
registry.
- **Precedence is written down once**, in `_layers`/`layers`
(`workspace/route/route.py`, `route.ts`): shell builtin, namespace command,
function, CLI, mount. `route` takes the first match (the winner, which is what
dispatch runs) and `route_all`/`routeAll` takes all of them (every layer, which
is what `type -a` prints). The generator is lazy so the winner still costs one
probe. Do not add a second precedence list.
- **Discoverability is part of shipping a CLI**, and it comes from the spec, so
it works for a user's own registered CLI exactly as for a builtin one. `man <cli>` and `man <cli> <verb>...` render through `node_help`/`nodeHelp`, the
same renderer `--help` uses, so a manual cannot drift from the program; bare
`man` lists installs under `# clis`. `type` reports an installed CLI as its own
kind (`type -t` prints `cli`, a sixth word beside bash's five, because reusing
`file` would promise `type -p` a path that does not exist). `which` prints the
bare name, never a fabricated path.
## Symlinks
Symlinks are **namespace state, not backend state**. The `Namespace` node table
+2 -1
View File
@@ -159,8 +159,9 @@ Mirage Bash is a tree-sitter-bash parser plus a custom executor. It implements t
- **Substitutions:** command substitution `` `cmd` `` and `$(cmd)`; arithmetic `$((expr))`; parameter expansion `${VAR}`, `${VAR:-default}`, `${VAR%suffix}`, etc.; input-direction process substitution `<(cmd)`.
- **Control flow:** `if`/`elif`/`else`/`fi`, `for`, `while`, `until`, `case`, `select`, `function name() {}`, `break`, `continue`, `return`.
- **Grouping:** subshells `(cmd)`, compound `{ cmd; }`, negation `! cmd`.
- **Builtins:** `cd`, `pwd`, `echo`, `printf`, `printenv`, `read`, `source`, `.`, `eval`, `export`, `unset`, `local`, `set`, `shift`, `trap` (no-op), `test`, `[`, `[[`, `true`, `false`, `sleep`, `xargs`, `timeout`, `bash`, `sh`, `python`, `python3`.
- **Builtins:** `cd`, `pwd`, `echo`, `printf`, `printenv`, `read`, `source`, `.`, `eval`, `export`, `unset`, `local`, `set`, `shift`, `trap` (no-op), `test`, `[`, `[[`, `true`, `false`, `sleep`, `xargs`, `timeout`, `bash`, `sh`, `python`, `python3`, `man`, `command`, `type`, `which`.
- **Builtin options (GNU semantics):** `echo -n/-e/-E` (leading-word option rule: `echo hi -n` prints `hi -n`), `read -r`, `xargs -n/-0/-d/-r/--` (batching, GNU exit codes: `123` when an invocation fails, `126`/`127` stop the run), `timeout DURATION` with `s`/`m`/`h`/`d` suffixes (kills at the deadline with exit `124`, usage errors exit `125`). `shift` and `return` report bash's `numeric argument required` errors.
- **Name lookup:** `type name` reports what a name resolves to (`type -t` prints one of `keyword`, `function`, `cli`, `builtin`; `type -a` lists every layer holding the name), `which name` prints the name of anything runnable (there is no PATH, so there is no path to print) and reports a miss through exit `1` alone, and `man name` renders a page: a command's spec, or an installed CLI's own `--help` tree (`man linear issue create`).
- **Globs:** `*`, `?`, `[...]` classes and `[!...]` negation (Python `fnmatch` semantics in both implementations), resolved by the shell or pushed down to the resource.
- **Comments:** `#`.
+27
View File
@@ -39,6 +39,33 @@ wording (exit 1), and missing required flags fail with argparse's
wording (exit 2). Two installs under different head words are two
accounts.
## 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.
```bash
man # lists installs under "# clis", beside the mounts
man linear # the tree: description, verbs, flags
man linear issue create # one leaf, same text as `linear issue create --help`
type linear # linear is a mirage CLI
type -t linear # cli
which linear # linear
```
`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.register_cli` / `ws.unregister_cli`): there is no shell
verb for it, so an agent cannot uninstall the tools it was given.
## Builtin CLIs
| Program | Acts on | Vocabulary |
+27
View File
@@ -38,6 +38,33 @@ wording (exit 1), and missing required flags fail with argparse's
wording (exit 2). Two installs under different head words are two
accounts.
## 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.
```bash
man # lists installs under "# clis", beside the mounts
man linear # the tree: description, verbs, flags
man linear issue create # one leaf, same text as `linear issue create --help`
type linear # linear is a mirage CLI
type -t linear # cli
which linear # linear
```
`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
| Program | Acts on | Vocabulary |
+104
View File
@@ -402,6 +402,110 @@
"stdout": "Search\n",
"stderr": ""
}
},
{
"id": "ln_disc_type",
"seq": 567114,
"targets": [
"cli-linear"
],
"command": "type linear",
"expect": {
"exit": 0,
"stdout": "linear is a mirage CLI\n",
"stderr": ""
}
},
{
"id": "ln_disc_type_word",
"seq": 567115,
"targets": [
"cli-linear"
],
"command": "type -t linear",
"expect": {
"exit": 0,
"stdout": "cli\n",
"stderr": ""
}
},
{
"id": "ln_disc_which",
"seq": 567116,
"targets": [
"cli-linear"
],
"command": "which linear",
"expect": {
"exit": 0,
"stdout": "linear\n",
"stderr": ""
}
},
{
"id": "ln_disc_man_root",
"seq": 567117,
"targets": [
"cli-linear"
],
"command": "man linear | head -1",
"expect": {
"exit": 0,
"stdout": "linear: Linear GraphQL API client\n",
"stderr": ""
}
},
{
"id": "ln_disc_man_leaf",
"seq": 567118,
"targets": [
"cli-linear"
],
"command": "man linear issue create | head -1",
"expect": {
"exit": 0,
"stdout": "linear issue create: Create an issue\n",
"stderr": ""
}
},
{
"id": "ln_disc_man_index",
"seq": 567119,
"targets": [
"cli-linear"
],
"command": "man | grep '^- linear'",
"expect": {
"exit": 0,
"stdout": "- linear — Linear GraphQL API client\n",
"stderr": ""
}
},
{
"id": "ln_disc_type_a_shadowed",
"seq": 567120,
"targets": [
"cli-linear"
],
"command": "linear() { echo shadowed; }; type -a linear",
"expect": {
"exit": 0,
"stdout": "linear is a function\nlinear is a mirage CLI\n",
"stderr": ""
}
},
{
"id": "ln_disc_which_miss",
"seq": 567121,
"targets": [
"cli-linear"
],
"command": "which nope-xyz",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
}
]
}
+173
View File
@@ -0,0 +1,173 @@
{
"cases": [
{
"id": "type_builtin",
"seq": 904,
"targets": [
"ram"
],
"command": "type cd",
"expect": {
"exit": 0,
"stdout": "cd is a shell builtin\n",
"stderr": ""
}
},
{
"id": "type_t_builtin",
"seq": 905,
"targets": [
"ram"
],
"command": "type -t cd",
"expect": {
"exit": 0,
"stdout": "builtin\n",
"stderr": ""
}
},
{
"id": "type_keyword",
"seq": 906,
"targets": [
"ram"
],
"command": "type if",
"expect": {
"exit": 0,
"stdout": "if is a shell keyword\n",
"stderr": ""
}
},
{
"id": "type_t_keyword",
"seq": 907,
"targets": [
"ram"
],
"command": "type -t if",
"expect": {
"exit": 0,
"stdout": "keyword\n",
"stderr": ""
}
},
{
"id": "type_function",
"seq": 908,
"targets": [
"ram"
],
"command": "f() { :; }; type -t f",
"expect": {
"exit": 0,
"stdout": "function\n",
"stderr": ""
}
},
{
"id": "type_not_found",
"seq": 909,
"targets": [
"ram"
],
"command": "type nope-xyz",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "type: nope-xyz: not found\n"
}
},
{
"id": "type_all_found_exit_rule",
"seq": 910,
"targets": [
"ram"
],
"command": "type cd nope-xyz",
"expect": {
"exit": 1,
"stdout": "cd is a shell builtin\n",
"stderr": "type: nope-xyz: not found\n"
}
},
{
"id": "type_t_not_found_is_silent",
"seq": 911,
"targets": [
"ram"
],
"command": "type -t nope-xyz",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
},
{
"id": "type_p_prints_no_path",
"seq": 912,
"targets": [
"ram"
],
"command": "type -p cd",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "type_invalid_option",
"seq": 913,
"targets": [
"ram"
],
"command": "type -x cd",
"expect": {
"exit": 2,
"stdout": "",
"stderr": "type: -x: invalid option\ntype: usage: type [-afptP] name [name ...]\n"
}
},
{
"id": "type_a_lists_every_layer",
"seq": 914,
"targets": [
"ram"
],
"command": "cd() { :; }; type -at cd",
"expect": {
"exit": 0,
"stdout": "builtin\nfunction\n",
"stderr": ""
}
},
{
"id": "type_a_keeps_the_function_under_a_keyword",
"seq": 922,
"targets": [
"ram"
],
"command": "then() { echo x; }; type -a then",
"expect": {
"exit": 0,
"stdout": "then is a shell keyword\nthen is a function\n",
"stderr": ""
}
},
{
"id": "type_t_time_is_not_a_keyword_here",
"seq": 923,
"targets": [
"ram"
],
"command": "type -t time",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
}
]
}
+95
View File
@@ -0,0 +1,95 @@
{
"cases": [
{
"id": "which_runnable_prints_the_name",
"seq": 915,
"targets": [
"ram"
],
"command": "which cat",
"expect": {
"exit": 0,
"stdout": "cat\n",
"stderr": ""
}
},
{
"id": "which_miss_is_silent",
"seq": 916,
"targets": [
"ram"
],
"command": "which nope-xyz",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
},
{
"id": "which_keyword_is_not_a_command",
"seq": 917,
"targets": [
"ram"
],
"command": "which if",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
},
{
"id": "which_all_found_exit_rule",
"seq": 918,
"targets": [
"ram"
],
"command": "which cat nope-xyz",
"expect": {
"exit": 1,
"stdout": "cat\n",
"stderr": ""
}
},
{
"id": "which_no_operand_exits_1",
"seq": 919,
"targets": [
"ram"
],
"command": "which",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
},
{
"id": "which_s_reports_through_the_status",
"seq": 920,
"targets": [
"ram"
],
"command": "which -s cat",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "which_invalid_option",
"seq": 921,
"targets": [
"ram"
],
"command": "which -z cat",
"expect": {
"exit": 2,
"stdout": "",
"stderr": "which: -z: invalid option\nwhich: usage: which [-as] name [name ...]\n"
}
}
]
}
+38 -2
View File
@@ -35,6 +35,43 @@ def _verb_display(child: CLISpec) -> str:
return child.name
def find_child(node: CLISpec, word: str) -> CLISpec | None:
"""The subcommand a word names, by canonical name or alias.
Args:
node (CLISpec): the group being descended.
word (str): the verb word as typed.
"""
return next(
(c for c in node.subcommands if word == c.name or word in c.aliases),
None)
def find_node(spec: CLISpec,
verbs: Sequence[str]) -> tuple[CLISpec, tuple[str, ...]] | None:
"""Descend a tree by verb words, None if a word names no subcommand.
Returns the node and its canonical path, so an alias renders under
the name it resolves to, the attribution rule ``walk`` uses. This is
introspection only (``man``): no options are parsed and no usage
error is produced, so a caller gets the node or nothing.
Args:
spec (CLISpec): the root of the tree.
verbs (Sequence[str]): verb words after the head, aliases
allowed.
"""
node = spec
path: tuple[str, ...] = ()
for word in verbs:
child = find_child(node, word)
if child is None:
return None
node = child
path = path + (child.name, )
return node, path
def node_help(name: str, node: CLISpec) -> str:
"""A group node's help: the ordinary command help plus Commands rows.
@@ -373,8 +410,7 @@ def walk(head: str, spec: CLISpec, argv: Sequence[str]) -> WalkResult:
# An alias resolves to its canonical node; the path records
# the canonical name (argparse prog attribution: errors under
# `gws co` render as `gws checkout`).
child = next((c for c in node.subcommands
if token == c.name or token in c.aliases), None)
child = find_child(node, token)
if child is None:
return _unknown_verb(head, name, token)
node = child
+1
View File
@@ -220,6 +220,7 @@ class ShellBuiltin(StrEnum):
TIMEOUT = "timeout"
COMMAND = "command"
TYPE = "type"
WHICH = "which"
BREAK = "break"
CONTINUE = "continue"
RETURN = "return"
+16 -1
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel
from mirage.commands.cli.types import CLISpec
from mirage.commands.spec import SPECS
from mirage.workspace.cli.types import CLIInstall
from mirage.workspace.names import (JOB_BUILTINS, NAMESPACE_COMMANDS,
from mirage.workspace.names import (JOB_BUILTINS, KEYWORDS, NAMESPACE_COMMANDS,
SHELL_NAMES)
@@ -30,6 +30,17 @@ class CLIRegistry:
colliding name, or a config the spec's ``config_model`` rejects
raises at install time, so a workspace that loads has only valid
entries.
The lifecycle is host-side only, and must stay that way: install and
uninstall are called by the program embedding mirage, never by a
line the agent types, so an agent cannot take away the tools it was
given. Do not add an ``install``/``uninstall`` shell builtin. What
an agent can do is shadow a head word with a shell function, which
is bash's own rule, reversible with ``unset -f``, bypassable with
``command <name>``, and visible through ``type -a``. Pinning a head
word against that belongs in the policy layer's ``pre_execute``,
since it is a per-deployment call rather than a property of the
registry.
"""
def __init__(self) -> None:
@@ -57,6 +68,10 @@ class CLIRegistry:
if name in SHELL_NAMES or name in JOB_BUILTINS:
raise ValueError(f"CLI name {name!r} collides with a shell "
f"builtin")
# A reserved word never reaches dispatch (the parser consumes it),
# so an install under one would be unreachable rather than wrong.
if name in KEYWORDS:
raise ValueError(f"CLI name {name!r} is a shell keyword")
if name in NAMESPACE_COMMANDS or name in SPECS:
raise ValueError(f"CLI name {name!r} collides with a general "
f"command")
@@ -13,8 +13,7 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.capacity import handle_df
from mirage.workspace.executor.builtins.command import (handle_command_builtin,
handle_type)
from mirage.workspace.executor.builtins.command import handle_command_builtin
from mirage.workspace.executor.builtins.condition import handle_test
from mirage.workspace.executor.builtins.dirs import handle_cd
from mirage.workspace.executor.builtins.history import handle_history
@@ -22,6 +21,7 @@ from mirage.workspace.executor.builtins.links import (follow_paths, handle_ln,
handle_readlink,
link_flags, prepare_mv,
strip_link_operands)
from mirage.workspace.executor.builtins.lookup import handle_type, handle_which
from mirage.workspace.executor.builtins.man import (_collect_man_hits,
_render_man_entry,
_render_man_index,
@@ -56,7 +56,6 @@ __all__ = [
'handle_bash',
'handle_cd',
'handle_command_builtin',
'handle_type',
'handle_echo',
'handle_env',
'handle_eval',
@@ -90,6 +89,8 @@ __all__ = [
'handle_timeout',
'handle_trap',
'handle_unset',
'handle_type',
'handle_which',
'handle_whoami',
'note_local_array',
'handle_xargs',
@@ -13,142 +13,23 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import Any
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.executor.builtins.getopt import last_of, scan_options
from mirage.workspace.executor.builtins.lookup import classify, describe
from mirage.workspace.executor.builtins.shared import Result, ok, result
from mirage.workspace.mount import MountRegistry
from mirage.workspace.route import route
from mirage.workspace.route.types import Consumer
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_USAGE = "command: usage: command [-pVv] command [arg ...]\n"
# bash reserved words: reported by `command -v/-V` as keywords even
# though the parser, not the executor, consumes them.
_KEYWORDS = frozenset({
"if",
"then",
"else",
"elif",
"fi",
"case",
"esac",
"for",
"select",
"while",
"until",
"do",
"done",
"in",
"function",
"time",
"coproc",
"{",
"}",
"!",
"[[",
"]]",
})
_OPTIONS = "pvV"
def _parse_flags(args: list[str]) -> tuple[str | None, list[str], str | None]:
"""Split ``command``'s own options from its operands.
bash uses non-permuting getopt: option scanning stops at the first
non-option word (or ``--``), so a flag after the target name belongs
to the target, not to ``command``. Only ``-p -v -V`` are valid; ``-p``
is accepted but has no identity effect (mirage has no PATH), and the
last of ``-v``/``-V`` wins.
Args:
args (list[str]): words after the ``command`` name.
Returns:
``(mode, rest, bad)`` where ``mode`` is ``"v"``/``"V"``/``None``,
``rest`` is the operand words, and ``bad`` is the first invalid
option (as ``-x``) or ``None``.
"""
mode: str | None = None
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if not (tok.startswith("-") and len(tok) > 1):
break
for ch in tok[1:]:
if ch == "v":
mode = "v"
elif ch == "V":
mode = "V"
elif ch == "p":
continue
else:
return None, [], f"-{ch}"
i += 1
return mode, args[i:], None
def _classify(name: str, session: Session, registry: MountRegistry) -> str:
"""Classify a name for ``command -v/-V`` reporting.
Args:
name (str): the operand word.
session (Session): shell session (function table).
registry (MountRegistry): mount registry.
Returns:
One of ``"keyword"``, ``"function"``, ``"builtin"``, ``"not_found"``.
Every mirage-native runnable non-function name (shell builtin,
namespace command, or mount command) reports as ``"builtin"``:
mirage has no external binaries, so there is no honest path to
print, and grouping them matches bash's runnable-and-in-process
category (a deliberate divergence from bash's file paths).
"""
if name in _KEYWORDS:
return "keyword"
consumer = route(name, session, registry)
if consumer is Consumer.FUNCTION:
return "function"
if consumer is Consumer.UNKNOWN:
return "not_found"
return "builtin"
def _describe(name: str, kind: str) -> str:
"""Render the ``command -V`` verbose line for a classified name.
Args:
name (str): the operand word.
kind (str): the classification from ``_classify``.
"""
if kind == "keyword":
return f"{name} is a shell keyword"
if kind == "function":
return f"{name} is a function"
return f"{name} is a shell builtin"
def _type_word(kind: str) -> str:
"""The single classification word printed by ``type -t``.
Args:
kind (str): the classification from ``_classify``.
"""
if kind == "keyword":
return "keyword"
if kind == "function":
return "function"
return "builtin"
def _probe(
mode: str, rest: list[str], session: Session, registry: MountRegistry
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
def _probe(mode: str, rest: Sequence[str], session: Session,
registry: MountRegistry) -> Result:
"""Run the ``-v``/``-V`` introspection modes.
The exit status is 0 when no names are given, otherwise 0 if any name
@@ -159,7 +40,7 @@ def _probe(
Args:
mode (str): ``"v"`` or ``"V"``.
rest (list[str]): operand words to classify.
rest (Sequence[str]): operand words to classify.
session (Session): shell session state.
registry (MountRegistry): mount registry.
"""
@@ -167,121 +48,23 @@ def _probe(
err_lines: list[str] = []
any_found = False
for name in rest:
kind = _classify(name, session, registry)
if kind == "not_found":
kind = classify(name, session, registry)
if kind is None:
if mode == "V":
err_lines.append(f"command: {name}: not found")
err_lines.append(f"command: {name}: not found\n")
continue
any_found = True
out_lines.append(name if mode == "v" else _describe(name, kind))
out = ("\n".join(out_lines) + "\n").encode() if out_lines else None
err = ("\n".join(err_lines) + "\n").encode() if err_lines else b""
line = name if mode == "v" else describe(name, kind)
out_lines.append(f"{line}\n")
out = "".join(out_lines).encode() if out_lines else None
# The status and the diagnostics are independent: bash prints
# `command: nope: not found` for a missing name and still exits 0
# when another name resolved.
code = 0 if (not rest or any_found) else 1
return out, IOResult(exit_code=code,
stderr=err), ExecutionNode(command="command",
exit_code=code,
stderr=err)
def _parse_type_flags(
args: list[str]) -> tuple[str | None, bool, list[str], str | None]:
"""Split ``type``'s options from its name operands.
Recognizes ``-t`` (type word only), ``-p``/``-P`` (path; empty for
mirage's pathless builtins), ``-a`` (all locations; one in mirage),
and ``-f`` (skip the function table). Non-permuting like bash: option
scanning stops at the first non-option word or ``--``.
Args:
args (list[str]): words after the ``type`` name.
Returns:
``(mode, nofunc, rest, bad)`` where ``mode`` is ``"t"``/``"p"``/
``None``, ``nofunc`` skips functions, ``rest`` is the operands,
and ``bad`` is the first invalid option (as ``-x``) or ``None``.
"""
mode: str | None = None
nofunc = False
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if not (tok.startswith("-") and len(tok) > 1):
break
for ch in tok[1:]:
if ch == "t":
mode = "t"
elif ch in ("p", "P"):
mode = "p"
elif ch == "a":
continue
elif ch == "f":
nofunc = True
else:
return None, False, [], f"-{ch}"
i += 1
return mode, nofunc, args[i:], None
def handle_type(
args: list[str],
session: Session,
registry: MountRegistry,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run the ``type`` builtin (``type [-afptP] name [name ...]``).
Mirrors ``command -V`` resolution (every mirage-native runnable name
is reported as a shell builtin; there are no external paths), but uses
``type``'s all-found exit rule: 0 only when every name resolves. ``-t``
prints the classification word, ``-p``/``-P`` print a path (always
empty here), and a missing name warns on stderr unless a word-only
mode (``-t``/``-p``) is active.
Args:
args (list[str]): words after the ``type`` name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry for name resolution.
"""
mode, nofunc, rest, bad = _parse_type_flags(args)
if bad is not None:
err = (f"type: {bad}: invalid option\n"
"type: usage: type [-afptP] name [name ...]\n").encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="type",
exit_code=2,
stderr=err)
out_lines: list[str] = []
err_lines: list[str] = []
all_found = True
for name in rest:
if nofunc and name in session.functions:
saved = session.functions.pop(name)
try:
kind = _classify(name, session, registry)
finally:
session.functions[name] = saved
else:
kind = _classify(name, session, registry)
if kind == "not_found":
all_found = False
if mode is None:
err_lines.append(f"type: {name}: not found")
continue
if mode == "t":
out_lines.append(_type_word(kind))
elif mode == "p":
continue
else:
out_lines.append(_describe(name, kind))
out = ("\n".join(out_lines) + "\n").encode() if out_lines else None
err = ("\n".join(err_lines) + "\n").encode() if err_lines else b""
code = 0 if (not rest or all_found) else 1
return out, IOResult(exit_code=code,
stderr=err), ExecutionNode(command="type",
exit_code=code,
stderr=err)
return result("command",
out=out,
exit_code=code,
stderr="".join(err_lines))
async def handle_command_builtin(
@@ -290,7 +73,7 @@ async def handle_command_builtin(
session: Session,
registry: MountRegistry,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
) -> Result:
"""Run the ``command`` builtin (``command [-pVv] name [arg ...]``).
Without ``-v``/``-V`` it runs the target ignoring any shell function
@@ -298,7 +81,8 @@ async def handle_command_builtin(
session function table for the inner run so a shadowing function is
skipped while builtins and mount commands still resolve. Already
expanded operands are re-joined with ``shlex`` so they survive
re-parsing as one token each.
re-parsing as one token each. ``-p`` is accepted but inert (mirage
has no PATH) and the last of ``-v``/``-V`` wins.
Args:
execute_fn (Callable): shell evaluator for the inner line.
@@ -307,17 +91,17 @@ async def handle_command_builtin(
registry (MountRegistry): mount registry for name resolution.
stdin (ByteSource | None): piped input for the inner run.
"""
mode, rest, bad = _parse_flags(args)
if bad is not None:
err = f"command: {bad}: invalid option\n{_USAGE}".encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="command",
exit_code=2,
stderr=err)
scan = scan_options(args, _OPTIONS)
if scan.bad is not None:
return result("command",
exit_code=2,
stderr=f"command: {scan.bad}: invalid option\n{_USAGE}")
mode = last_of(scan.letters, "vV")
rest = scan.operands
if mode is not None:
return _probe(mode, rest, session, registry)
if not rest:
return None, IOResult(), ExecutionNode(command="command", exit_code=0)
return ok("command")
inner_name = rest[0]
inner = shlex.join(rest)
@@ -0,0 +1,80 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class OptionScan:
"""A bash builtin's option scan: letters as typed, then operands.
Args:
letters (tuple[str, ...]): every option letter in the order it
was typed, repeats kept, so a builtin whose flags are
mutually exclusive can apply bash's last-one-wins rule.
operands (tuple[str, ...]): words from the first non-option on.
bad (str | None): the first invalid option, spelled the way the
refusal spells it, or None when every letter is known.
"""
letters: tuple[str, ...] = ()
operands: tuple[str, ...] = ()
bad: str | None = None
def scan_options(args: Sequence[str], known: str) -> OptionScan:
"""Scan a bash builtin's leading option letters.
bash builtins take single letters only (``internal_getopt``), which
is a different grammar from the GNU tools ``parse_shell_options``
serves: scanning is non-permuting and stops at ``--`` or the first
non-option word, a token carries options only when it starts with a
dash and is longer than one character, and every character after
that dash is a letter. A long spelling therefore fails on its second
dash, which is why bash refuses ``type --foo`` as ``--`` and not as
``--foo`` (pinned against bash 5.2, debian:stable-slim).
Args:
args (Sequence[str]): words after the builtin's name.
known (str): the option letters this builtin accepts.
"""
letters: list[str] = []
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if not (tok.startswith("-") and len(tok) > 1):
break
for ch in tok[1:]:
if ch not in known:
return OptionScan(bad=f"-{ch}")
letters.append(ch)
i += 1
return OptionScan(letters=tuple(letters), operands=tuple(args[i:]))
def last_of(letters: Sequence[str], choices: str) -> str | None:
"""The last of a mutually exclusive letter group, as bash resolves it.
bash holds such a group in one variable, so the last letter typed
wins: ``type -tp`` prints a path and ``type -pt`` a type word, and
``command -vV`` is verbose where ``command -Vv`` is not.
Args:
letters (Sequence[str]): scanned letters, in typed order.
choices (str): the mutually exclusive group.
"""
return next((ch for ch in reversed(letters) if ch in choices), None)
@@ -0,0 +1,27 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.lookup.classify import (classify,
describe)
from mirage.workspace.executor.builtins.lookup.handle import (handle_type,
handle_which)
# The package's public surface: what other packages consume. Inside the
# package, and in its tests, the modules are imported directly.
__all__ = [
"classify",
"describe",
"handle_type",
"handle_which",
]
@@ -0,0 +1,100 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.lookup.constants import (
DESCRIPTIONS, KIND_BY_CONSUMER)
from mirage.workspace.executor.builtins.lookup.types import NameKind
from mirage.workspace.mount import MountRegistry
from mirage.workspace.names import KEYWORDS
from mirage.workspace.route import route, route_all
from mirage.workspace.session import Session
def classify(name: str, session: Session,
registry: MountRegistry) -> NameKind | None:
"""Classify the name as the layer that would run it, None if none does.
Args:
name (str): the operand word.
session (Session): shell session (function table).
registry (MountRegistry): mount registry.
"""
if name in KEYWORDS:
return NameKind.KEYWORD
return KIND_BY_CONSUMER.get(route(name, session, registry))
def classify_all(name: str, session: Session,
registry: MountRegistry) -> list[NameKind]:
"""Classify every layer holding the name, most-preferred first.
A reserved word goes first and does not end the walk: bash prints
both lines when a function shares a keyword's name (pinned:
``function time { :; }; type -a time`` prints the keyword line then
the function line). mirage's parser is looser than bash's about
reserved words as function names, so the shadow is reachable here
for any of them, and hiding it would leave ``type -a`` claiming a
keyword while the line runs the function.
Duplicate kinds are dropped, since the kinds are coarser than the
layers: a shell builtin that a mount also registers is one
``builtin`` line, not two identical ones.
Args:
name (str): the operand word.
session (Session): shell session (function table).
registry (MountRegistry): mount registry.
"""
kinds: list[NameKind] = [NameKind.KEYWORD] if name in KEYWORDS else []
for consumer in route_all(name, session, registry):
kind = KIND_BY_CONSUMER[consumer]
if kind not in kinds:
kinds.append(kind)
return kinds
def locations(name: str,
session: Session,
registry: MountRegistry,
all_mode: bool,
drop: NameKind | None = None) -> list[NameKind]:
"""The kinds to report for one name: hide a layer, then take the top.
Hiding is a filter over the layer list, never an edit to the
session, and it runs before the winner is picked. That order is
what keeps the winner honest: ``type -f`` reports the layer under a
shadowing function, and ``which`` the layer under a reserved word,
where filtering afterwards would report nothing at all.
Args:
name (str): the operand word.
session (Session): shell session (function table).
registry (MountRegistry): mount registry.
all_mode (bool): report every layer instead of the winner only.
drop (NameKind | None): a layer this caller does not resolve.
"""
kinds = classify_all(name, session, registry)
if drop is not None:
kinds = [kind for kind in kinds if kind is not drop]
return kinds if all_mode else kinds[:1]
def describe(name: str, kind: NameKind) -> str:
"""Render the verbose line ``command -V`` and ``type`` print.
Args:
name (str): the operand word.
kind (NameKind): the classification.
"""
return f"{name} is {DESCRIPTIONS[kind]}"
@@ -0,0 +1,44 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.lookup.types import NameKind
from mirage.workspace.route import Consumer
TYPE_USAGE = "type: usage: type [-afptP] name [name ...]\n"
WHICH_USAGE = "which: usage: which [-as] name [name ...]\n"
# The words each builtin accepts, as bash's usage line spells them.
TYPE_OPTIONS = "afptP"
WHICH_OPTIONS = "as"
# Shell builtins, namespace commands and mount commands are all
# in-process and pathless, so they share bash's runnable-and-in-process
# category. That collapse is deliberate; `cli` is kept apart because an
# installed CLI is the one runnable an agent cannot otherwise discover.
# UNKNOWN is absent: it is what `route` reports for a name no layer
# holds, and `route_all` never yields it.
KIND_BY_CONSUMER: dict[Consumer, NameKind] = {
Consumer.SESSION: NameKind.BUILTIN,
Consumer.NAMESPACE: NameKind.BUILTIN,
Consumer.FUNCTION: NameKind.FUNCTION,
Consumer.CLI: NameKind.CLI,
Consumer.MOUNT: NameKind.BUILTIN,
}
DESCRIPTIONS: dict[NameKind, str] = {
NameKind.KEYWORD: "a shell keyword",
NameKind.FUNCTION: "a function",
NameKind.CLI: "a mirage CLI",
NameKind.BUILTIN: "a shell builtin",
}
@@ -0,0 +1,125 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.getopt import last_of, scan_options
from mirage.workspace.executor.builtins.lookup.classify import (describe,
locations)
from mirage.workspace.executor.builtins.lookup.constants import (TYPE_OPTIONS,
TYPE_USAGE,
WHICH_OPTIONS,
WHICH_USAGE)
from mirage.workspace.executor.builtins.lookup.types import NameKind
from mirage.workspace.executor.builtins.shared import Result, result
from mirage.workspace.mount import MountRegistry
from mirage.workspace.session import Session
def handle_type(
args: list[str],
session: Session,
registry: MountRegistry,
) -> Result:
"""Run the ``type`` builtin (``type [-afptP] name [name ...]``).
Resolution matches ``command -V``, but the exit rule is ``type``'s:
0 only when every name resolves. ``-t`` prints the classification
word, ``-p``/``-P`` print a path (always empty here) and are one
mutually exclusive group with ``-t``, ``-a`` prints one line per
layer holding the name (a shell function shadowing an installed CLI
is the case that has two), ``-f`` ignores the function table, and a
missing name warns on stderr unless a word-only mode (``-t``/``-p``)
is active.
Args:
args (list[str]): words after the ``type`` name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry for name resolution.
"""
scan = scan_options(args, TYPE_OPTIONS)
if scan.bad is not None:
return result("type",
exit_code=2,
stderr=f"type: {scan.bad}: invalid option\n{TYPE_USAGE}")
last = last_of(scan.letters, "tpP")
mode = last if last is None or last == "t" else "p"
all_mode = "a" in scan.letters
hidden = NameKind.FUNCTION if "f" in scan.letters else None
out_lines: list[str] = []
err_lines: list[str] = []
all_found = True
for name in scan.operands:
kinds = locations(name, session, registry, all_mode, hidden)
if not kinds:
all_found = False
if mode is None:
err_lines.append(f"type: {name}: not found\n")
continue
if mode == "t":
out_lines.extend(f"{kind.value}\n" for kind in kinds)
elif mode is None:
out_lines.extend(f"{describe(name, kind)}\n" for kind in kinds)
out = "".join(out_lines).encode() if out_lines else None
# One call, so the diagnostics never ride on the status: a partial
# miss both warns and reports through the exit code.
code = 0 if (not scan.operands or all_found) else 1
return result("type", out=out, exit_code=code, stderr="".join(err_lines))
def handle_which(
args: list[str],
session: Session,
registry: MountRegistry,
) -> Result:
"""Run the ``which`` builtin (``which [-as] name [name ...]``).
Pinned against debianutils ``which`` (debian:stable-slim): a miss
prints nothing at all, the exit status is 0 only when every name
resolves (1 with no operands), and ``-s`` reports through the status
alone. Two deliberate divergences, both forced by mirage having no
PATH: the printed word is the name rather than a path (as
``command -v`` already does), and every runnable resolves, where GNU
reports only files (``which cd`` misses there, since a builtin is
not on the PATH; here everything is in-process, so reporting nothing
would make the command useless). Keywords stay unresolvable, as they
are not commands anywhere. ``-a`` prints one line per layer, so a
shadowed name prints its name twice; ``type -a`` is the surface that
names the layers. The refusal for an unknown option is bash's shape,
not the C tool's ``Illegal option``, because this is a builtin and
the usage line cannot honestly name ``/usr/bin/which``.
Args:
args (list[str]): words after the ``which`` name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry for name resolution.
"""
scan = scan_options(args, WHICH_OPTIONS)
if scan.bad is not None:
return result(
"which",
exit_code=2,
stderr=f"which: {scan.bad}: invalid option\n{WHICH_USAGE}")
all_mode = "a" in scan.letters
silent = "s" in scan.letters
out_lines: list[str] = []
all_found = True
for name in scan.operands:
kinds = locations(name, session, registry, all_mode, NameKind.KEYWORD)
if not kinds:
all_found = False
continue
if not silent:
out_lines.extend([f"{name}\n"] * len(kinds))
out = "".join(out_lines).encode() if out_lines else None
code = 0 if (scan.operands and all_found) else 1
return result("which", out=out, exit_code=code)
@@ -0,0 +1,34 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from enum import StrEnum
class NameKind(StrEnum):
"""What a command name resolves to, spelled as ``type -t`` prints it.
bash's ``-t`` vocabulary is alias/keyword/function/builtin/file.
mirage has no aliases and no external binaries, so ``file`` never
applies and every mirage-native runnable name that is not a function
would collapse into ``builtin``. ``cli`` is a sixth word rather than
a reuse of ``file``: reusing it would promise ``type -p`` a path to
print, and there is none.
Members are ordered as ``type -a`` prints them, which is also the
order the layers resolve in.
"""
KEYWORD = "keyword"
FUNCTION = "function"
CLI = "cli"
BUILTIN = "builtin"
+102 -13
View File
@@ -12,18 +12,31 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from collections.abc import Sequence
from dataclasses import dataclass
from mirage.commands.cli.types import CLISpec
from mirage.commands.cli.walk import find_node, node_help
from mirage.commands.config import RegisteredCommand
from mirage.commands.spec import SPECS, CommandSpec
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.cli.types import CLIInstall
from mirage.workspace.mount.mount import MountEntry
from mirage.workspace.mount.registry import DEV_PREFIX, MountRegistry
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
def _described(text: str | None) -> str:
"""A description, or man's placeholder when the spec carries none.
Args:
text (str | None): the spec's description.
"""
return text if text is not None else "(no description)"
@dataclass
class _ManHit:
mount: MountEntry
@@ -69,8 +82,7 @@ def _render_man_entry(name: str, hits: list[_ManHit]) -> str:
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append(_described(spec.description))
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
@@ -110,8 +122,7 @@ def _render_shell_builtin_man(name: str, spec: CommandSpec) -> str:
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append(_described(spec.description))
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
@@ -120,6 +131,46 @@ def _render_shell_builtin_man(name: str, spec: CommandSpec) -> str:
return "\n".join(lines) + "\n"
def _render_cli_entry(head: str, verbs: Sequence[str],
spec: CLISpec) -> str | None:
"""The page for one node of an installed CLI, None when verbs miss.
The page is the node's own ``--help``, rendered by the one renderer
that serves ``--help`` and the bare-group refusal, so a CLI's manual
cannot drift from the program. A tree is a manual with sections:
``man linear`` lists the verbs and ``man linear issue create`` is
the page for one leaf.
Args:
head (str): installed head word, as typed.
verbs (Sequence[str]): verb words after the head, aliases
allowed.
spec (CLISpec): the installed program tree.
"""
found = find_node(spec, verbs)
if found is None:
return None
node, path = found
return node_help(" ".join((head, ) + path), node)
def _render_cli_index(registry: MountRegistry) -> list[str]:
"""The installed-CLI section of the bare ``man`` listing.
Args:
registry (MountRegistry): registry holding the installs.
"""
installs = registry.clis.items()
if not installs:
return []
lines = ["# clis", ""]
for name, install in sorted(installs.items()):
desc = _described(install.spec.description)
lines.append(f"- {name} \u2014 {desc}")
lines.append("")
return lines
def _render_man_index(session: Session, registry: MountRegistry) -> str:
by_kind: dict[str, MountEntry] = {}
for m in registry.mounts():
@@ -156,24 +207,56 @@ def _render_man_index(session: Session, registry: MountRegistry) -> str:
key=lambda c: c.name,
)
for cmd in resource_cmds:
desc = (cmd.spec.description if cmd.spec.description is not None
else "(no description)")
desc = _described(cmd.spec.description)
lines.append(f"- {cmd.name} \u2014 {desc}")
for cmd in all_cmds:
if (m.is_general_command(cmd.name)
and cmd.name not in general_seen):
general_seen[cmd.name] = cmd
lines.append("")
lines.extend(_render_cli_index(registry))
lines.append("# general")
lines.append("")
for name in sorted(general_seen):
cmd = general_seen[name]
desc = (cmd.spec.description
if cmd.spec.description is not None else "(no description)")
desc = _described(general_seen[name].spec.description)
lines.append(f"- {name} \u2014 {desc}")
return "\n".join(lines) + "\n"
def _cli_man(
install: CLIInstall, verbs: Sequence[str], cmd_str: str,
registry: MountRegistry
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""The page (or pages) for an installed head word.
A CLI may not take a general command's name, but a mount can
register a custom command under any name, so both pages can exist
for one word. The CLI goes first: it is the one dispatch would run.
Args:
install (CLIInstall): the installed CLI, head word included.
verbs (Sequence[str]): verb words after the head, aliases
allowed.
cmd_str (str): the line, for the execution node.
registry (MountRegistry): registry holding the mounts.
"""
head = install.name
entry = _render_cli_entry(head, verbs, install.spec)
if entry is None:
typed = " ".join([head, *verbs])
err = f"man: no entry for {typed}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
sections = [entry]
hits = _collect_man_hits(head, registry) if not verbs else []
if hits:
sections.append(_render_man_entry(head, hits))
out = "\n".join(sections).encode()
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
async def handle_man(
args: list[str],
session: Session,
@@ -183,18 +266,24 @@ async def handle_man(
out = _render_man_index(session, registry).encode()
return out, IOResult(), ExecutionNode(command="man", exit_code=0)
name = args[0]
cmd_str = "man " + " ".join(args)
# Only an installed head word reads the words after it: they are its
# verb path. Everything else keeps man's older shape and documents
# args[0].
install = registry.clis.get(name)
if install is not None:
return _cli_man(install, args[1:], cmd_str, registry)
hits = _collect_man_hits(name, registry)
if not hits:
spec_key = _SHELL_BUILTIN_MAN.get(name)
spec = SPECS.get(spec_key) if spec_key is not None else None
if spec is not None:
out = _render_shell_builtin_man(name, spec).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}",
exit_code=0)
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
err = f"man: no entry for {name}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"man {name}",
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
out = _render_man_entry(name, hits).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}", exit_code=0)
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
+30
View File
@@ -32,6 +32,36 @@ UNSUPPORTED_BUILTINS = frozenset({
NAMESPACE_COMMANDS = frozenset({"ln", "readlink"})
# bash reserved words that mirage's grammar implements. The parser, not
# the executor, consumes them, so they never reach route; `type` reports
# them and the CLI registry refuses them as head words. bash's `time`
# and `coproc` are left out on purpose: mirage implements neither
# construct, so a line starting with one reports `command not found`,
# and `type` may not contradict what dispatch does. Add a word back
# when its construct lands.
KEYWORDS = frozenset({
"if",
"then",
"else",
"elif",
"fi",
"case",
"esac",
"for",
"select",
"while",
"until",
"do",
"done",
"in",
"function",
"{",
"}",
"!",
"[[",
"]]",
})
# ShellBuiltin subset handled through the job table in the executor.
JOB_BUILTINS = frozenset({"wait", "fg", "kill", "jobs", "ps"})
@@ -46,8 +46,8 @@ from mirage.workspace.executor.builtins import ( # isort: skip
handle_ln, handle_local, handle_man, handle_printenv, handle_printf,
handle_read, handle_readlink, handle_return, handle_set, handle_shift,
handle_sleep, handle_source, handle_test, handle_timeout, handle_touch,
handle_trap, handle_type, handle_unset, handle_whoami, handle_xargs,
link_flags, prepare_mv, strip_link_operands)
handle_trap, handle_type, handle_unset, handle_which, handle_whoami,
handle_xargs, link_flags, prepare_mv, strip_link_operands)
_CdArgs = list[str | PathSpec]
@@ -473,6 +473,9 @@ async def _run_argv(
if name == SB.TYPE:
return handle_type(args, session, registry)
if name == SB.WHICH:
return handle_which(args, session, registry)
if name == SB.XARGS:
return await handle_xargs(execute_fn, args, session, stdin)
+2 -1
View File
@@ -16,7 +16,7 @@ from mirage.workspace.route.constants import (JOB_BUILTINS, NAMESPACE_COMMANDS,
NO_FOLLOW_COMMANDS,
UNSUPPORTED_BUILTINS,
dereferences, reports_link)
from mirage.workspace.route.route import route
from mirage.workspace.route.route import route, route_all
from mirage.workspace.route.types import (SHELL_CONSUMERS, Consumer,
WordPolicy, word_policy)
@@ -31,5 +31,6 @@ __all__ = [
"UNSUPPORTED_BUILTINS",
"WordPolicy",
"route",
"route_all",
"word_policy",
]
+52 -11
View File
@@ -12,12 +12,40 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from collections.abc import Iterator
from mirage.workspace.mount import MountRegistry
from mirage.workspace.route.constants import NAMESPACE_COMMANDS, SHELL_NAMES
from mirage.workspace.route.types import Consumer
from mirage.workspace.session import Session
def _layers(name: str, session: Session,
registry: MountRegistry) -> Iterator[Consumer]:
"""Yield every layer holding the name, most-preferred first.
The one place precedence is written down: ``route`` reads the first
yield and ``route_all`` reads all of them. Lazy on purpose, so the
winner costs exactly what it did before the split (a name an
installed CLI answers never reaches the mount lookup).
Args:
name (str): expanded command name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry (command registration).
"""
if name in SHELL_NAMES:
yield Consumer.SESSION
if name in NAMESPACE_COMMANDS:
yield Consumer.NAMESPACE
if name in session.functions:
yield Consumer.FUNCTION
if registry.clis.get(name) is not None:
yield Consumer.CLI
if registry.mount_for_command(name) is not None:
yield Consumer.MOUNT
def route(name: str, session: Session, registry: MountRegistry) -> Consumer:
"""Route a command name to the layer that consumes it.
@@ -43,19 +71,32 @@ def route(name: str, session: Session, registry: MountRegistry) -> Consumer:
Runtimes are orthogonal, not a seventh row: a capture decides where
a command executes (docker vs vfs), never whether the name exists.
This is the winner only. A name can sit in more than one layer at
once (a function shadowing an installed CLI); ``route_all`` reports
them all, which is what ``type -a`` prints. Reading one item off the
generator is what makes that sharing free: the lookups after the
winner never run, so dispatch pays exactly what it did when this was
a chain of ``if`` arms.
Args:
name (str): expanded command name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry (command registration).
"""
if name in SHELL_NAMES:
return Consumer.SESSION
if name in NAMESPACE_COMMANDS:
return Consumer.NAMESPACE
if name in session.functions:
return Consumer.FUNCTION
if registry.clis.get(name) is not None:
return Consumer.CLI
if registry.mount_for_command(name) is not None:
return Consumer.MOUNT
return Consumer.UNKNOWN
return next(_layers(name, session, registry), Consumer.UNKNOWN)
def route_all(name: str, session: Session,
registry: MountRegistry) -> list[Consumer]:
"""Every layer holding the name, most-preferred first.
Empty when nothing holds it, where ``route`` says UNKNOWN. Only
introspection (``type -a``, ``which -a``) needs this: dispatch runs
the winner and never asks what it shadowed.
Args:
name (str): expanded command name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry (command registration).
"""
return list(_layers(name, session, registry))
+29
View File
@@ -13,6 +13,7 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.cli import CLISpec, walk
from mirage.commands.cli.walk import find_child, find_node
from mirage.commands.spec.types import Option
@@ -311,3 +312,31 @@ def test_float_typed_group_option_uses_git_wording():
ok = walk("tool", tree, ["--ratio", "2.5", "run"])
assert ok.leaf is not None
assert ok.group_flags == {"--ratio": "2.5"}
def test_find_child_matches_name_or_alias():
tree = CLISpec(name="gws",
subcommands=(CLISpec(name="checkout",
aliases=("co", ),
fn=_verb), ))
assert find_child(tree, "checkout").name == "checkout"
assert find_child(tree, "co").name == "checkout"
assert find_child(tree, "nope") is None
def test_find_node_returns_the_node_and_its_canonical_path():
node, path = find_node(_tree(), ["gmail", "send"])
assert node.name == "send"
assert path == ("gmail", "send")
def test_find_node_with_no_verbs_is_the_root():
tree = _tree()
node, path = find_node(tree, [])
assert node is tree
assert path == ()
def test_find_node_misses_on_an_unknown_verb():
assert find_node(_tree(), ["gmail", "bogus"]) is None
assert find_node(_tree(), ["bogus"]) is None
@@ -74,6 +74,16 @@ def test_shell_builtin_collision_is_refused():
reg.install("kill", tree())
def test_shell_keyword_is_refused():
# The parser consumes a reserved word, so the install would never be
# reachable from a line.
reg = CLIRegistry()
with pytest.raises(ValueError, match="shell keyword"):
reg.install("if", tree())
with pytest.raises(ValueError, match="shell keyword"):
reg.install("select", tree())
def test_general_command_collision_is_refused():
reg = CLIRegistry()
with pytest.raises(ValueError, match="general command"):
@@ -0,0 +1,102 @@
from mirage.commands.cli.types import CLISpec
from mirage.workspace.cli.registry import CLIRegistry
from mirage.workspace.executor.builtins.lookup.classify import (classify,
classify_all,
describe)
from mirage.workspace.executor.builtins.lookup.types import NameKind
from mirage.workspace.session.session import Session
TREE = CLISpec(name="linear",
subcommands=(CLISpec(name="issue", fn=lambda: None), ))
class FakeRegistry:
def __init__(self, commands: set[str], with_cli: bool = False):
self._commands = commands
self.clis = CLIRegistry()
if with_cli:
self.clis.install("linear", TREE)
def mount_for_command(self, name: str) -> object | None:
return object() if name in self._commands else None
def make_session() -> Session:
return Session(session_id="s1")
def make_registry(with_cli: bool = False) -> FakeRegistry:
return FakeRegistry({"cat", "grep", "ls", "jq"}, with_cli=with_cli)
def test_classify_keyword_before_route():
session = make_session()
registry = make_registry()
for kw in ("if", "for", "while", "case", "[[", "]]", "!", "{", "}"):
assert classify(kw, session, registry) is NameKind.KEYWORD
def test_classify_shell_builtin_and_mount_are_builtin():
session = make_session()
registry = make_registry()
assert classify("cd", session, registry) is NameKind.BUILTIN
assert classify("echo", session, registry) is NameKind.BUILTIN
assert classify("cat", session, registry) is NameKind.BUILTIN
assert classify("jq", session, registry) is NameKind.BUILTIN
def test_classify_function_and_not_found():
session = make_session()
session.functions["myfn"] = []
registry = make_registry()
assert classify("myfn", session, registry) is NameKind.FUNCTION
assert classify("nope_xyz", session, registry) is None
def test_classify_installed_cli():
assert classify("linear", make_session(),
make_registry(True)) is NameKind.CLI
def test_classify_all_reports_a_function_shadowing_a_cli():
session = make_session()
registry = make_registry(True)
assert classify_all("linear", session, registry) == [NameKind.CLI]
session.functions["linear"] = []
assert classify_all("linear", session,
registry) == [NameKind.FUNCTION, NameKind.CLI]
def test_classify_all_dedupes_one_kind_held_by_two_layers():
session = make_session()
registry = FakeRegistry({"cd"})
assert classify_all("cd", session, registry) == [NameKind.BUILTIN]
def test_classify_all_keeps_the_layers_under_a_keyword():
# bash: `function time { :; }; type -a time` prints the keyword line
# then the function line.
session = make_session()
session.functions["then"] = []
assert classify_all("then", session, make_registry()) == [
NameKind.KEYWORD, NameKind.FUNCTION
]
def test_time_and_coproc_are_not_keywords_here():
# mirage implements neither construct, so `time echo hi` reports
# command not found and type may not call it a keyword.
session = make_session()
registry = make_registry()
assert classify("time", session, registry) is None
assert classify("coproc", session, registry) is None
session.functions["time"] = []
assert classify("time", session, registry) is NameKind.FUNCTION
def test_describe_lines():
assert describe("if", NameKind.KEYWORD) == "if is a shell keyword"
assert describe("myfn", NameKind.FUNCTION) == "myfn is a function"
assert describe("cat", NameKind.BUILTIN) == "cat is a shell builtin"
assert describe("linear", NameKind.CLI) == "linear is a mirage CLI"
@@ -0,0 +1,208 @@
import pytest
from mirage.commands.cli.types import CLISpec
from mirage.workspace.cli.registry import CLIRegistry
from mirage.workspace.executor.builtins.lookup.handle import (handle_type,
handle_which)
from mirage.workspace.session.session import Session
TREE = CLISpec(name="linear",
subcommands=(CLISpec(name="issue", fn=lambda: None), ))
class FakeRegistry:
def __init__(self, commands: set[str], with_cli: bool = False):
self._commands = commands
self.clis = CLIRegistry()
if with_cli:
self.clis.install("linear", TREE)
def mount_for_command(self, name: str) -> object | None:
return object() if name in self._commands else None
def make_session() -> Session:
return Session(session_id="s1")
def make_registry(with_cli: bool = False) -> FakeRegistry:
return FakeRegistry({"cat", "grep", "ls", "jq"}, with_cli=with_cli)
def _out(result) -> str:
out, _io, _node = result
return out.decode() if out is not None else ""
def test_type_reports_builtin():
out, io, _ = handle_type(["cd"], make_session(), make_registry())
assert out.decode() == "cd is a shell builtin\n"
assert io.exit_code == 0
def test_type_reports_keyword():
assert _out(handle_type(["if"], make_session(),
make_registry())) == "if is a shell keyword\n"
def test_type_a_prints_the_function_under_a_keyword():
session = make_session()
session.functions["then"] = []
assert _out(handle_type(
["-a", "then"], session,
make_registry())) == ("then is a shell keyword\nthen is a function\n")
def test_type_reports_installed_cli():
assert _out(handle_type(["linear"], make_session(),
make_registry(True))) == "linear is a mirage CLI\n"
assert _out(
handle_type(["-t", "linear"], make_session(),
make_registry(True))) == "cli\n"
def test_type_t_prints_word():
assert _out(handle_type(["-t", "cd"], make_session(),
make_registry())) == "builtin\n"
assert _out(handle_type(["-t", "if"], make_session(),
make_registry())) == "keyword\n"
def test_type_last_of_t_and_p_wins():
# bash: `type -tp cd` prints a path (empty here), `type -pt cd` the
# type word.
assert _out(handle_type(["-tp", "cd"], make_session(),
make_registry())) == ""
assert _out(handle_type(["-pt", "cd"], make_session(),
make_registry())) == "builtin\n"
assert _out(handle_type(["-P", "cd"], make_session(),
make_registry())) == ""
def test_type_mount_command_is_builtin():
assert _out(handle_type(["cat"], make_session(),
make_registry())) == "cat is a shell builtin\n"
def test_type_a_prints_every_layer():
session = make_session()
session.functions["linear"] = []
assert _out(handle_type(
["-a", "linear"], session, make_registry(True))) == (
"linear is a function\nlinear is a mirage CLI\n")
assert _out(handle_type(["-at", "linear"], session,
make_registry(True))) == "function\ncli\n"
def test_type_f_skips_functions_without_touching_the_session():
session = make_session()
body: list[str] = []
session.functions["linear"] = body
assert _out(handle_type(["-f", "linear"], session,
make_registry(True))) == "linear is a mirage CLI\n"
assert session.functions["linear"] is body
def test_type_f_on_a_function_only_name_is_not_found():
session = make_session()
session.functions["myfn"] = []
out, io, _ = handle_type(["-f", "myfn"], session, make_registry())
assert out is None
assert io.exit_code == 1
def test_type_not_found_warns_and_exits_1():
out, io, _ = handle_type(["nope"], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
assert io.stderr == b"type: nope: not found\n"
def test_type_t_not_found_is_silent():
out, io, _ = handle_type(["-t", "nope"], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
assert not io.stderr
def test_type_all_found_exit_rule():
out, io, _ = handle_type(["cd", "nope"], make_session(), make_registry())
assert out.decode() == "cd is a shell builtin\n"
assert io.exit_code == 1
def test_type_path_mode_empty_for_builtin():
out, io, _ = handle_type(["-p", "cd"], make_session(), make_registry())
assert out is None
assert io.exit_code == 0
def test_type_invalid_option():
out, io, _ = handle_type(["-x", "cd"], make_session(), make_registry())
assert io.exit_code == 2
assert io.stderr.startswith(b"type: -x: invalid option\n")
@pytest.mark.parametrize("name", ["linear", "cd", "cat"])
def test_which_prints_the_name_for_every_runnable(name: str):
out, io, _ = handle_which([name], make_session(), make_registry(True))
assert out.decode() == f"{name}\n"
assert io.exit_code == 0
def test_which_miss_is_silent_and_exits_1():
out, io, _ = handle_which(["nope"], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
assert not io.stderr
def test_which_does_not_resolve_a_keyword():
out, io, _ = handle_which(["if"], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
def test_which_reports_the_layer_under_a_keyword():
# The keyword is filtered before the winner is picked, so the
# function below it is what `which` resolves.
session = make_session()
session.functions["then"] = []
out, io, _ = handle_which(["then"], session, make_registry())
assert out.decode() == "then\n"
assert io.exit_code == 0
def test_which_all_found_exit_rule():
out, io, _ = handle_which(["cd", "nope"], make_session(), make_registry())
assert out.decode() == "cd\n"
assert io.exit_code == 1
def test_which_no_operands_exits_1():
out, io, _ = handle_which([], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
def test_which_a_prints_a_line_per_layer():
session = make_session()
session.functions["linear"] = []
out, io, _ = handle_which(["-a", "linear"], session, make_registry(True))
assert out.decode() == "linear\nlinear\n"
assert io.exit_code == 0
def test_which_s_reports_through_the_status():
out, io, _ = handle_which(["-s", "cd"], make_session(), make_registry())
assert out is None
assert io.exit_code == 0
assert handle_which(["-s", "nope"], make_session(),
make_registry())[1].exit_code == 1
def test_which_invalid_option():
out, io, _ = handle_which(["-z", "cd"], make_session(), make_registry())
assert io.exit_code == 2
assert io.stderr.startswith(b"which: -z: invalid option\n")
@@ -3,10 +3,7 @@ import pytest
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.workspace.cli.registry import CLIRegistry
from mirage.workspace.executor.builtins.command import (_classify, _describe,
_parse_flags,
handle_command_builtin,
handle_type)
from mirage.workspace.executor.builtins.command import handle_command_builtin
from mirage.workspace.session.session import Session
@@ -45,66 +42,25 @@ def make_registry() -> FakeRegistry:
return FakeRegistry({"cat", "grep", "ls", "jq"})
def test_parse_flags_last_v_or_V_wins():
assert _parse_flags(["-v", "ls"]) == ("v", ["ls"], None)
assert _parse_flags(["-V", "ls"]) == ("V", ["ls"], None)
assert _parse_flags(["-vV", "ls"]) == ("V", ["ls"], None)
assert _parse_flags(["-Vv", "ls"]) == ("v", ["ls"], None)
@pytest.mark.asyncio
@pytest.mark.parametrize("args,expected", [
(["-vV", "cd"], b"cd is a shell builtin\n"),
(["-Vv", "cd"], b"cd\n"),
(["-pv", "cd"], b"cd\n"),
])
async def test_last_of_v_or_V_wins_and_p_is_inert(args: list[str],
expected: bytes):
out, _io, _ = await handle_command_builtin(FakeShell(), args,
make_session(), make_registry())
assert await materialize(out) == expected
def test_parse_flags_p_is_accepted_but_inert():
assert _parse_flags(["-p", "ls"]) == (None, ["ls"], None)
assert _parse_flags(["-pv", "ls"]) == ("v", ["ls"], None)
def test_parse_flags_stops_at_first_operand():
# A flag after the target name belongs to the target.
assert _parse_flags(["ls", "-l"]) == (None, ["ls", "-l"], None)
assert _parse_flags(["-v", "ls", "-l"]) == ("v", ["ls", "-l"], None)
def test_parse_flags_double_dash_ends_options():
assert _parse_flags(["--", "ls"]) == (None, ["ls"], None)
assert _parse_flags(["-v", "--", "ls"]) == ("v", ["ls"], None)
def test_parse_flags_invalid_option():
assert _parse_flags(["-x", "ls"]) == (None, [], "-x")
assert _parse_flags(["-vx", "ls"]) == (None, [], "-x")
def test_parse_flags_bare_dash_is_operand():
assert _parse_flags(["-"]) == (None, ["-"], None)
def test_classify_keyword_before_route():
session = make_session()
registry = make_registry()
for kw in ("if", "for", "while", "case", "[[", "]]", "!", "{", "}"):
assert _classify(kw, session, registry) == "keyword"
def test_classify_shell_builtin_and_mount_are_builtin():
session = make_session()
registry = make_registry()
assert _classify("cd", session, registry) == "builtin"
assert _classify("echo", session, registry) == "builtin"
assert _classify("cat", session, registry) == "builtin"
assert _classify("jq", session, registry) == "builtin"
def test_classify_function_and_not_found():
session = make_session()
session.functions["myfn"] = []
registry = make_registry()
assert _classify("myfn", session, registry) == "function"
assert _classify("nope_xyz", session, registry) == "not_found"
def test_describe_lines():
assert _describe("if", "keyword") == "if is a shell keyword"
assert _describe("myfn", "function") == "myfn is a function"
assert _describe("cat", "builtin") == "cat is a shell builtin"
@pytest.mark.asyncio
async def test_a_flag_after_the_target_belongs_to_the_target():
shell = FakeShell()
await handle_command_builtin(shell, ["ls", "-l"], make_session(),
make_registry())
assert shell.lines == ["ls -l"]
@pytest.mark.asyncio
@@ -133,6 +89,18 @@ async def test_v_multi_name_any_found_rc0():
assert io.exit_code == 0
@pytest.mark.asyncio
async def test_V_warns_for_a_missing_name_while_exiting_0():
# bash prints the diagnostic and still exits 0 when another name
# resolved: the status and the stderr are independent.
out, io, _ = await handle_command_builtin(FakeShell(),
["-V", "cd", "nope_xyz"],
make_session(), make_registry())
assert await materialize(out) == b"cd is a shell builtin\n"
assert await materialize(io.stderr) == b"command: nope_xyz: not found\n"
assert io.exit_code == 0
@pytest.mark.asyncio
async def test_v_multi_name_none_found_rc1():
out, io, _ = await handle_command_builtin(FakeShell(),
@@ -227,64 +195,3 @@ async def test_run_mode_masks_function_then_restores():
await handle_command_builtin(shell, ["cat"], session, make_registry())
assert seen["masked"] is True
assert session.functions["cat"] is body
def _type_out(result) -> str:
out, _io, _node = result
return out.decode() if out is not None else ""
def test_type_reports_builtin():
out, io, _ = handle_type(["cd"], make_session(), make_registry())
assert out.decode() == "cd is a shell builtin\n"
assert io.exit_code == 0
def test_type_reports_keyword():
assert _type_out(handle_type(["if"], make_session(),
make_registry())) == "if is a shell keyword\n"
def test_type_t_prints_word():
assert _type_out(handle_type(["-t", "cd"], make_session(),
make_registry())) == "builtin\n"
assert _type_out(handle_type(["-t", "if"], make_session(),
make_registry())) == "keyword\n"
def test_type_mount_command_is_builtin():
assert _type_out(
handle_type(["cat"], make_session(),
make_registry())) == "cat is a shell builtin\n"
def test_type_not_found_warns_and_exits_1():
out, io, _ = handle_type(["nope"], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
assert io.stderr == b"type: nope: not found\n"
def test_type_t_not_found_is_silent():
out, io, _ = handle_type(["-t", "nope"], make_session(), make_registry())
assert out is None
assert io.exit_code == 1
assert io.stderr == b""
def test_type_all_found_exit_rule():
out, io, _ = handle_type(["cd", "nope"], make_session(), make_registry())
assert out.decode() == "cd is a shell builtin\n"
assert io.exit_code == 1
def test_type_path_mode_empty_for_builtin():
out, io, _ = handle_type(["-p", "cd"], make_session(), make_registry())
assert out is None
assert io.exit_code == 0
def test_type_invalid_option():
out, io, _ = handle_type(["-x", "cd"], make_session(), make_registry())
assert io.exit_code == 2
assert io.stderr.startswith(b"type: -x: invalid option\n")
@@ -0,0 +1,50 @@
from mirage.workspace.executor.builtins.getopt import last_of, scan_options
def test_letters_keep_typed_order_with_repeats():
scan = scan_options(["-a", "-tp", "-t", "cd"], "afptP")
assert scan.letters == ("a", "t", "p", "t")
assert scan.operands == ("cd", )
assert scan.bad is None
def test_scan_is_non_permuting():
scan = scan_options(["-a", "cd", "-t"], "at")
assert scan.letters == ("a", )
assert scan.operands == ("cd", "-t")
def test_double_dash_ends_options():
scan = scan_options(["-a", "--", "-t"], "at")
assert scan.letters == ("a", )
assert scan.operands == ("-t", )
def test_bare_dash_is_an_operand():
scan = scan_options(["-"], "at")
assert scan.letters == ()
assert scan.operands == ("-", )
def test_unknown_letter_is_reported_as_bash_spells_it():
assert scan_options(["-x", "cd"], "at").bad == "-x"
def test_a_long_spelling_fails_on_its_second_dash():
# bash: `type --foo` refuses `--`, not `--foo`.
assert scan_options(["--foo", "cd"], "afptP").bad == "--"
def test_no_args_scans_to_nothing():
scan = scan_options([], "at")
assert scan.letters == ()
assert scan.operands == ()
assert scan.bad is None
def test_last_of_resolves_a_mutually_exclusive_group():
assert last_of(("t", "p"), "tpP") == "p"
assert last_of(("p", "t"), "tpP") == "t"
assert last_of(("t", "p", "t"), "tpP") == "t"
assert last_of(("a", ), "tpP") is None
assert last_of((), "vV") is None
@@ -15,6 +15,7 @@
import asyncio
from unittest.mock import MagicMock
from mirage.commands.cli.types import CLISpec
from mirage.commands.config import RegisteredCommand
from mirage.commands.spec.types import CommandSpec, Option
from mirage.workspace.cli.registry import CLIRegistry
@@ -188,3 +189,74 @@ def test_render_man_index_dedupes_general_across_mounts():
reg = _mk_registry([m1, m2])
text = _render_man_index(Session(session_id="t"), reg)
assert text.count("- bc \u2014 bc desc") == 1
def _cli_tree() -> CLISpec:
return CLISpec(
name="linear",
description="Linear API client",
subcommands=(CLISpec(name="issue",
description="Manage issues",
aliases=("i", ),
subcommands=(CLISpec(name="create",
description="Create one",
fn=lambda: None), )), ),
)
def _cli_registry(mounts=None):
reg = _mk_registry(mounts or [])
reg.clis.install("linear", _cli_tree())
return reg
def test_handle_man_renders_an_installed_cli():
out, io, _node = asyncio.run(
handle_man(["linear"], Session(session_id="t"), _cli_registry()))
assert io.exit_code == 0
text = out.decode()
assert "Usage: linear" in text
assert "issue" in text
def test_handle_man_descends_a_verb_path_and_resolves_aliases():
reg = _cli_registry()
text = asyncio.run(
handle_man(["linear", "issue", "create"], Session(session_id="t"),
reg))[0].decode()
assert "Usage: linear issue create" in text
aliased = asyncio.run(
handle_man(["linear", "i", "create"], Session(session_id="t"),
reg))[0].decode()
assert aliased == text
def test_handle_man_unknown_verb_names_the_whole_line():
out, io, node = asyncio.run(
handle_man(["linear", "bogus"], Session(session_id="t"),
_cli_registry()))
assert out is None
assert io.exit_code == 1
assert io.stderr == b"man: no entry for linear bogus\n"
assert node.exit_code == 1
def test_handle_man_prints_the_cli_before_a_colliding_mount_command():
spec = CommandSpec(description="mount side")
mount = _mk_mount("/ram/", "ram", cmds={"linear": _mk_cmd("linear", spec)})
reg = _cli_registry([mount])
text = asyncio.run(handle_man(["linear"], Session(session_id="t"),
reg))[0].decode()
assert text.index("Usage: linear") < text.index("mount side")
def test_render_man_index_lists_installed_clis():
text = _render_man_index(Session(session_id="t"), _cli_registry())
assert "# clis" in text
assert "- linear \u2014 Linear API client" in text
assert text.index("# clis") < text.index("# general")
def test_render_man_index_omits_the_cli_section_when_none_installed():
assert "# clis" not in _render_man_index(Session(session_id="t"),
_mk_registry([]))
+26 -1
View File
@@ -17,7 +17,7 @@ from mirage.io import IOResult
from mirage.resource.ram import RAMResource
from mirage.types import MountMode
from mirage.workspace import Workspace
from mirage.workspace.route import SHELL_CONSUMERS, Consumer, route
from mirage.workspace.route import SHELL_CONSUMERS, Consumer, route, route_all
from mirage.workspace.session import Session
@@ -109,3 +109,28 @@ def test_shell_consumers_resolve_globs():
assert Consumer.CLI in SHELL_CONSUMERS
assert Consumer.MOUNT not in SHELL_CONSUMERS
assert Consumer.UNKNOWN not in SHELL_CONSUMERS
def test_route_all_reports_every_layer_winner_first():
session, ws = _fixture()
ws.register_cli("prog", _cli_tree())
assert route_all("prog", session, ws._registry) == [Consumer.CLI]
session.functions["prog"] = []
assert route_all("prog", session,
ws._registry) == [Consumer.FUNCTION, Consumer.CLI]
def test_route_all_is_empty_where_route_says_unknown():
session, ws = _fixture()
assert route_all("bogus", session, ws._registry) == []
assert route("bogus", session, ws._registry) is Consumer.UNKNOWN
def test_route_agrees_with_the_first_layer_route_all_reports():
session, ws = _fixture()
ws.register_cli("prog", _cli_tree())
session.functions["greet"] = []
for name in ("cd", "ln", "greet", "prog", "cat", "bogus"):
layers = route_all(name, session, ws._registry)
winner = layers[0] if layers else Consumer.UNKNOWN
assert route(name, session, ws._registry) is winner
@@ -14,6 +14,7 @@
import asyncio
from mirage.commands.cli.types import CLISpec
from mirage.resource.ram import RAMResource
from mirage.types import MountMode
from mirage.workspace import Workspace
@@ -139,6 +140,40 @@ def test_man_unknown_command_exits_1():
assert "no entry for" in err
def _cli_ws():
ws = _ws()
ws.register_cli(
"linear",
CLISpec(name="linear",
description="Linear API client",
subcommands=(CLISpec(name="issue",
description="Manage issues",
fn=lambda: None), )))
return ws
def test_installed_cli_is_discoverable_from_the_shell():
ws = _cli_ws()
assert _out(_exec(ws, "type linear")) == "linear is a mirage CLI\n"
assert _out(_exec(ws, "type -t linear")) == "cli\n"
assert _out(_exec(ws, "which linear")) == "linear\n"
assert "Usage: linear" in _out(_exec(ws, "man linear"))
assert "# clis" in _out(_exec(ws, "man"))
def test_which_reports_a_missing_name_through_the_status_only():
io = _exec(_cli_ws(), "which nope-xyz")
assert io.exit_code == 1
assert not io.stdout
assert not io.stderr
def test_a_shell_function_shadows_a_cli_and_type_a_shows_both():
ws = _cli_ws()
assert _out(_exec(ws, "linear() { echo shadowed; }; type -a linear")) == (
"linear is a function\nlinear is a mirage CLI\n")
def test_workspace_file_prompt_mentions_help_and_man():
ws = _ws()
prompt = ws.file_prompt
@@ -15,7 +15,7 @@
import { describe, expect, it } from 'vitest'
import { Option } from '../spec/types.ts'
import { CLISpec, type CLIVerbFn } from './types.ts'
import { walk } from './walk.ts'
import { findChild, findNode, walk } from './walk.ts'
const verb: CLIVerbFn = () => null
@@ -352,3 +352,28 @@ describe('walk float-typed group options', () => {
expect(ok.groupFlags).toEqual({ '--ratio': '2.5' })
})
})
describe('findChild / findNode', () => {
it('matches a subcommand by name or alias', () => {
const spec = new CLISpec({
name: 'gws',
subcommands: [new CLISpec({ name: 'checkout', aliases: ['co'], fn: verb })],
})
expect(findChild(spec, 'checkout')?.name).toBe('checkout')
expect(findChild(spec, 'co')?.name).toBe('checkout')
expect(findChild(spec, 'nope')).toBeNull()
})
it('returns the node and its canonical path', () => {
const found = findNode(tree(), ['gmail', 'send'])
expect(found?.node.name).toBe('send')
expect(found?.path).toEqual(['gmail', 'send'])
})
it('is the root with no verbs and null on an unknown verb', () => {
const spec = tree()
expect(findNode(spec, [])).toEqual({ node: spec, path: [] })
expect(findNode(spec, ['gmail', 'bogus'])).toBeNull()
expect(findNode(spec, ['bogus'])).toBeNull()
})
})
@@ -28,6 +28,34 @@ function verbDisplay(child: CLISpec): string {
return child.aliases.length > 0 ? `${child.name} (${child.aliases.join(', ')})` : child.name
}
/** The subcommand a word names, by canonical name or alias. */
export function findChild(node: CLISpec, word: string): CLISpec | null {
return node.subcommands.find((c) => c.name === word || c.aliases.includes(word)) ?? null
}
/**
* Descend a tree by verb words, null if a word names no subcommand.
*
* Returns the node and its canonical path, so an alias renders under the
* name it resolves to, the attribution rule `walk` uses. This is
* introspection only (`man`): no options are parsed and no usage error is
* produced, so a caller gets the node or nothing.
*/
export function findNode(
spec: CLISpec,
verbs: readonly string[],
): { node: CLISpec; path: string[] } | null {
let node = spec
const path: string[] = []
for (const word of verbs) {
const child = findChild(node, word)
if (child === null) return null
node = child
path.push(child.name)
}
return { node, path }
}
/**
* A group node's help: the ordinary command help plus Commands rows.
* One renderer serves leaves and groups; the same text serves `--help`
@@ -345,8 +373,8 @@ export function walk(head: string, spec: CLISpec, argv: readonly string[]): Walk
// An alias resolves to its canonical node; the path records the
// canonical name (argparse prog attribution: errors under `gws co`
// render as `gws checkout`).
const child = node.subcommands.find((c) => c.name === token || c.aliases.includes(token))
if (child === undefined) {
const child = findChild(node, token)
if (child === null) {
return unknownVerb(head, name, token)
}
node = child
@@ -215,6 +215,7 @@ export const ShellBuiltin = Object.freeze({
TIMEOUT: 'timeout',
COMMAND: 'command',
TYPE: 'type',
WHICH: 'which',
BREAK: 'break',
CONTINUE: 'continue',
RETURN: 'return',
@@ -81,6 +81,14 @@ describe('CLIRegistry', () => {
expect(() => reg.install('kill', tree())).toThrow(/shell builtin/)
})
it('refuses a shell keyword', () => {
// The parser consumes a reserved word, so the install would never be
// reachable from a line.
const reg = new CLIRegistry()
expect(() => reg.install('if', tree())).toThrow(/shell keyword/)
expect(() => reg.install('select', tree())).toThrow(/shell keyword/)
})
it('refuses general command collisions', () => {
const reg = new CLIRegistry()
expect(() => reg.install('grep', tree())).toThrow(/general command/)
@@ -14,7 +14,7 @@
import type { CLISpec } from '../../commands/cli/types.ts'
import { BUILTIN_SPECS } from '../../commands/spec/builtins.ts'
import { JOB_BUILTINS, NAMESPACE_COMMANDS, SHELL_NAMES } from '../route/constants.ts'
import { JOB_BUILTINS, KEYWORDS, NAMESPACE_COMMANDS, SHELL_NAMES } from '../route/constants.ts'
import { z } from 'zod'
import type { CLIInstall } from './types.ts'
@@ -27,6 +27,16 @@ import type { CLIInstall } from './types.ts'
* storage was mounted. Install is fail-loud: a bad name, a colliding
* name, or a config the spec's configModel rejects throws at install
* time, so a workspace that loads has only valid entries.
*
* The lifecycle is host-side only, and must stay that way: install and
* uninstall are called by the program embedding mirage, never by a line
* the agent types, so an agent cannot take away the tools it was given.
* Do not add an `install`/`uninstall` shell builtin. What an agent can
* do is shadow a head word with a shell function, which is bash's own
* rule, reversible with `unset -f`, bypassable with `command <name>`,
* and visible through `type -a`. Pinning a head word against that
* belongs in the policy layer's `preExecute`, since it is a
* per-deployment call rather than a property of the registry.
*/
export class CLIRegistry {
private readonly installs = new Map<string, CLIInstall>()
@@ -47,6 +57,11 @@ export class CLIRegistry {
if (SHELL_NAMES.has(name) || JOB_BUILTINS.has(name)) {
throw new Error(`CLI name '${name}' collides with a shell builtin`)
}
// A reserved word never reaches dispatch (the parser consumes it), so
// an install under one would be unreachable rather than wrong.
if (KEYWORDS.has(name)) {
throw new Error(`CLI name '${name}' is a shell keyword`)
}
if (NAMESPACE_COMMANDS.has(name) || name in BUILTIN_SPECS) {
throw new Error(`CLI name '${name}' collides with a general command`)
}
@@ -13,6 +13,7 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it, vi } from 'vitest'
import { CLISpec } from '../../commands/cli/types.ts'
import { GENERAL_COMMANDS } from '../../commands/builtin/general/index.ts'
import { IOResult, materialize } from '../../io/types.ts'
import type { ByteSource } from '../../io/types.ts'
@@ -1275,6 +1276,72 @@ function fakeShell(exitCodes: number[] = []): {
}
}
describe('handleMan for installed CLIs', () => {
function cliRegistry(): MountRegistry {
const reg = new MountRegistry({ '/ram/': new RAMResource() }, MountMode.WRITE)
wireRegistry(reg)
reg.clis.install(
'linear',
new CLISpec({
name: 'linear',
description: 'Linear API client',
subcommands: [
new CLISpec({
name: 'issue',
description: 'Manage issues',
aliases: ['i'],
subcommands: [
new CLISpec({
name: 'create',
description: 'Create one',
fn: () => [null, new IOResult()],
}),
],
}),
],
}),
)
return reg
}
it('renders an installed CLI', async () => {
const [out, io] = handleMan(
['linear'],
new Session({ sessionId: 't', cwd: '/' }),
cliRegistry(),
)
expect(io.exitCode).toBe(0)
const text = await readBody(out)
expect(text).toContain('Usage: linear')
expect(text).toContain('issue')
})
it('descends a verb path and resolves aliases', async () => {
const reg = cliRegistry()
const s = new Session({ sessionId: 't', cwd: '/' })
const text = await readBody(handleMan(['linear', 'issue', 'create'], s, reg)[0])
expect(text).toContain('Usage: linear issue create')
expect(await readBody(handleMan(['linear', 'i', 'create'], s, reg)[0])).toBe(text)
})
it('names the whole line for an unknown verb', () => {
const s = new Session({ sessionId: 't', cwd: '/' })
const [out, io] = handleMan(['linear', 'bogus'], s, cliRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
const errBytes = io.stderr instanceof Uint8Array ? io.stderr : null
expect(decode(errBytes)).toBe('man: no entry for linear bogus\n')
})
it('lists installed CLIs in the bare index, before general', async () => {
const s = new Session({ sessionId: 't', cwd: '/' })
const text = await readBody(handleMan([], s, cliRegistry())[0])
expect(text).toContain('# clis')
expect(text).toContain('- linear — Linear API client')
expect(text.indexOf('# clis')).toBeLessThan(text.indexOf('# general'))
})
})
describe('handleEcho GNU option rules', () => {
it('trailing -n prints literally', () => {
const [out] = handleEcho(['hi', '-n'])
@@ -18,7 +18,7 @@ import type { ByteSource } from '../../../io/types.ts'
import { CLIRegistry } from '../../cli/registry.ts'
import type { MountRegistry } from '../../mount/registry.ts'
import { Session } from '../../session/session.ts'
import { handleCommandBuiltin, handleType, parseFlags } from './command.ts'
import { handleCommandBuiltin } from './command.ts'
const MOUNT_COMMANDS = new Set(['cat', 'grep', 'ls', 'jq'])
@@ -43,36 +43,20 @@ function decode(b: Uint8Array | null): string {
return b === null ? '' : new TextDecoder().decode(b)
}
describe('parseFlags', () => {
it('last of -v/-V wins', () => {
expect(parseFlags(['-v', 'ls'])).toEqual(['v', ['ls'], null])
expect(parseFlags(['-V', 'ls'])).toEqual(['V', ['ls'], null])
expect(parseFlags(['-vV', 'ls'])).toEqual(['V', ['ls'], null])
expect(parseFlags(['-Vv', 'ls'])).toEqual(['v', ['ls'], null])
describe('command option handling', () => {
it.each([
[['-vV', 'cd'], 'cd is a shell builtin\n'],
[['-Vv', 'cd'], 'cd\n'],
[['-pv', 'cd'], 'cd\n'],
])('last of -v/-V wins and -p is inert: %s', async (args, expected) => {
const [out] = await handleCommandBuiltin(vi.fn(), args, makeSession(), makeRegistry())
expect(await body(out)).toBe(expected)
})
it('accepts -p but it is inert', () => {
expect(parseFlags(['-p', 'ls'])).toEqual([null, ['ls'], null])
expect(parseFlags(['-pv', 'ls'])).toEqual(['v', ['ls'], null])
})
it('stops at the first operand (flag after name belongs to target)', () => {
expect(parseFlags(['ls', '-l'])).toEqual([null, ['ls', '-l'], null])
expect(parseFlags(['-v', 'ls', '-l'])).toEqual(['v', ['ls', '-l'], null])
})
it('-- ends options', () => {
expect(parseFlags(['--', 'ls'])).toEqual([null, ['ls'], null])
expect(parseFlags(['-v', '--', 'ls'])).toEqual(['v', ['ls'], null])
})
it('reports the first invalid option', () => {
expect(parseFlags(['-x', 'ls'])).toEqual([null, [], '-x'])
expect(parseFlags(['-vx', 'ls'])).toEqual([null, [], '-x'])
})
it('a bare dash is an operand', () => {
expect(parseFlags(['-'])).toEqual([null, ['-'], null])
it('leaves a flag after the target name to the target', async () => {
const shell = vi.fn(() => Promise.resolve(new IOResult()))
await handleCommandBuiltin(shell, ['ls', '-l'], makeSession(), makeRegistry())
expect(shell).toHaveBeenCalledWith('ls -l', expect.anything())
})
})
@@ -160,6 +144,20 @@ describe('handleCommandBuiltin -v/-V', () => {
expect(io.exitCode).toBe(1)
})
it('-V warns for a missing name while exiting 0', async () => {
// bash prints the diagnostic and still exits 0 when another name
// resolved: the status and the stderr are independent.
const [out, io] = await handleCommandBuiltin(
vi.fn(),
['-V', 'cd', 'nope_xyz'],
makeSession(),
makeRegistry(),
)
expect(await body(out)).toBe('cd is a shell builtin\n')
expect(decode(await materialize(io.stderr))).toBe('command: nope_xyz: not found\n')
expect(io.exitCode).toBe(0)
})
it('reports a function', async () => {
const session = makeSession()
session.functions.myfn = []
@@ -249,58 +247,3 @@ describe('handleCommandBuiltin run mode', () => {
expect(session.functions.cat).toBe(fnBody)
})
})
describe('handleType', () => {
it('reports a builtin', async () => {
const [out, io] = handleType(['cd'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cd is a shell builtin\n')
expect(io.exitCode).toBe(0)
})
it('reports a keyword', async () => {
const [out] = handleType(['if'], makeSession(), makeRegistry())
expect(await body(out)).toBe('if is a shell keyword\n')
})
it('-t prints the classification word', async () => {
expect(await body(handleType(['-t', 'cd'], makeSession(), makeRegistry())[0])).toBe('builtin\n')
expect(await body(handleType(['-t', 'if'], makeSession(), makeRegistry())[0])).toBe('keyword\n')
})
it('classifies a mount command as a builtin', async () => {
const [out] = handleType(['cat'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cat is a shell builtin\n')
})
it('warns and exits 1 for an unknown name', async () => {
const [out, io] = handleType(['nope'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
expect(decode(await materialize(io.stderr))).toBe('type: nope: not found\n')
})
it('-t is silent for an unknown name', async () => {
const [out, io] = handleType(['-t', 'nope'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
expect(decode(await materialize(io.stderr))).toBe('')
})
it('uses the all-found exit rule', async () => {
const [out, io] = handleType(['cd', 'nope'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cd is a shell builtin\n')
expect(io.exitCode).toBe(1)
})
it('-p is empty for a builtin', () => {
const [out, io] = handleType(['-p', 'cd'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(0)
})
it('rejects an invalid option', async () => {
const [, io] = handleType(['-x', 'cd'], makeSession(), makeRegistry())
expect(io.exitCode).toBe(2)
expect(decode(await materialize(io.stderr)).startsWith('type: -x: invalid option\n')).toBe(true)
})
})
@@ -16,94 +16,14 @@ import { IOResult } from '../../../io/types.ts'
import type { ByteSource } from '../../../io/types.ts'
import { shellJoin } from '../../../shell/join.ts'
import type { MountRegistry } from '../../mount/registry.ts'
import { route } from '../../route/route.ts'
import { Consumer } from '../../route/types.ts'
import type { Session } from '../../session/session.ts'
import { ExecutionNode } from '../../types.ts'
import { lastOf, scanOptions } from './getopt.ts'
import { classify, describe } from './lookup/index.ts'
import type { Result, ExecuteStringFn } from './scope.ts'
const USAGE = 'command: usage: command [-pVv] command [arg ...]\n'
// bash reserved words: reported by `command -v/-V` as keywords even
// though the parser, not the executor, consumes them.
const KEYWORDS: ReadonlySet<string> = new Set([
'if',
'then',
'else',
'elif',
'fi',
'case',
'esac',
'for',
'select',
'while',
'until',
'do',
'done',
'in',
'function',
'time',
'coproc',
'{',
'}',
'!',
'[[',
']]',
])
/**
* Split `command`'s own options from its operands.
*
* bash uses non-permuting getopt: option scanning stops at the first
* non-option word (or `--`), so a flag after the target name belongs to
* the target. Only `-p -v -V` are valid; `-p` is accepted but inert
* (mirage has no PATH), and the last of `-v`/`-V` wins. Returns
* `[mode, rest, bad]` where `bad` is the first invalid option or null.
*/
export function parseFlags(args: readonly string[]): [string | null, string[], string | null] {
let mode: string | null = null
let i = 0
while (i < args.length) {
const tok = args[i] ?? ''
if (tok === '--') {
i += 1
break
}
if (!(tok.startsWith('-') && tok.length > 1)) break
for (const ch of tok.slice(1)) {
if (ch === 'v') mode = 'v'
else if (ch === 'V') mode = 'V'
else if (ch === 'p') continue
else return [null, [], `-${ch}`]
}
i += 1
}
return [mode, [...args.slice(i)], null]
}
/**
* Classify a name for `command -v/-V` reporting.
*
* Every mirage-native runnable non-function name (shell builtin,
* namespace command, or mount command) reports as 'builtin': mirage has
* no external binaries, so there is no honest path to print, and
* grouping them matches bash's runnable-and-in-process category (a
* deliberate divergence from bash's file paths).
*/
function classify(name: string, session: Session, registry: MountRegistry): string {
if (KEYWORDS.has(name)) return 'keyword'
const consumer = route(name, session, registry)
if (consumer === Consumer.FUNCTION) return 'function'
if (consumer === Consumer.UNKNOWN) return 'not_found'
return 'builtin'
}
function describe(name: string, kind: string): string {
if (kind === 'keyword') return `${name} is a shell keyword`
if (kind === 'function') return `${name} is a function`
return `${name} is a shell builtin`
}
/**
* Run the `-v`/`-V` introspection modes.
*
@@ -124,7 +44,7 @@ function probe(
let anyFound = false
for (const name of rest) {
const kind = classify(name, session, registry)
if (kind === 'not_found') {
if (kind === null) {
if (mode === 'V') errLines.push(`command: ${name}: not found`)
continue
}
@@ -142,107 +62,6 @@ function probe(
]
}
/**
* Split `type`'s options from its name operands.
*
* Recognizes `-t` (type word only), `-p`/`-P` (path; empty for mirage's
* pathless builtins), `-a` (all locations; one in mirage), and `-f` (skip
* the function table). Non-permuting like bash. Returns
* `[mode, nofunc, rest, bad]` where `bad` is the first invalid option.
*/
function parseTypeFlags(
args: readonly string[],
): ['t' | 'p' | null, boolean, string[], string | null] {
let mode: 't' | 'p' | null = null
let nofunc = false
let i = 0
while (i < args.length) {
const tok = args[i] ?? ''
if (tok === '--') {
i += 1
break
}
if (!(tok.startsWith('-') && tok.length > 1)) break
for (const ch of tok.slice(1)) {
if (ch === 't') mode = 't'
else if (ch === 'p' || ch === 'P') mode = 'p'
else if (ch === 'a') continue
else if (ch === 'f') nofunc = true
else return [null, false, [], `-${ch}`]
}
i += 1
}
return [mode, nofunc, [...args.slice(i)], null]
}
function typeWord(kind: string): string {
if (kind === 'keyword') return 'keyword'
if (kind === 'function') return 'function'
return 'builtin'
}
/**
* Run the `type` builtin (`type [-afptP] name [name ...]`).
*
* Mirrors `command -V` resolution (every mirage-native runnable name is a
* shell builtin; no external paths) but uses `type`'s all-found exit rule:
* 0 only when every name resolves. `-t` prints the classification word,
* `-p`/`-P` print a path (always empty here), and a missing name warns on
* stderr unless a word-only mode (`-t`/`-p`) is active.
*/
export function handleType(
args: readonly string[],
session: Session,
registry: MountRegistry,
): Result {
const [mode, nofunc, rest, bad] = parseTypeFlags(args)
const enc = new TextEncoder()
if (bad !== null) {
const err = enc.encode(
`type: ${bad}: invalid option\ntype: usage: type [-afptP] name [name ...]\n`,
)
return [
null,
new IOResult({ exitCode: 2, stderr: err }),
new ExecutionNode({ command: 'type', exitCode: 2, stderr: err }),
]
}
const outLines: string[] = []
const errLines: string[] = []
let allFound = true
for (const name of rest) {
let kind: string
const savedFn = session.functions[name]
if (nofunc && savedFn !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete session.functions[name]
try {
kind = classify(name, session, registry)
} finally {
session.functions[name] = savedFn
}
} else {
kind = classify(name, session, registry)
}
if (kind === 'not_found') {
allFound = false
if (mode === null) errLines.push(`type: ${name}: not found`)
continue
}
if (mode === 't') outLines.push(typeWord(kind))
else if (mode === 'p') continue
else outLines.push(describe(name, kind))
}
const out = outLines.length > 0 ? enc.encode(`${outLines.join('\n')}\n`) : null
const err = errLines.length > 0 ? enc.encode(`${errLines.join('\n')}\n`) : new Uint8Array()
const code = rest.length === 0 || allFound ? 0 : 1
return [
out,
new IOResult({ exitCode: code, stderr: err }),
new ExecutionNode({ command: 'type', exitCode: code, stderr: err }),
]
}
/**
* Run the `command` builtin (`command [-pVv] name [arg ...]`).
*
@@ -251,7 +70,8 @@ export function handleType(
* function table for the inner run so a shadowing function is skipped
* while builtins and mount commands still resolve. Already expanded
* operands are re-joined with shellJoin so they survive re-parsing as one
* token each; the pipe stdin flows to the inner command.
* token each; the pipe stdin flows to the inner command. `-p` is accepted
* but inert (mirage has no PATH) and the last of `-v`/`-V` wins.
*/
export async function handleCommandBuiltin(
executeFn: ExecuteStringFn,
@@ -260,15 +80,17 @@ export async function handleCommandBuiltin(
registry: MountRegistry,
stdin: ByteSource | null = null,
): Promise<Result> {
const [mode, rest, bad] = parseFlags(args)
if (bad !== null) {
const err = new TextEncoder().encode(`command: ${bad}: invalid option\n${USAGE}`)
const scan = scanOptions(args, 'pvV')
if (scan.bad !== null) {
const err = new TextEncoder().encode(`command: ${scan.bad}: invalid option\n${USAGE}`)
return [
null,
new IOResult({ exitCode: 2, stderr: err }),
new ExecutionNode({ command: 'command', exitCode: 2, stderr: err }),
]
}
const mode = lastOf(scan.letters, 'vV')
const rest = scan.operands
if (mode !== null) return probe(mode, rest, session, registry)
if (rest.length === 0) {
return [null, new IOResult(), new ExecutionNode({ command: 'command', exitCode: 0 })]
@@ -0,0 +1,71 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { lastOf, scanOptions } from './getopt.ts'
// Mirrors python/tests/workspace/executor/builtins/test_getopt.py.
describe('scanOptions', () => {
it('keeps letters in typed order, repeats included', () => {
const scan = scanOptions(['-a', '-tp', '-t', 'cd'], 'afptP')
expect(scan.letters).toEqual(['a', 't', 'p', 't'])
expect(scan.operands).toEqual(['cd'])
expect(scan.bad).toBeNull()
})
it('is non-permuting', () => {
const scan = scanOptions(['-a', 'cd', '-t'], 'at')
expect(scan.letters).toEqual(['a'])
expect(scan.operands).toEqual(['cd', '-t'])
})
it('ends options at --', () => {
const scan = scanOptions(['-a', '--', '-t'], 'at')
expect(scan.letters).toEqual(['a'])
expect(scan.operands).toEqual(['-t'])
})
it('treats a bare dash as an operand', () => {
const scan = scanOptions(['-'], 'at')
expect(scan.letters).toEqual([])
expect(scan.operands).toEqual(['-'])
})
it('reports an unknown letter the way bash spells it', () => {
expect(scanOptions(['-x', 'cd'], 'at').bad).toBe('-x')
})
it('fails a long spelling on its second dash', () => {
// bash: `type --foo` refuses `--`, not `--foo`.
expect(scanOptions(['--foo', 'cd'], 'afptP').bad).toBe('--')
})
it('scans no args to nothing', () => {
const scan = scanOptions([], 'at')
expect(scan.letters).toEqual([])
expect(scan.operands).toEqual([])
expect(scan.bad).toBeNull()
})
})
describe('lastOf', () => {
it('resolves a mutually exclusive group', () => {
expect(lastOf(['t', 'p'], 'tpP')).toBe('p')
expect(lastOf(['p', 't'], 'tpP')).toBe('t')
expect(lastOf(['t', 'p', 't'], 'tpP')).toBe('t')
expect(lastOf(['a'], 'tpP')).toBeNull()
expect(lastOf([], 'vV')).toBeNull()
})
})
@@ -0,0 +1,73 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
/**
* A bash builtin's option scan: letters as typed, then operands.
*
* `letters` keeps every option letter in the order it was typed, repeats
* kept, so a builtin whose flags are mutually exclusive can apply bash's
* last-one-wins rule. `bad` is the first invalid option, spelled the way
* the refusal spells it, or null when every letter is known.
*/
export interface OptionScan {
letters: readonly string[]
operands: readonly string[]
bad: string | null
}
/**
* Scan a bash builtin's leading option letters.
*
* bash builtins take single letters only (`internal_getopt`), which is a
* different grammar from the GNU tools `parseShellOptions` serves:
* scanning is non-permuting and stops at `--` or the first non-option
* word, a token carries options only when it starts with a dash and is
* longer than one character, and every character after that dash is a
* letter. A long spelling therefore fails on its second dash, which is
* why bash refuses `type --foo` as `--` and not as `--foo` (pinned
* against bash 5.2, debian:stable-slim).
*/
export function scanOptions(args: readonly string[], known: string): OptionScan {
const letters: string[] = []
let i = 0
while (i < args.length) {
const tok = args[i] ?? ''
if (tok === '--') {
i += 1
break
}
if (!(tok.startsWith('-') && tok.length > 1)) break
for (const ch of tok.slice(1)) {
if (!known.includes(ch)) return { letters: [], operands: [], bad: `-${ch}` }
letters.push(ch)
}
i += 1
}
return { letters, operands: args.slice(i), bad: null }
}
/**
* The last of a mutually exclusive letter group, as bash resolves it.
*
* bash holds such a group in one variable, so the last letter typed
* wins: `type -tp` prints a path and `type -pt` a type word, and
* `command -vV` is verbose where `command -Vv` is not.
*/
export function lastOf(letters: readonly string[], choices: string): string | null {
for (let i = letters.length - 1; i >= 0; i -= 1) {
const ch = letters[i] ?? ''
if (choices.includes(ch)) return ch
}
return null
}
@@ -48,5 +48,6 @@ export { handleBash, handleEval, handleSleep, handleSource } from './script.ts'
export { handleTest } from './condition/index.ts'
export { handleTimeout } from './timeout.ts'
export { handleXargs } from './xargs.ts'
export { handleCommandBuiltin, handleType } from './command.ts'
export { handleCommandBuiltin } from './command.ts'
export { handleType, handleWhich } from './lookup/index.ts'
export { handleEcho, handlePrintf } from './text.ts'
@@ -0,0 +1,92 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { CLISpec } from '../../../../commands/cli/types.ts'
import { IOResult } from '../../../../io/types.ts'
import { CLIRegistry } from '../../../cli/registry.ts'
import type { MountRegistry } from '../../../mount/registry.ts'
import { Session } from '../../../session/session.ts'
import { classify, classifyAll } from './classify.ts'
import { NameKind } from './types.ts'
// Mirrors python/tests/workspace/executor/builtins/lookup/test_classify.py.
const MOUNT_COMMANDS = new Set(['cat', 'grep', 'ls', 'jq'])
function noop(): [null, IOResult] {
return [null, new IOResult()]
}
const TREE = new CLISpec({
name: 'linear',
subcommands: [new CLISpec({ name: 'issue', fn: noop })],
})
function makeRegistry(withCli = false): MountRegistry {
const clis = new CLIRegistry()
if (withCli) clis.install('linear', TREE)
return {
mountForCommand: (name: string): unknown => (MOUNT_COMMANDS.has(name) ? {} : null),
clis,
} as unknown as MountRegistry
}
function makeSession(): Session {
return new Session({ sessionId: 's1' })
}
describe('classify', () => {
it('names each layer', () => {
const session = makeSession()
const registry = makeRegistry(true)
session.functions.deploy = 'deploy() { :; }'
expect(classify('if', session, registry)).toBe(NameKind.KEYWORD)
expect(classify('deploy', session, registry)).toBe(NameKind.FUNCTION)
expect(classify('linear', session, registry)).toBe(NameKind.CLI)
expect(classify('cd', session, registry)).toBe(NameKind.BUILTIN)
expect(classify('cat', session, registry)).toBe(NameKind.BUILTIN)
expect(classify('nope', session, registry)).toBeNull()
})
it('classifyAll reports a function shadowing a CLI, winner first', () => {
const session = makeSession()
const registry = makeRegistry(true)
expect(classifyAll('linear', session, registry)).toEqual([NameKind.CLI])
session.functions.linear = 'linear() { :; }'
expect(classifyAll('linear', session, registry)).toEqual([NameKind.FUNCTION, NameKind.CLI])
})
it('keeps the layers under a keyword', () => {
// bash: `function time { :; }; type -a time` prints the keyword line
// then the function line.
const session = makeSession()
session.functions.then = 'then() { :; }'
expect(classifyAll('then', session, makeRegistry())).toEqual([
NameKind.KEYWORD,
NameKind.FUNCTION,
])
})
it('does not call time or coproc keywords', () => {
// mirage implements neither construct, so `time echo hi` reports
// command not found and type may not call it a keyword.
const session = makeSession()
const registry = makeRegistry()
expect(classify('time', session, registry)).toBeNull()
expect(classify('coproc', session, registry)).toBeNull()
session.functions.time = 'time() { :; }'
expect(classify('time', session, registry)).toBe(NameKind.FUNCTION)
})
})
@@ -0,0 +1,76 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { MountRegistry } from '../../../mount/registry.ts'
import { KEYWORDS } from '../../../route/constants.ts'
import { route, routeAll } from '../../../route/route.ts'
import type { Session } from '../../../session/session.ts'
import { DESCRIPTIONS, KIND_BY_CONSUMER } from './constants.ts'
import { NameKind } from './types.ts'
/** Classify the name as the layer that would run it, null if none does. */
export function classify(name: string, session: Session, registry: MountRegistry): NameKind | null {
if (KEYWORDS.has(name)) return NameKind.KEYWORD
return KIND_BY_CONSUMER[route(name, session, registry)] ?? null
}
/**
* Classify every layer holding the name, most-preferred first.
*
* A reserved word goes first and does not end the walk: bash prints both
* lines when a function shares a keyword's name (pinned:
* `function time { :; }; type -a time` prints the keyword line then the
* function line). mirage's parser is looser than bash's about reserved
* words as function names, so the shadow is reachable here for any of
* them, and hiding it would leave `type -a` claiming a keyword while the
* line runs the function.
*
* Duplicate kinds are dropped, since the kinds are coarser than the
* layers: a shell builtin that a mount also registers is one `builtin`
* line, not two identical ones.
*/
export function classifyAll(name: string, session: Session, registry: MountRegistry): NameKind[] {
const kinds: NameKind[] = KEYWORDS.has(name) ? [NameKind.KEYWORD] : []
for (const consumer of routeAll(name, session, registry)) {
const kind = KIND_BY_CONSUMER[consumer]
if (kind !== undefined && !kinds.includes(kind)) kinds.push(kind)
}
return kinds
}
/**
* The kinds to report for one name: hide a layer, then take the top.
*
* Hiding is a filter over the layer list, never an edit to the session,
* and it runs before the winner is picked. That order is what keeps the
* winner honest: `type -f` reports the layer under a shadowing function,
* and `which` the layer under a reserved word, where filtering
* afterwards would report nothing at all.
*/
export function locations(
name: string,
session: Session,
registry: MountRegistry,
allMode: boolean,
drop: NameKind | null = null,
): NameKind[] {
let kinds = classifyAll(name, session, registry)
if (drop !== null) kinds = kinds.filter((kind) => kind !== drop)
return allMode ? kinds : kinds.slice(0, 1)
}
/** Render the verbose line `command -V` and `type` print. */
export function describe(name: string, kind: NameKind): string {
return `${name} is ${DESCRIPTIONS[kind]}`
}
@@ -0,0 +1,44 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { Consumer } from '../../../route/types.ts'
import { NameKind } from './types.ts'
export const TYPE_USAGE = 'type: usage: type [-afptP] name [name ...]\n'
export const WHICH_USAGE = 'which: usage: which [-as] name [name ...]\n'
// The words each builtin accepts, as bash's usage line spells them.
export const TYPE_OPTIONS = 'afptP'
export const WHICH_OPTIONS = 'as'
// Shell builtins, namespace commands and mount commands are all
// in-process and pathless, so they share bash's runnable-and-in-process
// category. That collapse is deliberate; `cli` is kept apart because an
// installed CLI is the one runnable an agent cannot otherwise discover.
// UNKNOWN is absent: it is what `route` reports for a name no layer
// holds, and `routeAll` never yields it.
export const KIND_BY_CONSUMER: Readonly<Partial<Record<Consumer, NameKind>>> = Object.freeze({
[Consumer.SESSION]: NameKind.BUILTIN,
[Consumer.NAMESPACE]: NameKind.BUILTIN,
[Consumer.FUNCTION]: NameKind.FUNCTION,
[Consumer.CLI]: NameKind.CLI,
[Consumer.MOUNT]: NameKind.BUILTIN,
})
export const DESCRIPTIONS: Readonly<Record<NameKind, string>> = Object.freeze({
[NameKind.KEYWORD]: 'a shell keyword',
[NameKind.FUNCTION]: 'a function',
[NameKind.CLI]: 'a mirage CLI',
[NameKind.BUILTIN]: 'a shell builtin',
})
@@ -0,0 +1,220 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { CLISpec } from '../../../../commands/cli/types.ts'
import { IOResult, materialize } from '../../../../io/types.ts'
import type { ByteSource } from '../../../../io/types.ts'
import { CLIRegistry } from '../../../cli/registry.ts'
import type { MountRegistry } from '../../../mount/registry.ts'
import { Session } from '../../../session/session.ts'
import { handleType, handleWhich } from './handle.ts'
// Mirrors python/tests/workspace/executor/builtins/lookup/test_handle.py.
const MOUNT_COMMANDS = new Set(['cat', 'grep', 'ls', 'jq'])
function noop(): [null, IOResult] {
return [null, new IOResult()]
}
const TREE = new CLISpec({
name: 'linear',
subcommands: [new CLISpec({ name: 'issue', fn: noop })],
})
function makeRegistry(withCli = false): MountRegistry {
const clis = new CLIRegistry()
if (withCli) clis.install('linear', TREE)
return {
mountForCommand: (name: string): unknown => (MOUNT_COMMANDS.has(name) ? {} : null),
clis,
} as unknown as MountRegistry
}
function makeSession(): Session {
return new Session({ sessionId: 's1' })
}
async function body(out: ByteSource | null): Promise<string> {
if (out === null) return ''
const buf = out instanceof Uint8Array ? out : await materialize(out as AsyncIterable<Uint8Array>)
return new TextDecoder().decode(buf)
}
function decode(b: Uint8Array | null): string {
return b === null ? '' : new TextDecoder().decode(b)
}
describe('handleType', () => {
it('reports a builtin', async () => {
const [out, io] = handleType(['cd'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cd is a shell builtin\n')
expect(io.exitCode).toBe(0)
})
it('reports a keyword', async () => {
const [out] = handleType(['if'], makeSession(), makeRegistry())
expect(await body(out)).toBe('if is a shell keyword\n')
})
it('-a prints the function under a keyword', async () => {
const session = makeSession()
session.functions.then = 'then() { :; }'
const [out] = handleType(['-a', 'then'], session, makeRegistry())
expect(await body(out)).toBe('then is a shell keyword\nthen is a function\n')
})
it('reports an installed CLI as its own kind', async () => {
const [out] = handleType(['linear'], makeSession(), makeRegistry(true))
expect(await body(out)).toBe('linear is a mirage CLI\n')
expect(await body(handleType(['-t', 'linear'], makeSession(), makeRegistry(true))[0])).toBe(
'cli\n',
)
})
it('-t prints the classification word', async () => {
expect(await body(handleType(['-t', 'cd'], makeSession(), makeRegistry())[0])).toBe('builtin\n')
expect(await body(handleType(['-t', 'if'], makeSession(), makeRegistry())[0])).toBe('keyword\n')
})
it('resolves -t and -p as one group, last one typed winning', async () => {
// bash: `type -tp cd` prints a path (empty here), `type -pt cd` the
// type word.
expect(await body(handleType(['-tp', 'cd'], makeSession(), makeRegistry())[0])).toBe('')
expect(await body(handleType(['-pt', 'cd'], makeSession(), makeRegistry())[0])).toBe(
'builtin\n',
)
expect(await body(handleType(['-P', 'cd'], makeSession(), makeRegistry())[0])).toBe('')
})
it('classifies a mount command as a builtin', async () => {
const [out] = handleType(['cat'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cat is a shell builtin\n')
})
it('-a prints every layer holding the name', async () => {
const session = makeSession()
session.functions.linear = 'linear() { :; }'
const [out] = handleType(['-a', 'linear'], session, makeRegistry(true))
expect(await body(out)).toBe('linear is a function\nlinear is a mirage CLI\n')
const [words] = handleType(['-at', 'linear'], session, makeRegistry(true))
expect(await body(words)).toBe('function\ncli\n')
})
it('-f skips the function table so the CLI below it shows', async () => {
const session = makeSession()
session.functions.linear = 'linear() { :; }'
const [out] = handleType(['-f', 'linear'], session, makeRegistry(true))
expect(await body(out)).toBe('linear is a mirage CLI\n')
expect(session.functions.linear).toBe('linear() { :; }')
})
it('-f on a function-only name is not found', () => {
const session = makeSession()
session.functions.myfn = 'myfn() { :; }'
const [out, io] = handleType(['-f', 'myfn'], session, makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
})
it('warns and exits 1 for an unknown name', async () => {
const [out, io] = handleType(['nope'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
expect(decode(await materialize(io.stderr))).toBe('type: nope: not found\n')
})
it('-t is silent for an unknown name', async () => {
const [out, io] = handleType(['-t', 'nope'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
expect(decode(await materialize(io.stderr))).toBe('')
})
it('uses the all-found exit rule', async () => {
const [out, io] = handleType(['cd', 'nope'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cd is a shell builtin\n')
expect(io.exitCode).toBe(1)
})
it('-p is empty for a builtin', () => {
const [out, io] = handleType(['-p', 'cd'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(0)
})
it('rejects an invalid option', async () => {
const [, io] = handleType(['-x', 'cd'], makeSession(), makeRegistry())
expect(io.exitCode).toBe(2)
expect(decode(await materialize(io.stderr)).startsWith('type: -x: invalid option\n')).toBe(true)
})
})
describe('handleWhich', () => {
it('prints the name for every runnable, with no fake path', async () => {
const registry = makeRegistry(true)
expect(await body(handleWhich(['linear'], makeSession(), registry)[0])).toBe('linear\n')
expect(await body(handleWhich(['cd'], makeSession(), registry)[0])).toBe('cd\n')
expect(await body(handleWhich(['cat'], makeSession(), registry)[0])).toBe('cat\n')
})
it('is silent on a miss and exits 1', async () => {
const [out, io] = handleWhich(['nope'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
expect(decode(await materialize(io.stderr))).toBe('')
})
it('does not resolve a keyword', () => {
const [out, io] = handleWhich(['if'], makeSession(), makeRegistry())
expect(out).toBeNull()
expect(io.exitCode).toBe(1)
})
it('reports the layer under a keyword', async () => {
// The keyword is filtered before the winner is picked, so the
// function below it is what `which` resolves.
const session = makeSession()
session.functions.then = 'then() { :; }'
const [out, io] = handleWhich(['then'], session, makeRegistry())
expect(await body(out)).toBe('then\n')
expect(io.exitCode).toBe(0)
})
it('uses the all-found exit rule and exits 1 with no operands', async () => {
const [out, io] = handleWhich(['cd', 'nope'], makeSession(), makeRegistry())
expect(await body(out)).toBe('cd\n')
expect(io.exitCode).toBe(1)
expect(handleWhich([], makeSession(), makeRegistry())[1].exitCode).toBe(1)
})
it('-a prints a line per layer and -s reports through the status', async () => {
const session = makeSession()
session.functions.linear = 'linear() { :; }'
const [out] = handleWhich(['-a', 'linear'], session, makeRegistry(true))
expect(await body(out)).toBe('linear\nlinear\n')
const [quiet, io] = handleWhich(['-s', 'linear'], session, makeRegistry(true))
expect(quiet).toBeNull()
expect(io.exitCode).toBe(0)
})
it('rejects an invalid option', async () => {
const [, io] = handleWhich(['-z', 'cd'], makeSession(), makeRegistry())
expect(io.exitCode).toBe(2)
expect(decode(await materialize(io.stderr)).startsWith('which: -z: invalid option\n')).toBe(
true,
)
})
})
@@ -0,0 +1,125 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { IOResult } from '../../../../io/types.ts'
import type { MountRegistry } from '../../../mount/registry.ts'
import type { Session } from '../../../session/session.ts'
import { ExecutionNode } from '../../../types.ts'
import { lastOf, scanOptions } from '../getopt.ts'
import type { Result } from '../scope.ts'
import { describe, locations } from './classify.ts'
import { TYPE_OPTIONS, TYPE_USAGE, WHICH_OPTIONS, WHICH_USAGE } from './constants.ts'
import { NameKind } from './types.ts'
/** The refusal shape both builtins use for an unknown option. */
function optionError(cmd: string, bad: string, usage: string): Result {
const err = new TextEncoder().encode(`${cmd}: ${bad}: invalid option\n${usage}`)
return [
null,
new IOResult({ exitCode: 2, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 2, stderr: err }),
]
}
/**
* Run the `type` builtin (`type [-afptP] name [name ...]`).
*
* Resolution matches `command -V`, but the exit rule is `type`'s: 0 only
* when every name resolves. `-t` prints the classification word,
* `-p`/`-P` print a path (always empty here) and are one mutually
* exclusive group with `-t`, `-a` prints one line per layer holding the
* name (a shell function shadowing an installed CLI is the case that has
* two), `-f` ignores the function table, and a missing name warns on
* stderr unless a word-only mode (`-t`/`-p`) is active.
*/
export function handleType(
args: readonly string[],
session: Session,
registry: MountRegistry,
): Result {
const scan = scanOptions(args, TYPE_OPTIONS)
if (scan.bad !== null) return optionError('type', scan.bad, TYPE_USAGE)
const enc = new TextEncoder()
const last = lastOf(scan.letters, 'tpP')
const mode = last === null || last === 't' ? last : 'p'
const allMode = scan.letters.includes('a')
const hidden = scan.letters.includes('f') ? NameKind.FUNCTION : null
const outLines: string[] = []
const errLines: string[] = []
let allFound = true
for (const name of scan.operands) {
const kinds = locations(name, session, registry, allMode, hidden)
if (kinds.length === 0) {
allFound = false
if (mode === null) errLines.push(`type: ${name}: not found\n`)
continue
}
if (mode === 't') outLines.push(...kinds.map((kind) => `${kind}\n`))
else if (mode === null) outLines.push(...kinds.map((kind) => `${describe(name, kind)}\n`))
}
const out = outLines.length > 0 ? enc.encode(outLines.join('')) : null
const err = enc.encode(errLines.join(''))
const code = scan.operands.length === 0 || allFound ? 0 : 1
return [
out,
new IOResult({ exitCode: code, stderr: err }),
new ExecutionNode({ command: 'type', exitCode: code, stderr: err }),
]
}
/**
* Run the `which` builtin (`which [-as] name [name ...]`).
*
* Pinned against debianutils `which` (debian:stable-slim): a miss prints
* nothing at all, the exit status is 0 only when every name resolves (1
* with no operands), and `-s` reports through the status alone. Two
* deliberate divergences, both forced by mirage having no PATH: the
* printed word is the name rather than a path (as `command -v` already
* does), and every runnable resolves, where GNU reports only files
* (`which cd` misses there, since a builtin is not on the PATH; here
* everything is in-process, so reporting nothing would make the command
* useless). Keywords stay unresolvable, as they are not commands
* anywhere. `-a` prints one line per layer, so a shadowed name prints
* its name twice; `type -a` is the surface that names the layers. The
* refusal for an unknown option is bash's shape, not the C tool's
* `Illegal option`, because this is a builtin and the usage line cannot
* honestly name `/usr/bin/which`.
*/
export function handleWhich(
args: readonly string[],
session: Session,
registry: MountRegistry,
): Result {
const scan = scanOptions(args, WHICH_OPTIONS)
if (scan.bad !== null) return optionError('which', scan.bad, WHICH_USAGE)
const allMode = scan.letters.includes('a')
const silent = scan.letters.includes('s')
const outLines: string[] = []
let allFound = true
for (const name of scan.operands) {
const kinds = locations(name, session, registry, allMode, NameKind.KEYWORD)
if (kinds.length === 0) {
allFound = false
continue
}
if (!silent) outLines.push(...Array.from({ length: kinds.length }, () => `${name}\n`))
}
const out = outLines.length > 0 ? new TextEncoder().encode(outLines.join('')) : null
const code = scan.operands.length > 0 && allFound ? 0 : 1
return [
out,
new IOResult({ exitCode: code }),
new ExecutionNode({ command: 'which', exitCode: code }),
]
}
@@ -0,0 +1,18 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// The package's public surface: what other packages consume. Inside
// the package, and in its tests, the modules are imported directly.
export { classify, describe } from './classify.ts'
export { handleType, handleWhich } from './handle.ts'
@@ -0,0 +1,35 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
/**
* What a command name resolves to, spelled as `type -t` prints it.
*
* bash's `-t` vocabulary is alias/keyword/function/builtin/file. mirage
* has no aliases and no external binaries, so `file` never applies and
* every mirage-native runnable name that is not a function would
* collapse into `builtin`. `cli` is a sixth word rather than a reuse of
* `file`: reusing it would promise `type -p` a path to print, and there
* is none.
*
* Members are ordered as `type -a` prints them, which is also the order
* the layers resolve in.
*/
export const NameKind = Object.freeze({
KEYWORD: 'keyword',
FUNCTION: 'function',
CLI: 'cli',
BUILTIN: 'builtin',
} as const)
export type NameKind = (typeof NameKind)[keyof typeof NameKind]
@@ -12,10 +12,13 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { CLISpec } from '../../../commands/cli/types.ts'
import { findNode, nodeHelp } from '../../../commands/cli/walk.ts'
import type { RegisteredCommand } from '../../../commands/config.ts'
import { BUILTIN_SPECS } from '../../../commands/spec/builtins.ts'
import type { CommandSpec } from '../../../commands/spec/types.ts'
import { IOResult } from '../../../io/types.ts'
import type { CLIInstall } from '../../cli/types.ts'
import type { MountEntry } from '../../mount/mount.ts'
import { DEV_PREFIX } from '../../mount/registry.ts'
import type { MountRegistry } from '../../mount/registry.ts'
@@ -23,6 +26,11 @@ import type { Session } from '../../session/session.ts'
import { ExecutionNode } from '../../types.ts'
import type { Result } from './scope.ts'
/** A description, or man's placeholder when the spec carries none. */
function described(text: string | null | undefined): string {
return text ?? '(no description)'
}
interface ManHit {
mount: MountEntry
cmd: RegisteredCommand
@@ -68,7 +76,7 @@ function renderManEntry(name: string, hits: ManHit[]): string {
const spec = first.cmd.spec
const lines: string[] = []
lines.push(`# ${name}`, '')
lines.push(spec.description ?? '(no description)', '')
lines.push(described(spec.description), '')
lines.push(...renderOptionsTable(spec))
lines.push('## RESOURCES', '')
const seen = new Set<string>()
@@ -92,6 +100,32 @@ function renderManEntry(name: string, hits: ManHit[]): string {
return lines.join('\n') + '\n'
}
/**
* The page for one node of an installed CLI, null when the verbs miss.
*
* The page is the node's own `--help`, rendered by the one renderer that
* serves `--help` and the bare-group refusal, so a CLI's manual cannot
* drift from the program. A tree is a manual with sections: `man linear`
* lists the verbs and `man linear issue create` is the page for one leaf.
*/
function renderCliEntry(head: string, verbs: readonly string[], spec: CLISpec): string | null {
const found = findNode(spec, verbs)
if (found === null) return null
return nodeHelp([head, ...found.path].join(' '), found.node)
}
/** The installed-CLI section of the bare `man` listing. */
function renderCliIndex(registry: MountRegistry): string[] {
const installs = [...registry.clis.items().entries()].sort(([a], [b]) => (a < b ? -1 : 1))
if (installs.length === 0) return []
const lines = ['# clis', '']
for (const [name, install] of installs) {
lines.push(`- ${name}${described(install.spec.description)}`)
}
lines.push('')
return lines
}
function renderManIndex(session: Session, registry: MountRegistry): string {
const byKind = new Map<string, MountEntry>()
for (const m of registry.allMounts()) {
@@ -122,7 +156,7 @@ function renderManIndex(session: Session, registry: MountRegistry): string {
.slice()
.sort((a, b) => (a.name < b.name ? -1 : 1))
for (const cmd of resourceCmds) {
lines.push(`- ${cmd.name}${cmd.spec.description ?? '(no description)'}`)
lines.push(`- ${cmd.name}${described(cmd.spec.description)}`)
}
for (const cmd of allCmds) {
if (m.isGeneralCommand(cmd.name) && !generalSeen.has(cmd.name)) {
@@ -131,9 +165,10 @@ function renderManIndex(session: Session, registry: MountRegistry): string {
}
lines.push('')
}
lines.push(...renderCliIndex(registry))
lines.push('# general', '')
for (const [name, cmd] of [...generalSeen.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
lines.push(`- ${name}${cmd.spec.description ?? '(no description)'}`)
lines.push(`- ${name}${described(cmd.spec.description)}`)
}
return lines.join('\n') + '\n'
}
@@ -149,34 +184,78 @@ function renderShellBuiltinMan(
): string {
const lines: string[] = []
lines.push(`# ${name}`, '')
lines.push(spec.description ?? '(no description)', '')
lines.push(described(spec.description), '')
lines.push(...renderOptionsTable(spec))
lines.push('## RESOURCES', '')
lines.push('- shell builtin')
return lines.join('\n') + '\n'
}
/**
* The page (or pages) for an installed head word.
*
* A CLI may not take a general command's name, but a mount can register
* a custom command under any name, so both pages can exist for one word.
* The CLI goes first: it is the one dispatch would run.
*/
function cliMan(
install: CLIInstall,
verbs: readonly string[],
cmdStr: string,
registry: MountRegistry,
): Result {
const enc = new TextEncoder()
const head = install.name
const entry = renderCliEntry(head, verbs, install.spec)
if (entry === null) {
const err = enc.encode(`man: no entry for ${[head, ...verbs].join(' ')}\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: 1, stderr: err }),
]
}
const sections = [entry]
const hits = verbs.length === 0 ? collectManHits(head, registry) : []
if (hits.length > 0) sections.push(renderManEntry(head, hits))
return [
enc.encode(sections.join('\n')),
new IOResult(),
new ExecutionNode({ command: cmdStr, exitCode: 0 }),
]
}
export function handleMan(args: string[], session: Session, registry: MountRegistry): Result {
const enc = new TextEncoder()
const name = args[0]
if (name === undefined) {
const out = new TextEncoder().encode(renderManIndex(session, registry))
return [out, new IOResult(), new ExecutionNode({ command: 'man', exitCode: 0 })]
return [
enc.encode(renderManIndex(session, registry)),
new IOResult(),
new ExecutionNode({ command: 'man', exitCode: 0 }),
]
}
const cmdStr = `man ${args.join(' ')}`
// Only an installed head word reads the words after it: they are its
// verb path. Everything else keeps man's older shape and documents
// args[0].
const install = registry.clis.get(name)
if (install !== null) return cliMan(install, args.slice(1), cmdStr, registry)
const hits = collectManHits(name, registry)
if (hits.length === 0) {
const specKey = SHELL_BUILTIN_MAN[name]
const spec = specKey !== undefined ? BUILTIN_SPECS[specKey] : undefined
if (spec !== undefined) {
const out = new TextEncoder().encode(renderShellBuiltinMan(name, spec))
return [out, new IOResult(), new ExecutionNode({ command: `man ${name}`, exitCode: 0 })]
const out = enc.encode(renderShellBuiltinMan(name, spec))
return [out, new IOResult(), new ExecutionNode({ command: cmdStr, exitCode: 0 })]
}
const err = new TextEncoder().encode(`man: no entry for ${name}\n`)
const err = enc.encode(`man: no entry for ${name}\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: `man ${name}`, exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: 1, stderr: err }),
]
}
const out = new TextEncoder().encode(renderManEntry(name, hits))
return [out, new IOResult(), new ExecutionNode({ command: `man ${name}`, exitCode: 0 })]
const out = enc.encode(renderManEntry(name, hits))
return [out, new IOResult(), new ExecutionNode({ command: cmdStr, exitCode: 0 })]
}
@@ -13,6 +13,8 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { CLISpec } from '../commands/cli/types.ts'
import { IOResult } from '../io/types.ts'
import { OpsRegistry } from '../ops/registry.ts'
import { RAMResource } from '../resource/ram/ram.ts'
import { MountMode } from '../types.ts'
@@ -56,6 +58,25 @@ async function makeMultiWs(): Promise<Workspace> {
)
}
async function cliWs(): Promise<Workspace> {
const ws = await makeWs()
ws.registerCli(
'linear',
new CLISpec({
name: 'linear',
description: 'Linear API client',
subcommands: [
new CLISpec({
name: 'issue',
description: 'Manage issues',
fn: () => [null, new IOResult()],
}),
],
}),
)
return ws
}
describe('--help and man through the executor', () => {
it('--help on a builtin renders help text without invoking the handler', async () => {
const ws = await makeWs()
@@ -141,6 +162,28 @@ describe('--help and man through the executor', () => {
expect(stderrStr(io)).toContain('no entry for')
})
it('an installed CLI is discoverable from the shell', async () => {
const ws = await cliWs()
expect(stdoutStr(await ws.execute('type linear'))).toBe('linear is a mirage CLI\n')
expect(stdoutStr(await ws.execute('type -t linear'))).toBe('cli\n')
expect(stdoutStr(await ws.execute('which linear'))).toBe('linear\n')
expect(stdoutStr(await ws.execute('man linear'))).toContain('Usage: linear')
expect(stdoutStr(await ws.execute('man'))).toContain('# clis')
})
it('which reports a missing name through the status only', async () => {
const io = await (await cliWs()).execute('which nope-xyz')
expect(io.exitCode).toBe(1)
expect(stdoutStr(io)).toBe('')
expect(stderrStr(io)).toBe('')
})
it('a shell function shadows a CLI and type -a shows both', async () => {
const ws = await cliWs()
const io = await ws.execute('linear() { echo shadowed; }; type -a linear')
expect(stdoutStr(io)).toBe('linear is a function\nlinear is a mirage CLI\n')
})
it('workspace filePrompt mentions --help and man (with and without args)', async () => {
const ws = await makeWs()
const prompt = ws.filePrompt
@@ -48,6 +48,7 @@ import {
handleCd,
handleCommandBuiltin,
handleType,
handleWhich,
handleEcho,
handleEnv,
handleEval,
@@ -631,6 +632,10 @@ async function runArgv(
return handleType(args, session, registry)
}
if (name === SB.WHICH) {
return handleWhich(args, session, registry)
}
if (name === SB.XARGS) {
return handleXargs(executeFn, args, session, stdin)
}
@@ -28,6 +28,36 @@ export const UNSUPPORTED_BUILTINS: ReadonlySet<string> = new Set([
export const NAMESPACE_COMMANDS: ReadonlySet<string> = new Set(['ln', 'readlink'])
// bash reserved words that mirage's grammar implements. The parser, not
// the executor, consumes them, so they never reach route; `type` reports
// them and the CLI registry refuses them as head words. bash's `time`
// and `coproc` are left out on purpose: mirage implements neither
// construct, so a line starting with one reports `command not found`,
// and `type` may not contradict what dispatch does. Add a word back when
// its construct lands.
export const KEYWORDS: ReadonlySet<string> = new Set([
'if',
'then',
'else',
'elif',
'fi',
'case',
'esac',
'for',
'select',
'while',
'until',
'do',
'done',
'in',
'function',
'{',
'}',
'!',
'[[',
']]',
])
// ShellBuiltin subset handled through the job table in the executor.
export const JOB_BUILTINS: ReadonlySet<string> = new Set(['wait', 'fg', 'kill', 'jobs', 'ps'])
@@ -20,5 +20,5 @@ export {
dereferences,
reportsLink,
} from './constants.ts'
export { route } from './route.ts'
export { route, routeAll } from './route.ts'
export { Consumer, SHELL_CONSUMERS, WordPolicy, wordPolicy } from './types.ts'
@@ -18,7 +18,7 @@ import { IOResult } from '../../io/types.ts'
import { OpsRegistry } from '../../ops/registry.ts'
import { RAMResource } from '../../resource/ram/ram.ts'
import { MountMode } from '../../types.ts'
import { Consumer, SHELL_CONSUMERS, dereferences, route } from './index.ts'
import { Consumer, SHELL_CONSUMERS, dereferences, route, routeAll } from './index.ts'
import { Session } from '../session/session.ts'
import { Workspace } from '../workspace.ts'
@@ -118,6 +118,32 @@ describe('route', () => {
})
})
describe('routeAll', () => {
it('reports every layer, winner first', () => {
const { session, ws } = fixture()
ws.registerCli('prog', cliTree())
expect(routeAll('prog', session, ws.registry)).toEqual([Consumer.CLI])
session.functions.prog = 'prog() { :; }'
expect(routeAll('prog', session, ws.registry)).toEqual([Consumer.FUNCTION, Consumer.CLI])
})
it('is empty where route says UNKNOWN', () => {
const { session, ws } = fixture()
expect(routeAll('bogus', session, ws.registry)).toEqual([])
expect(route('bogus', session, ws.registry)).toBe(Consumer.UNKNOWN)
})
it('agrees with route on the winner', () => {
const { session, ws } = fixture()
ws.registerCli('prog', cliTree())
session.functions.greet = 'greet() { :; }'
for (const name of ['cd', 'ln', 'greet', 'prog', 'cat', 'bogus']) {
const layers = routeAll(name, session, ws.registry)
expect(route(name, session, ws.registry)).toBe(layers[0] ?? Consumer.UNKNOWN)
}
})
})
describe('find link-policy options', () => {
it('takes the last of -P/-H/-L', () => {
// GNU: `find -L -P x` does not follow, `find -P -L x` does.
@@ -17,6 +17,22 @@ import type { Session } from '../session/session.ts'
import { NAMESPACE_COMMANDS, SHELL_NAMES } from './constants.ts'
import { Consumer } from './types.ts'
/**
* Yield every layer holding the name, most-preferred first.
*
* The one place precedence is written down: `route` reads the first
* yield and `routeAll` reads all of them. Lazy on purpose, so the winner
* costs exactly what it did before the split (a name an installed CLI
* answers never reaches the mount lookup).
*/
function* layers(name: string, session: Session, registry: MountRegistry): Generator<Consumer> {
if (SHELL_NAMES.has(name)) yield Consumer.SESSION
if (NAMESPACE_COMMANDS.has(name)) yield Consumer.NAMESPACE
if (name in session.functions) yield Consumer.FUNCTION
if (registry.clis.get(name) !== null) yield Consumer.CLI
if (registry.mountForCommand(name) !== null) yield Consumer.MOUNT
}
/**
* Route a command name to the layer that consumes it.
*
@@ -41,12 +57,26 @@ import { Consumer } from './types.ts'
*
* Runtimes are orthogonal, not a seventh row: a capture decides where a
* command executes (docker vs vfs), never whether the name exists.
*
* This is the winner only. A name can sit in more than one layer at once
* (a function shadowing an installed CLI); `routeAll` reports them all,
* which is what `type -a` prints. Reading one item off the generator is
* what makes that sharing free: the lookups after the winner never run,
* so dispatch pays exactly what it did when this was a chain of `if`
* arms.
*/
export function route(name: string, session: Session, registry: MountRegistry): Consumer {
if (SHELL_NAMES.has(name)) return Consumer.SESSION
if (NAMESPACE_COMMANDS.has(name)) return Consumer.NAMESPACE
if (name in session.functions) return Consumer.FUNCTION
if (registry.clis.get(name) !== null) return Consumer.CLI
if (registry.mountForCommand(name) !== null) return Consumer.MOUNT
for (const consumer of layers(name, session, registry)) return consumer
return Consumer.UNKNOWN
}
/**
* Every layer holding the name, most-preferred first.
*
* Empty when nothing holds it, where `route` says UNKNOWN. Only
* introspection (`type -a`, `which -a`) needs this: dispatch runs the
* winner and never asks what it shadowed.
*/
export function routeAll(name: string, session: Session, registry: MountRegistry): Consumer[] {
return [...layers(name, session, registry)]
}