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
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:
@@ -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)
|
||||
Reference in New Issue
Block a user