chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
@@ -0,0 +1,78 @@
# ========= 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.condition import handle_test
from mirage.workspace.executor.builtins.dirs import handle_cd
from mirage.workspace.executor.builtins.history import handle_history
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.man import (_collect_man_hits,
_render_man_entry,
_render_man_index,
handle_man)
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.executor.builtins.script import (handle_bash,
handle_eval,
handle_sleep,
handle_source)
from mirage.workspace.executor.builtins.text import (_interpret_escapes,
handle_echo,
handle_printf)
from mirage.workspace.executor.builtins.timeout import handle_timeout
from mirage.workspace.executor.builtins.xargs import handle_xargs
from mirage.workspace.executor.builtins.vars import ( # isort: skip
handle_export, handle_local, handle_printenv, handle_read, handle_readonly,
handle_return, handle_set, handle_shift, handle_trap, handle_unset,
handle_whoami)
__all__ = [
'_collect_man_hits',
'_interpret_escapes',
'_render_man_entry',
'_render_man_index',
'_scope_path',
'_to_scope',
'handle_bash',
'handle_cd',
'handle_echo',
'handle_eval',
'handle_export',
'handle_history',
'handle_ln',
'handle_local',
'handle_readlink',
'link_flags',
'follow_paths',
'prepare_mv',
'strip_link_operands',
'handle_man',
'handle_printenv',
'handle_printf',
'handle_read',
'handle_readonly',
'handle_return',
'handle_set',
'handle_shift',
'handle_sleep',
'handle_source',
'handle_test',
'handle_timeout',
'handle_trap',
'handle_unset',
'handle_whoami',
'handle_xargs',
]
@@ -0,0 +1,102 @@
# ========= 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 Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.utils.path import resolve_path
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def _eval_test(dispatch: Callable, argv: list, cwd: str) -> bool:
if not argv:
return False
first = _scope_path(argv[0])
if first == "!" and len(argv) > 1:
return not await _eval_test(dispatch, argv[1:], cwd)
if len(argv) == 1:
return bool(first)
if len(argv) == 2:
op = _scope_path(argv[0])
val = argv[1]
if op == "-z":
return _scope_path(val) == ""
if op == "-n":
return _scope_path(val) != ""
if op == "-f":
# A relative string operand resolves against cwd, like bash:
# `cd /data && test -f plain.txt` checks /data/plain.txt.
path = _scope_path(val)
if not isinstance(val, PathSpec) and not path:
return False
scope = val if isinstance(val, PathSpec) else _to_scope(
resolve_path(path, cwd))
try:
await dispatch("stat", scope)
return True
except (FileNotFoundError, ValueError):
return False
if op == "-d":
path = _scope_path(val)
if not isinstance(val, PathSpec) and not path:
return False
if not isinstance(val, PathSpec):
path = resolve_path(path, cwd)
scope = val if isinstance(val, PathSpec) else PathSpec(
virtual=path, directory=path, resource_path="", resolved=False)
try:
await dispatch("readdir", scope)
return True
except (FileNotFoundError, ValueError, NotADirectoryError):
return False
if len(argv) == 3:
left = _scope_path(argv[0])
op = _scope_path(argv[1])
right = _scope_path(argv[2])
if op == "=" or op == "==":
return left == right
if op == "!=":
return left != right
try:
li, ri = int(left), int(right)
except (ValueError, TypeError):
return False
if op == "-eq":
return li == ri
if op == "-ne":
return li != ri
if op == "-lt":
return li < ri
if op == "-le":
return li <= ri
if op == "-gt":
return li > ri
if op == "-ge":
return li >= ri
return False
async def handle_test(
dispatch: Callable,
argv: list,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
result = await _eval_test(dispatch, argv, session.cwd)
code = 0 if result else 1
return None, IOResult(exit_code=code), ExecutionNode(command="test",
exit_code=code)
@@ -0,0 +1,168 @@
# ========= 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 posixpath
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import FileType, PathSpec
from mirage.utils.path import (MAX_SYMLINK_HOPS, CycleError, resolve_path,
resolve_symlinks)
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.session.shell_dirs import change_dir
from mirage.workspace.types import ExecutionNode
def _norm(path: str) -> str:
resolved = posixpath.normpath(path)
if resolved.startswith("//"):
resolved = "/" + resolved.lstrip("/")
return resolved
def _resolve_target(combined: str, links: dict[str, str],
physical: bool) -> str:
"""Resolve a combined ``cd`` path, following symlinks per mode.
Logical (``-L``, default) simplifies ``..`` textually first, then
follows links. Physical (``-P``) follows links first so ``..`` acts on
the link target. Both loop resolve<->normalize until stable.
Args:
combined (str): The absolute target (cwd joined to arg).
links (dict[str, str]): The symlink table (link -> target).
physical (bool): True for ``-P``, False for ``-L``.
Returns:
str: The final absolute path with links resolved.
Raises:
CycleError: On a symlink loop or unbounded expansion (ELOOP).
"""
p = combined if physical else _norm(combined)
for _ in range(MAX_SYMLINK_HOPS):
n = _norm(resolve_symlinks(p, links))
if n == p:
return n
p = n
raise CycleError(p)
def _cdpath_searchable(target: str) -> bool:
"""Return whether ``target`` triggers a ``$CDPATH`` search.
Args:
target: The as-typed ``cd`` operand.
Returns:
True when ``target`` is relative and does not begin with ``./``
or ``../`` (mirroring GNU bash's ``cd`` search rule).
"""
if target.startswith(("/", "./", "../")):
return False
return target not in (".", "..")
def _cd_candidates(
raw: str,
cdpath_target: str | None,
session: Session,
) -> list[tuple[str, bool]]:
"""Build the ordered list of directories ``cd`` should try.
Args:
raw: The resolved operand path string.
cdpath_target: The as-typed operand when a ``$CDPATH`` search
applies, else ``None``.
session: The shell session (provides cwd and env).
Returns:
``(resolved_path, announce)`` pairs in trial order; ``announce``
marks a non-empty ``$CDPATH`` hit whose absolute path GNU prints.
"""
cwd = session.cwd
fallback = resolve_path(raw, cwd)
cdpath = session.env.get("CDPATH")
if (not cdpath or not cdpath_target
or not _cdpath_searchable(cdpath_target)):
return [(fallback, False)]
out: list[tuple[str, bool]] = []
for entry in cdpath.split(":"):
base = resolve_path(entry, cwd) if entry else cwd
out.append((resolve_path(cdpath_target, base), entry != ""))
out.append((fallback, False))
return out
async def handle_cd(
dispatch: Callable,
is_mount_root: Callable[[str], bool],
path: str | PathSpec,
session: Session,
print_path: bool = False,
cdpath_target: str | None = None,
links: dict[str, str] | None = None,
physical: bool = False,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
raw = _scope_path(path)
table = links or {}
candidates = _cd_candidates(raw, cdpath_target, session)
error: str | None = None
for resolved, announce in candidates:
if table:
try:
resolved = _resolve_target(resolved, table, physical)
except CycleError:
error = f"cd: {raw}: Too many levels of symbolic links\n"
continue
if resolved == "/":
return _cd_success(session, "/", raw, print_path or announce)
scope = _to_scope(resolved)
s = None
not_found = False
try:
s, _ = await dispatch("stat", scope)
except FileNotFoundError:
not_found = True
except ValueError as exc:
error = f"cd: {raw}: {exc}\n"
continue
if s is None or not_found:
if is_mount_root(resolved):
return _cd_success(session, resolved, raw, print_path
or announce)
error = f"cd: {raw}: No such file or directory\n"
continue
if s.type != FileType.DIRECTORY:
error = f"cd: {raw}: Not a directory\n"
continue
return _cd_success(session, resolved, raw, print_path or announce)
err = (error or f"cd: {raw}: No such file or directory\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"cd {raw}",
exit_code=1,
stderr=err)
def _cd_success(
session: Session,
resolved: str,
raw: str,
print_path: bool,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
change_dir(session, resolved)
out = (resolved + "\n").encode() if print_path else None
return out, IOResult(), ExecutionNode(command=f"cd {raw}", exit_code=0)
@@ -0,0 +1,121 @@
# ========= 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.io.types import ByteSource, IOResult
from mirage.resource.history import HISTORY_PREFIX
from mirage.workspace.session.session import Session
from mirage.workspace.types import ExecutionNode
USAGE = ("history: usage: history [-c] [-d offset] [n] or "
"history -awrn [filename] or history -ps arg [arg...]\n")
OPTION_CHARS = "cdanrwsp"
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
err = (message + USAGE).encode()
io = IOResult(exit_code=2, stderr=err)
return None, io, ExecutionNode(command="history", exit_code=2, stderr=err)
def _parse_args(
args: list[str]) -> tuple[dict[str, object], list[str], str | None]:
"""Parse history builtin args the way bash getopt does.
Args:
args (list[str]): Raw tokens after the command name.
Returns:
tuple[dict[str, object], list[str], str | None]: Flags dict,
operand texts, and an error message (None when parsing
succeeded). Any dash-leading token is option-parsed, digits
included (`history -1` is an invalid option in bash); `--` or
the first operand ends option parsing, so `history -s rm -rf`
stores "rm -rf" as text. `-d` takes the rest of its token as
the offset when attached (`-d3`, and `-dc` deletes entry "c"),
otherwise the next token.
"""
flags: dict[str, object] = {}
texts: list[str] = []
options_done = False
i = 0
while i < len(args):
token = args[i]
if options_done or token == "-" or not token.startswith("-"):
texts.append(token)
options_done = True
elif token == "--":
options_done = True
else:
j = 1
while j < len(token):
ch = token[j]
if ch not in OPTION_CHARS:
return {}, [], f"history: -{ch}: invalid option\n"
flags[ch] = True
if ch == "d":
rest = token[j + 1:]
if rest:
flags["d"] = rest
elif i + 1 < len(args):
i += 1
flags["d"] = args[i]
else:
return ({}, [],
"history: -d: option requires an argument\n")
break
j += 1
i += 1
return flags, texts, None
async def handle_history(
registry,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Dispatch the history shell builtin to the view mount.
GNU lookup order: builtins resolve before mount commands, so a
mount-local command named "history" can never shadow this one.
The actual semantics live on the /.bash_history view resource;
this handler only parses options and routes.
Args:
registry (MountRegistry): The workspace's mount registry.
args (list[str]): Raw builtin args (flags and counts).
session (Session): Calling session.
"""
flags, texts, error = _parse_args(args)
if error is not None:
return _usage_error(error)
try:
mount = registry.mount_for(HISTORY_PREFIX)
except ValueError:
err = b"history: not enabled for this workspace\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="history",
exit_code=1,
stderr=err)
stream, io = await mount.execute_cmd("history", [],
texts,
flags,
cwd=session.cwd,
session_id=session.session_id)
# The view command always returns byte stderr, but io.stderr is typed
# as a ByteSource (a possible lazy stream); resolve it to bytes so the
# execution-tree node holds concrete stderr, never an unread stream.
stderr = await io.materialize_stderr()
return stream, io, ExecutionNode(command="history",
exit_code=io.exit_code,
stderr=stderr)
@@ -0,0 +1,260 @@
# ========= 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 dataclasses
import time
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import FileType, PathSpec, word_text
from mirage.utils.path import CycleError, resolve_path
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
def _split_flags(
args: list[str | PathSpec],
known: str,
) -> tuple[set[str], list[str | PathSpec]]:
flags: set[str] = set()
operands: list[str | PathSpec] = []
parsing = True
for arg in args:
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
if parsing and s == "--":
parsing = False
continue
if (parsing and s != "-" and len(s) >= 2 and s.startswith("-")
and all(c in known for c in s[1:])):
flags.update(s[1:])
continue
parsing = False
operands.append(arg)
return flags, operands
def link_flags(args: list[str | PathSpec], known: str) -> set[str]:
flags, _ = _split_flags(args, known)
return flags
def _abs(arg: str | PathSpec, cwd: str) -> str:
if isinstance(arg, PathSpec):
return arg.virtual
return resolve_path(arg, cwd)
def handle_ln(
namespace: Namespace,
session: Session,
args: list[str | PathSpec],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
flags, operands = _split_flags(args, "sfnv")
if len(operands) < 2:
err = b"ln: missing file operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
# GNU: with more than two operands the last must be a directory;
# namespace links never name directories, so this is always an error
# (an expanded multi-match glob source lands here).
if len(operands) > 2:
err = (f"ln: target '{word_text(operands[-1])}' "
f"is not a directory\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
link_abs = _abs(operands[1], session.cwd)
target_typed = word_text(operands[0])
exists = namespace.is_link(link_abs) and "f" not in flags
if namespace.is_mount_root(link_abs) or exists:
err = (f"ln: failed to create symbolic link "
f"'{word_text(operands[1])}': File exists\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
namespace.symlink(link_abs, target_typed, time.time())
out = None
if "v" in flags:
out = (f"'{word_text(operands[1])}' -> '{target_typed}'\n").encode()
return out, IOResult(), ExecutionNode(command="ln", exit_code=0)
def follow_paths(
namespace: Namespace,
items: list[str | PathSpec],
) -> list[str | PathSpec]:
"""Rewrite path operands through the symlink table (open(2) semantics).
Non-path items and paths that resolve to themselves pass through
untouched. A rewritten spec keeps the user-typed form in ``raw_path``
so error messages still name the operand as typed; the mount re-stamps
``resource_path`` at dispatch.
Args:
namespace (Namespace): addressing authority holding the link table.
items (list[str | PathSpec]): classified command parts.
Raises:
CycleError: when a path loops past the hop limit (ELOOP).
"""
out: list[str | PathSpec] = []
for item in items:
if not isinstance(item, PathSpec):
out.append(item)
continue
try:
virtual = namespace.follow(item.virtual)
except CycleError:
raise CycleError(item.raw_path) from None
if virtual == item.virtual:
out.append(item)
continue
out.append(
dataclasses.replace(item,
virtual=virtual,
directory=virtual[:virtual.rfind("/") + 1]
or "/",
resource_path=""))
return out
def strip_link_operands(
namespace: Namespace,
items: list[str | PathSpec],
) -> tuple[list[str | PathSpec], int]:
"""Unlink and drop ``rm`` operands that are symlinks.
GNU ``rm`` removes the link itself and never follows it; a dangling
link removes fine. Remaining operands stay for backend dispatch.
Args:
namespace (Namespace): addressing authority holding the link table.
items (list[str | PathSpec]): classified command parts.
Returns:
tuple[list[str | PathSpec], int]: surviving parts and the number
of link entries removed.
"""
removed = 0
kept: list[str | PathSpec] = []
for item in items:
if isinstance(item, PathSpec) and namespace.is_link(item.virtual):
namespace.unlink(item.virtual)
removed += 1
continue
kept.append(item)
return kept, removed
async def prepare_mv(
namespace: Namespace,
dispatch: Callable,
items: list[str | PathSpec],
) -> tuple[list[str | PathSpec], str | None, tuple[ByteSource | None, IOResult,
ExecutionNode] | None]:
"""Adjust a two-operand ``mv`` for symlink operands.
A link source renames the link entry itself (into a destination
directory when one exists, mirroring rename(2) preceded by mv's dst
stat). A link destination whose target is a directory is followed
(mv moves into it); any other link destination is replaced, so its
entry must drop once the backend move succeeds.
Args:
namespace (Namespace): addressing authority holding the link table.
dispatch (Callable): op dispatcher used to stat the destination.
items (list[str | PathSpec]): classified command parts.
Returns:
tuple: (possibly rewritten parts, link path to unlink after a
successful backend move, early result when the mv completed as a
pure namespace rename).
"""
paths = [p for p in items if isinstance(p, PathSpec)]
if len(paths) != 2:
return items, None, None
src, dst = paths
if namespace.is_link(src.virtual):
target_dst = dst.virtual
stat = await _stat_or_none(dispatch, dst)
if stat is not None and stat.type == FileType.DIRECTORY:
target_dst = (dst.virtual.rstrip("/") + "/" +
src.virtual.rsplit("/", 1)[-1])
namespace.unlink(target_dst)
namespace.rename(src.virtual, target_dst)
return items, None, (None, IOResult(),
ExecutionNode(command="mv", exit_code=0))
if namespace.is_link(dst.virtual):
followed = namespace.follow(dst.virtual)
stat = await _stat_or_none(dispatch, PathSpec.from_str_path(followed))
if stat is not None and stat.type == FileType.DIRECTORY:
return follow_paths(namespace, items), None, None
return items, dst.virtual, None
return items, None, None
async def _stat_or_none(dispatch: Callable, path: PathSpec):
"""Stat a path via dispatch, mapping a missing file to ``None``.
Args:
dispatch (Callable): op dispatcher.
path (PathSpec): path to stat.
"""
# A missing destination is an expected mv case (plain rename), not an
# error to surface.
try:
stat, _ = await dispatch("stat", path)
except FileNotFoundError:
return None
return stat
def handle_readlink(
namespace: Namespace,
session: Session,
args: list[str | PathSpec],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
flags, operands = _split_flags(args, "fenm")
if not operands:
err = b"readlink: missing operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="readlink",
exit_code=1,
stderr=err)
lines: list[str] = []
exit_code = 0
for op in operands:
target = namespace.readlink(_abs(op, session.cwd))
if target is None:
exit_code = 1
continue
lines.append(target)
if not lines:
return None, IOResult(exit_code=exit_code), ExecutionNode(
command="readlink", exit_code=exit_code)
if "n" in flags:
text = "".join(lines)
else:
text = "".join(line + "\n" for line in lines)
return text.encode(), IOResult(exit_code=exit_code), ExecutionNode(
command="readlink", exit_code=exit_code)
@@ -0,0 +1,200 @@
# ========= 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 dataclasses import dataclass
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.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
@dataclass
class _ManHit:
mount: MountEntry
cmd: RegisteredCommand
is_general: bool
def _collect_man_hits(name: str, registry: MountRegistry) -> list[_ManHit]:
hits: list[_ManHit] = []
for mount in registry.mounts():
if mount.prefix == DEV_PREFIX:
continue
cmd = mount.resolve_command(name)
if cmd is None:
continue
hits.append(
_ManHit(mount=mount,
cmd=cmd,
is_general=mount.is_general_command(name)))
return hits
def _render_options_table(spec: CommandSpec) -> list[str]:
if not spec.options:
return []
lines: list[str] = []
lines.append("## OPTIONS")
lines.append("")
lines.append("| short | long | value | description |")
lines.append("| ----- | ---- | ----- | ----------- |")
for opt in spec.options:
short = opt.short if opt.short is not None else ""
long = opt.long if opt.long is not None else ""
desc = opt.description if opt.description is not None else ""
lines.append(f"| {short} | {long} | {opt.value_kind.value} | {desc} |")
lines.append("")
return lines
def _render_man_entry(name: str, hits: list[_ManHit]) -> str:
first = hits[0]
spec = first.cmd.spec
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
lines.append("")
seen: set[str] = set()
has_general = False
rows: list[str] = []
for h in hits:
if h.is_general:
has_general = True
continue
kind = h.mount.resource.name
filetype = h.cmd.filetype
key = f"{kind}\x00{filetype or ''}"
if key in seen:
continue
seen.add(key)
if filetype is not None:
rows.append(f"- {kind} (filetype: {filetype})")
else:
rows.append(f"- {kind}")
rows.sort()
if has_general:
lines.append("- general")
for r in rows:
lines.append(r)
return "\n".join(lines) + "\n"
_SHELL_BUILTIN_MAN: dict[str, str] = {
"bash": "bash",
"sh": "bash",
}
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("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
lines.append("")
lines.append("- shell builtin")
return "\n".join(lines) + "\n"
def _render_man_index(session: Session, registry: MountRegistry) -> str:
by_kind: dict[str, MountEntry] = {}
for m in registry.mounts():
if m.prefix == DEV_PREFIX:
continue
if m.resource.name not in by_kind:
by_kind[m.resource.name] = m
try:
cwd_mount: MountEntry | None = registry.mount_for(session.cwd)
except ValueError:
cwd_mount = None
cwd_kind: str | None = None
if cwd_mount is not None and cwd_mount.prefix != DEV_PREFIX:
cwd_kind = cwd_mount.resource.name
kinds = sorted(by_kind.keys())
ordered: list[str] = []
if cwd_kind is not None and cwd_kind in by_kind:
ordered.append(cwd_kind)
for k in kinds:
if k == cwd_kind:
continue
ordered.append(k)
lines: list[str] = []
general_seen: dict[str, RegisteredCommand] = {}
for kind in ordered:
m = by_kind[kind]
lines.append(f"# {kind}")
lines.append("")
all_cmds = m.all_commands()
resource_cmds = sorted(
(c for c in all_cmds if not m.is_general_command(c.name)),
key=lambda c: c.name,
)
for cmd in resource_cmds:
desc = (cmd.spec.description if cmd.spec.description is not None
else "(no 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.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)")
lines.append(f"- {name} \u2014 {desc}")
return "\n".join(lines) + "\n"
async def handle_man(
args: list[str],
session: Session,
registry: MountRegistry,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
out = _render_man_index(session, registry).encode()
return out, IOResult(), ExecutionNode(command="man", exit_code=0)
name = args[0]
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)
err = f"man: no entry for {name}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"man {name}",
exit_code=1,
stderr=err)
out = _render_man_entry(name, hits).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}", exit_code=0)
@@ -0,0 +1,32 @@
# ========= 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.types import PathSpec
def _to_scope(path: str) -> PathSpec:
"""Wrap a resolved path string as PathSpec."""
last_slash = path.rfind("/")
directory = path[:last_slash + 1] if last_slash >= 0 else "/"
return PathSpec(virtual=path,
directory=directory,
resource_path="",
resolved=True)
def _scope_path(val) -> str:
"""Extract path string from str or PathSpec."""
if isinstance(val, PathSpec):
return val.virtual
return val
@@ -0,0 +1,162 @@
# ========= 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 asyncio
import math
import re
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.utils.path import resolve_path
from mirage.workspace.abort import cancellable_sleep
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_source(
dispatch: Callable,
execute_fn: Callable,
path: str | PathSpec,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Read a script file and execute it."""
raw = _scope_path(path)
resolved = resolve_path(raw, session.cwd)
scope = _to_scope(resolved)
data, _ = await dispatch("read", scope)
if isinstance(data, bytes):
script = data.decode(errors="replace")
else:
script = ""
io = await execute_fn(script, session_id=session.session_id)
return io.stdout, io, ExecutionNode(command=f"source {raw}",
exit_code=io.exit_code)
async def handle_eval(
execute_fn: Callable,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
script = " ".join(args)
io = await execute_fn(script, session_id=session.session_id)
return io.stdout, io, ExecutionNode(command="eval", exit_code=io.exit_code)
_BASH_NOOP_SHORT_FLAGS = frozenset({"l", "i", "e", "u", "x"})
_BASH_NOOP_LONG_FLAGS = frozenset(
{"--login", "--norc", "--noprofile", "--posix", "--rcfile"})
async def handle_bash(
execute_fn: Callable,
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
script: str | None = None
read_stdin = False
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if tok == "-c":
if i + 1 >= len(args):
err = b"bash: -c: option requires an argument\n"
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(
command="bash", exit_code=2, stderr=err)
script = args[i + 1]
break
if tok == "-s":
read_stdin = True
i += 1
continue
if tok in ("-o", "+o"):
i += 2
continue
if tok in _BASH_NOOP_LONG_FLAGS:
i += 1
continue
if (tok.startswith("-") and len(tok) > 1 and not tok.startswith("--")):
chars = tok[1:]
if "c" in chars:
if i + 1 >= len(args):
err = b"bash: -c: option requires an argument\n"
return None, IOResult(
exit_code=2, stderr=err), ExecutionNode(command="bash",
exit_code=2,
stderr=err)
script = args[i + 1]
break
if all(ch in _BASH_NOOP_SHORT_FLAGS or ch == "s" for ch in chars):
if "s" in chars:
read_stdin = True
i += 1
continue
err = (f"bash: {tok}: unsupported option\n").encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="bash",
exit_code=2,
stderr=err)
if script is None:
script = tok
break
i += 1
if script is None and read_stdin and stdin is not None:
stdin_data = await materialize(stdin)
if stdin_data:
script = stdin_data.decode(errors="replace")
stdin = None
if script is None:
return None, IOResult(), ExecutionNode(command="bash", exit_code=0)
io = await execute_fn(script, session_id=session.session_id, stdin=stdin)
return io.stdout, io, ExecutionNode(command=f"bash -c {script}",
exit_code=io.exit_code)
# Finite non-negative decimals only ("0", "0.2", ".5", "1.", "+1", "1e-3").
# GNU sleep additionally accepts "inf" and sleeps forever; an agent shell
# must never hang, so non-finite intervals are rejected (deliberate
# divergence). The regex also keeps Python/TypeScript parsing identical:
# float() alone would accept "inf", "nan", "1_0", and surrounding whitespace
# that Number() rejects, and Number() accepts hex that float() rejects.
SLEEP_INTERVAL = re.compile(r"\+?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?")
async def handle_sleep(
args: list[str],
cancel: asyncio.Event | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
err = b"sleep: missing operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="sleep",
exit_code=1)
raw = args[0]
# "1e309" passes the regex but overflows to inf, so check both.
seconds = float(raw) if SLEEP_INTERVAL.fullmatch(raw) else math.inf
if not math.isfinite(seconds):
err = f"sleep: invalid time interval '{raw}'\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="sleep",
exit_code=1)
await cancellable_sleep(seconds, cancel)
return None, IOResult(), ExecutionNode(command="sleep", exit_code=0)
@@ -0,0 +1,138 @@
# ========= 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.commands.spec.shell import ECHO_OPTION
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.types import ExecutionNode
_SIMPLE_ESCAPES = {
"\\": "\\",
"n": "\n",
"t": "\t",
"r": "\r",
"a": "\a",
"b": "\b",
"f": "\f",
"v": "\v",
}
_HEX = set("0123456789abcdefABCDEF")
_OCT = set("01234567")
def _interpret_escapes(text: str) -> str:
"""Process C-style escape sequences for echo -e.
Single-pass to handle \\\\ correctly (\\\\b → \\b literal).
Supports: \\\\, \\n, \\t, \\r, \\a, \\b, \\f, \\v,
\\xHH (hex), \\0NNN (octal), \\c (stop output).
Unknown escapes like \\z pass through as \\z.
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
if text[i] != "\\" or i + 1 >= n:
out.append(text[i])
i += 1
continue
ch = text[i + 1]
if ch in _SIMPLE_ESCAPES:
out.append(_SIMPLE_ESCAPES[ch])
i += 2
elif ch == "c":
break
elif ch == "x":
# \xHH — up to 2 hex digits
digits = []
j = i + 2
while j < n and len(digits) < 2 and text[j] in _HEX:
digits.append(text[j])
j += 1
if digits:
out.append(chr(int("".join(digits), 16)))
i = j
else:
out.append("\\x")
i += 2
elif ch == "0":
# \0NNN — up to 3 octal digits
digits = []
j = i + 2
while j < n and len(digits) < 3 and text[j] in _OCT:
digits.append(text[j])
j += 1
out.append(chr(int("".join(digits), 8)) if digits else "\0")
i = j
else:
# unknown escape — pass through literally
out.append("\\")
out.append(ch)
i += 2
return "".join(out)
async def handle_echo(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Print arguments, honoring GNU echo's option rules.
GNU echo is not getopt: options are LEADING words matching
``-[neE]+`` only. The first word that does not match (including
``-x`` or a repeated ``hi -n``) ends option parsing and prints
literally. Within clusters the last of -e/-E wins; -n sticks.
Args:
args (list[str]): words after the command name, as typed.
"""
no_newline = False
escapes = False
idx = 0
for word in args:
if not ECHO_OPTION.fullmatch(word):
break
for ch in word[1:]:
if ch == "n":
no_newline = True
elif ch == "e":
escapes = True
else:
escapes = False
idx += 1
text = " ".join(args[idx:])
if escapes:
text = _interpret_escapes(text)
if not no_newline:
text += "\n"
out = text.encode()
return out, IOResult(), ExecutionNode(command="echo", exit_code=0)
async def handle_printf(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
return b"", IOResult(), ExecutionNode(command="printf", exit_code=0)
fmt = args[0]
fmt = fmt.replace("\\n", "\n").replace("\\t", "\t")
if len(args) > 1:
try:
out = (fmt % tuple(args[1:])).encode()
except TypeError:
out = fmt.encode()
else:
out = fmt.encode()
return out, IOResult(), ExecutionNode(command="printf", exit_code=0)
@@ -0,0 +1,122 @@
# ========= 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 asyncio
import re
import shlex
from collections.abc import Callable
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_DURATION = re.compile(r"(\d+(?:\.\d*)?|\.\d+)([smhd]?)")
_UNIT_SECONDS = {"": 1.0, "s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
_UNSUPPORTED = ("s", "k", "preserve-status")
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
# GNU timeout reserves 125 for its own failures; 124 means the
# command was killed at the deadline.
stderr = f"timeout: {message}\n".encode()
return None, IOResult(exit_code=125,
stderr=stderr), ExecutionNode(command="timeout",
exit_code=125)
def parse_duration(raw: str) -> float | None:
"""Parse a GNU timeout duration (float plus optional s/m/h/d).
Args:
raw (str): duration operand as typed.
"""
match = _DURATION.fullmatch(raw)
if match is None:
return None
return float(match.group(1)) * _UNIT_SECONDS[match.group(2)]
async def handle_timeout(
execute_fn: Callable,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run `timeout DURATION COMMAND [ARG...]`, killing at the deadline.
The inner line is built with shlex.join so already-expanded words
survive re-parsing as one token each (GNU timeout execs the command
without a shell). On overrun the inner run is cancelled and the
exit code is 124 like GNU. Signal options (-s, -k,
--preserve-status) are parsed but rejected: the inner run is a
coroutine, not a process, so there is nothing to signal.
Args:
execute_fn (Callable): shell evaluator for the inner line.
args (list[str]): options, duration operand, then the command.
session (Session): shell session state.
"""
parse = parse_shell_options(SHELL_SPECS["timeout"], args or [])
if parse.invalid is not None:
if parse.invalid.startswith("--"):
return _usage_error(f"unrecognized option '{parse.invalid}'")
return _usage_error(f"invalid option -- '{parse.invalid}'")
if parse.needs_value is not None:
return _usage_error(
f"option requires an argument -- '{parse.needs_value}'")
for name in _UNSUPPORTED:
if name in parse.flags:
dashes = "--" if len(name) > 1 else "-"
return _usage_error(f"unsupported option -- '{dashes}{name}'")
if len(parse.operands) < 2:
return _usage_error("missing operand")
raw = parse.operands[0]
seconds = parse_duration(raw)
if seconds is None:
return _usage_error(f"invalid time interval '{raw}'")
inner = shlex.join(parse.operands[1:])
try:
stdout, io = await asyncio.wait_for(
_execute_drained(execute_fn, inner, session.session_id),
timeout=seconds if seconds > 0 else None,
)
except asyncio.TimeoutError:
return None, IOResult(exit_code=124), ExecutionNode(command="timeout",
exit_code=124)
return stdout, io, ExecutionNode(command="timeout", exit_code=io.exit_code)
async def _execute_drained(
execute_fn: Callable,
inner: str,
session_id: str,
) -> tuple[bytes | None, IOResult]:
"""Run the inner line and drain its stdout under the same deadline.
A lazy inner pipeline produces bytes only when consumed; draining
inside the wait_for scope keeps the whole run under the limit.
Args:
execute_fn (Callable): shell evaluator for the inner line.
inner (str): the joined command line.
session_id (str): session to run in.
"""
io = await execute_fn(inner, session_id=session_id)
stdout = await materialize(io.stdout)
return stdout, io
@@ -0,0 +1,286 @@
# ========= 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.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.async_line_iterator import AsyncLineIterator
from mirage.io.stream import async_chain
from mirage.io.types import ByteSource
from mirage.shell.call_stack import CallStack
from mirage.shell.types import SET_FLAG_TO_OPTION
from mirage.workspace.executor.control import ReturnSignal
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_export(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="export", exit_code=1, stderr=err)
session.env[key] = val
else:
session.env.setdefault(assign, "")
return None, IOResult(), ExecutionNode(command="export", exit_code=0)
async def handle_readonly(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="readonly", exit_code=1, stderr=err)
session.env[key] = val
session.readonly_vars.add(key)
else:
session.readonly_vars.add(assign)
return None, IOResult(), ExecutionNode(command="readonly", exit_code=0)
async def handle_unset(
names: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for name in names:
if name in session.readonly_vars:
err = (f"bash: unset: {name}: cannot unset: "
f"readonly variable\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="unset",
exit_code=1,
stderr=err)
session.env.pop(name, None)
return None, IOResult(), ExecutionNode(command="unset", exit_code=0)
async def handle_printenv(
name: str | None,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if name:
val = session.env.get(name)
if val is None:
return None, IOResult(exit_code=1), ExecutionNode(
command="printenv", exit_code=1)
out = f"{val}\n".encode()
else:
lines = [f"{k}={v}" for k, v in session.env.items()]
out = ("\n".join(sorted(lines)) + "\n").encode()
return out, IOResult(), ExecutionNode(command="printenv", exit_code=0)
async def handle_whoami(
session: Session, # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
user = session.env.get("USER")
if user is None:
err = b"whoami: USER not set\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="whoami",
exit_code=1,
stderr=err)
out = f"{user}\n".encode()
return out, IOResult(), ExecutionNode(command="whoami", exit_code=0)
async def handle_read(
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Read one line into variables, with bash's option handling.
Only -r is accepted (our read is already raw, so it is consumed
with no effect); anything else errors like bash instead of being
treated as a variable name.
Args:
args (list[str]): words after the command name.
session (Session): shell session state.
stdin (ByteSource | None): line source.
"""
parse = parse_shell_options(SHELL_SPECS["read"], args)
if parse.invalid is not None:
token = (parse.invalid
if parse.invalid.startswith("--") else f"-{parse.invalid}")
err = f"read: {token}: invalid option\n".encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="read",
exit_code=2)
variables = parse.operands or ["REPLY"]
if session._stdin_buffer is None and stdin is not None:
if isinstance(stdin, bytes):
session._stdin_buffer = AsyncLineIterator(async_chain(stdin))
elif hasattr(stdin, "__aiter__"):
session._stdin_buffer = AsyncLineIterator(stdin)
line_bytes: bytes | None = None
if session._stdin_buffer is not None:
line_bytes = await session._stdin_buffer.readline()
if line_bytes is None:
for var in variables:
session.env[var] = ""
return None, IOResult(exit_code=1), ExecutionNode(command="read",
exit_code=1)
line = line_bytes.decode(errors="replace").rstrip("\n")
ifs = session.env.get("IFS", " \t\n")
if ifs == " \t\n":
parts = line.split(None, len(variables) - 1) if variables else []
elif not ifs:
parts = [line]
else:
n_splits = max(0, len(variables) - 1)
chars = set(ifs)
out: list[str] = []
cur: list[str] = []
for ch in line:
if ch in chars and len(out) < n_splits:
out.append("".join(cur))
cur = []
continue
cur.append(ch)
out.append("".join(cur))
parts = out
for i, var in enumerate(variables):
session.env[var] = parts[i] if i < len(parts) else ""
return None, IOResult(), ExecutionNode(command="read", exit_code=0)
async def handle_local(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
local_vars = getattr(session, "_local_vars", None)
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if local_vars is not None and key not in local_vars:
local_vars[key] = session.env.get(key)
session.env[key] = val
else:
if local_vars is not None and assign not in local_vars:
local_vars[assign] = session.env.get(assign)
session.env.setdefault(assign, "")
return None, IOResult(), ExecutionNode(command="local", exit_code=0)
async def handle_shift(
args: list[str],
call_stack: CallStack | None,
session: Session | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Shift positional parameters, with bash's argument checks.
Args:
args (list[str]): words after the command name; at most one,
the shift count.
call_stack (CallStack | None): function-call positional frames.
session (Session | None): shell session state.
"""
if len(args) > 1:
err = b"shift: too many arguments\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="shift",
exit_code=1)
if args and not _is_shift_count(args[0]):
err = f"shift: {args[0]}: numeric argument required\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="shift",
exit_code=1)
n = int(args[0]) if args else 1
shifted = False
if call_stack is not None and call_stack.get_all_positional():
call_stack.shift(n)
shifted = True
if not shifted and session is not None:
pos = getattr(session, "positional_args", None)
if pos is not None:
session.positional_args = pos[n:]
return None, IOResult(), ExecutionNode(command="shift", exit_code=0)
def _is_shift_count(word: str) -> bool:
body = word[1:] if word[:1] in ("-", "+") else word
return body.isdigit()
async def handle_set(
args: list[str],
session: Session,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
lines = [f"{k}={v}" for k, v in session.env.items()]
out = ("\n".join(sorted(lines)) + "\n").encode()
return out, IOResult(), ExecutionNode(command="set", exit_code=0)
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
session.positional_args = args[i + 1:]
return None, IOResult(), ExecutionNode(command="set", exit_code=0)
if tok in ("-o", "+o"):
if i + 1 < len(args):
session.shell_options[args[i + 1]] = (tok == "-o")
i += 2
continue
i += 1
continue
if (tok.startswith("-") or tok.startswith("+")) and len(tok) > 1:
enable = tok[0] == "-"
for ch in tok[1:]:
opt = SET_FLAG_TO_OPTION.get(ch)
if opt:
session.shell_options[opt] = enable
i += 1
continue
session.positional_args = args[i:]
break
return None, IOResult(), ExecutionNode(command="set", exit_code=0)
async def handle_trap(
session: Session, # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return None, IOResult(), ExecutionNode(command="trap", exit_code=0)
async def handle_return(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Return from a function, with bash's argument check.
Args:
args (list[str]): words after the command name; at most one,
the return status.
"""
if args and not _is_shift_count(args[0]):
# bash prints the error and the function returns 2.
raise ReturnSignal(
2,
stderr=f"return: {args[0]}: numeric argument required\n".encode())
raise ReturnSignal(int(args[0]) if args else 0)
@@ -0,0 +1,124 @@
# ========= 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 shlex
from collections.abc import Callable
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.stream import async_chain, materialize
from mirage.io.types import ByteSource
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_UNSUPPORTED = ("I", "P")
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
stderr = f"xargs: {message}\n".encode()
return None, IOResult(exit_code=1,
stderr=stderr), ExecutionNode(command="xargs",
exit_code=1)
def _split_items(data: bytes, flags: dict[str, str | bool]) -> list[str]:
if flags.get("0") is True:
return [
chunk.decode(errors="replace") for chunk in data.split(b"\0")
if chunk
]
delim = flags.get("d")
if isinstance(delim, str):
delim = delim.replace("\\n", "\n").replace("\\t", "\t")
text = data.decode(errors="replace")
if text.endswith(delim):
text = text[:-len(delim)]
return text.split(delim) if text else []
return data.decode(errors="replace").split()
async def handle_xargs(
execute_fn: Callable,
args: list[str],
session: Session,
stdin: ByteSource | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run a command with words read from stdin appended (GNU xargs).
GNU xargs execs the command directly, so every input word must
reach it as exactly one argv token. The inner line is built with
shlex.join: a plain join would be re-parsed by the shell, splitting
words with whitespace and executing $(...) found in input.
Args:
execute_fn (Callable): shell evaluator for the inner line.
args (list[str]): options, then command name and initial
arguments; the command defaults to ["echo"] like GNU.
session (Session): shell session state.
stdin (ByteSource | None): input whose words become arguments.
"""
parse = parse_shell_options(SHELL_SPECS["xargs"], args or [])
if parse.invalid is not None:
if parse.invalid.startswith("--"):
return _usage_error(f"unrecognized option '{parse.invalid}'")
return _usage_error(f"invalid option -- '{parse.invalid}'")
if parse.needs_value is not None:
return _usage_error(
f"option requires an argument -- '{parse.needs_value}'")
for name in _UNSUPPORTED:
if name in parse.flags:
return _usage_error(f"unsupported option -- '{name}'")
max_args: int | None = None
raw_n = parse.flags.get("n")
if isinstance(raw_n, str):
if not raw_n.isdigit():
return _usage_error(f'invalid number "{raw_n}" for -n option')
max_args = int(raw_n)
if max_args < 1:
return _usage_error(f"value {raw_n} for -n option should be >= 1")
data = await materialize(stdin)
if data is None:
data = b""
items = _split_items(data, parse.flags)
if not items and parse.flags.get("r") is True:
return None, IOResult(), ExecutionNode(command="xargs", exit_code=0)
command = parse.operands or ["echo"]
if max_args is None:
batches = [items]
else:
batches = [
items[i:i + max_args] for i in range(0, len(items), max_args)
] or [[]]
stdouts: list[ByteSource] = []
merged = IOResult()
exit_code = 0
for batch in batches:
inner = shlex.join([*command, *batch])
io = await execute_fn(inner, session_id=session.session_id)
if io.stdout is not None:
stdouts.append(io.stdout)
merged = await merged.merge(io)
if io.exit_code in (126, 127):
# GNU xargs stops when the command cannot run or is missing.
exit_code = io.exit_code
break
if io.exit_code != 0:
# GNU exits 123 when any invocation fails, but keeps going.
exit_code = 123
merged.exit_code = exit_code
out = async_chain(*stdouts) if stdouts else None
return out, merged, ExecutionNode(command="xargs", exit_code=exit_code)