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:
@@ -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
@@ -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:** `#`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -220,6 +220,7 @@ class ShellBuiltin(StrEnum):
|
||||
TIMEOUT = "timeout"
|
||||
COMMAND = "command"
|
||||
TYPE = "type"
|
||||
WHICH = "which"
|
||||
BREAK = "break"
|
||||
CONTINUE = "continue"
|
||||
RETURN = "return"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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([]))
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user