3293cb5ff4
Two cleanup-plan items, both Python-side.
Item 29 -- `mirage mcp`. TypeScript shipped a six-tool stdio MCP server;
Python had none, so a pip-install user could not point Cursor or Claude
Desktop at a workspace and `mirage --help` differed by distribution.
Adding the entry point alone would have duplicated the tools, because
this side kept them private inside the Claude Agent SDK integration, so
the shared layer comes first:
- agents/tool_descriptions.py -- the six strings, one copy.
- agents/tool_operations.py -- MirageToolOperations, lifted out of the
SDK server's private _MirageTools.
- agents/file_version.py -- stale-write protection, which this side
lacked entirely. TS stamps stored bytes; here the stamp covers the
rendered bytes, because this read tool has always rendered and an
edit must search what the agent was actually shown.
- agents/mcp/server.py + cli/mcp.py -- the server and `mirage mcp`.
- server/workspace_config.py -- config discovery (candidates, env
names, walk up from cwd), which Python had nowhere, so every entry
point had to be handed an explicit path.
The server is the low-level MCP Server rather than FastMCP: FastMCP does
not forward a version, and TS advertises one. Handlers are bound methods,
not decorated closures, so nothing nests.
Item 28 -- the mypy allowlist. 54 modules opted *in* to annotation
checking against 1826, so the default was unchecked and every new file
joined the unchecked side. The default is now strict, with a list of
what is not yet annotated that only shrinks. 166 annotations cleared
along the way; the remainder is named module by module.
Two real defects surfaced by the annotations, neither of them typing:
- Workspace._original_open / _original_os were invented by assignment
in lifecycle.patch_process, so unpatch without a patch raised
AttributeError. Declared, and the restore is guarded.
- sed_generic declared a non-optional writer while its own docstring
and its `write_bytes is None` branch said otherwise; the builder
passes None whenever the backend cannot write.
Tests keep the PathSpec rule instead of full strict: measured, full
strict on python/tests is 2374 errors, of which 634 are `str` where a
pydantic field declares SecretStr -- which pydantic coerces at runtime --
and most of the rest is the monkeypatched-fake pattern CLAUDE.md
sanctions. The rule that is violated for real is PathSpec, 19 times, and
scripts/check_test_pathspec.py now holds that line. One of the 19 was a
latent AttributeError: tests/e2e passes a str to s3 write_bytes, which
reads .mount_path, and the test skips without a live versioned bucket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
443 lines
14 KiB
Python
443 lines
14 KiB
Python
from collections.abc import Awaitable, Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from itertools import groupby
|
|
|
|
from mirage.commands.builtin.utils.formatting import _ls_mode_string
|
|
from mirage.commands.builtin.utils.output import format_records
|
|
from mirage.commands.config import CommandOpts
|
|
from mirage.commands.spec import SPECS
|
|
from mirage.commands.spec.types import FlagValue, FlagView
|
|
from mirage.core.timeutil import iso_to_epoch
|
|
from mirage.io.types import ByteSource, IOResult
|
|
from mirage.ops.types import LinkView
|
|
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec, StatFn
|
|
from mirage.utils.errors import FS_ERRORS, fs_error_line
|
|
|
|
_STR_DIRECTIVES = frozenset("nNF")
|
|
|
|
_FORMAT_FLAGS = frozenset("#0 +-")
|
|
|
|
_ASCII_DIGITS = frozenset("0123456789")
|
|
|
|
_TYPE_LABELS = {
|
|
FileType.DIRECTORY: "directory",
|
|
FileType.SYMLINK: "symbolic link",
|
|
FileType.TEXT: "regular file",
|
|
FileType.BINARY: "regular file",
|
|
FileType.JSON: "regular file",
|
|
FileType.CSV: "regular file",
|
|
}
|
|
|
|
_DEFAULT_OWNER = "user"
|
|
|
|
_SHELL_SPECIAL = frozenset("!\"#$&()*;<=>?[\\^`{|}~")
|
|
|
|
_START_SAFE = frozenset("#~")
|
|
|
|
_ESCAPE_NAMES = {
|
|
"\a": "\\a",
|
|
"\b": "\\b",
|
|
"\t": "\\t",
|
|
"\n": "\\n",
|
|
"\v": "\\v",
|
|
"\f": "\\f",
|
|
"\r": "\\r",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _FormatDirective:
|
|
"""One parsed ``%[flags][width][.precision]conversion`` directive.
|
|
|
|
Args:
|
|
end (int): index just past the directive in the format string.
|
|
flags (str): any of ``# 0 + -``.
|
|
width (str): minimum field width (digits) or empty.
|
|
precision (str | None): precision digits, or None when absent.
|
|
spec (str): the conversion char, H/L-prefixed for device major/minor.
|
|
"""
|
|
|
|
end: int
|
|
flags: str
|
|
width: str
|
|
precision: str | None
|
|
spec: str
|
|
|
|
|
|
def _type_label(s: FileStat) -> str:
|
|
return _TYPE_LABELS.get(s.type,
|
|
"regular file") if s.type else "regular file"
|
|
|
|
|
|
def _effective_mode(s: FileStat) -> int:
|
|
if s.mode is not None:
|
|
return s.mode & 0o7777
|
|
if s.type == FileType.DIRECTORY:
|
|
return 0o755
|
|
# A symlink carries no permission bits of its own; GNU reports 0777.
|
|
if s.type == FileType.SYMLINK:
|
|
return 0o777
|
|
return 0o644
|
|
|
|
|
|
def _type_bits(s: FileStat) -> int:
|
|
if s.type == FileType.DIRECTORY:
|
|
return 0o040000
|
|
if s.type == FileType.SYMLINK:
|
|
return 0o120000
|
|
return 0o100000
|
|
|
|
|
|
def _owner(value: int | str | None) -> str:
|
|
return str(value) if value is not None else _DEFAULT_OWNER
|
|
|
|
|
|
def _epoch(iso: str | None) -> str:
|
|
if not iso:
|
|
return "0"
|
|
try:
|
|
return str(iso_to_epoch(iso))
|
|
except (ValueError, TypeError):
|
|
return "0"
|
|
|
|
|
|
def _needs_escape(char: str) -> bool:
|
|
"""Whether GNU spells a character as a ``$'..'`` escape.
|
|
|
|
Args:
|
|
char (str): the character to test.
|
|
"""
|
|
return char < " " or char == "\x7f"
|
|
|
|
|
|
def _escape_char(char: str) -> str:
|
|
"""Spell one character the way bash's ``$'..'`` does.
|
|
|
|
Args:
|
|
char (str): the character to escape.
|
|
"""
|
|
named = _ESCAPE_NAMES.get(char)
|
|
return named if named is not None else f"\\{ord(char):03o}"
|
|
|
|
|
|
def _double_quotable(name: str) -> bool:
|
|
"""Whether a name holding an apostrophe still fits in double quotes.
|
|
|
|
GNU only reaches for them when nothing else in the name would stay
|
|
live inside them, so ``a'b`` renders as ``"a'b"`` but ``a'b$c`` does
|
|
not. ``#`` and ``~`` count as special only away from the front.
|
|
|
|
Args:
|
|
name (str): the name to test.
|
|
"""
|
|
for index, char in enumerate(name):
|
|
if _needs_escape(char):
|
|
return False
|
|
if char in _SHELL_SPECIAL and not (index == 0 and char in _START_SAFE):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _single_quoted(name: str) -> str:
|
|
"""Render single-quoted runs spliced with ``$'..'`` escape segments.
|
|
|
|
Args:
|
|
name (str): the name to quote.
|
|
"""
|
|
parts: list[str] = []
|
|
for index, (escaped, chars) in enumerate(groupby(name, _needs_escape)):
|
|
run = "".join(chars)
|
|
if not escaped:
|
|
parts.append("'" + run.replace("'", "'\\''") + "'")
|
|
continue
|
|
# A leading escape keeps the empty quotes GNU emits; a trailing
|
|
# one does not.
|
|
if index == 0:
|
|
parts.append("''")
|
|
parts.append("$'" + "".join(_escape_char(c) for c in run) + "'")
|
|
return "".join(parts) if parts else "''"
|
|
|
|
|
|
def _quote_name(name: str) -> str:
|
|
"""Shell-safe quoting for %N, mirroring GNU's default.
|
|
|
|
Single quotes are the rule, with each apostrophe escaped as
|
|
``'\\''`` and every unprintable character lifted into a ``$'..'``
|
|
segment. A name whose only awkward character is an apostrophe reads
|
|
better in double quotes, and GNU renders that one case that way.
|
|
|
|
Args:
|
|
name (str): the file name to quote.
|
|
"""
|
|
if "'" in name and _double_quotable(name):
|
|
return f'"{name}"'
|
|
return _single_quoted(name)
|
|
|
|
|
|
def _apply_flags(value: str, flags: str, width: str, precision: str | None,
|
|
spec: str) -> str:
|
|
"""Apply GNU printf flags/width/precision to a rendered directive.
|
|
|
|
Args:
|
|
value (str): the raw directive value.
|
|
flags (str): any of ``# 0 + -``.
|
|
width (str): minimum field width (digits) or empty.
|
|
precision (str | None): precision digits, or None when absent.
|
|
spec (str): the conversion character.
|
|
"""
|
|
if "#" in flags and spec == "a" and not value.startswith("0"):
|
|
value = "0" + value
|
|
if precision is not None and spec in _STR_DIRECTIVES:
|
|
value = value[:int(precision)] if precision else ""
|
|
if width and len(value) < int(width):
|
|
w = int(width)
|
|
if "-" in flags:
|
|
value = value.ljust(w)
|
|
elif "0" in flags:
|
|
value = value.rjust(w, "0")
|
|
else:
|
|
value = value.rjust(w)
|
|
return value
|
|
|
|
|
|
def _directive_value(spec: str, s: FileStat, name: str) -> str:
|
|
if spec == "%":
|
|
return "%"
|
|
if spec == "n":
|
|
return name
|
|
if spec == "s":
|
|
return str(s.size if s.size is not None else 0)
|
|
if spec == "F":
|
|
return _type_label(s)
|
|
if spec == "a":
|
|
return format(_effective_mode(s), "o")
|
|
if spec == "A":
|
|
return _ls_mode_string(s)
|
|
if spec == "f":
|
|
return format(_type_bits(s) | _effective_mode(s), "x")
|
|
if spec in ("u", "U"):
|
|
return _owner(s.uid)
|
|
if spec in ("g", "G"):
|
|
return _owner(s.gid)
|
|
if spec == "x":
|
|
return s.atime or s.modified or ""
|
|
if spec == "X":
|
|
return _epoch(s.atime or s.modified)
|
|
if spec in ("y", "z"):
|
|
return s.modified or ""
|
|
if spec in ("Y", "Z"):
|
|
return _epoch(s.modified)
|
|
if spec == "w":
|
|
return "-"
|
|
if spec == "W":
|
|
return "0"
|
|
if spec == "B":
|
|
return "512"
|
|
if spec in ("r", "R", "t", "T"):
|
|
return "0"
|
|
if len(spec) == 2 and spec[0] in "HL":
|
|
# %Hr/%Lr are rdev major/minor (0, like %r); %Hd/%Ld are device
|
|
# major/minor, which a VFS has no truthful value for.
|
|
return "0" if spec[1] in "rR" else "?"
|
|
return "?"
|
|
|
|
|
|
def _name_parts(s: FileStat, name: str, quoted: bool) -> list[str]:
|
|
"""The fields ``%N`` renders: the name, plus a symlink's target.
|
|
|
|
Args:
|
|
s (FileStat): the stat being rendered.
|
|
name (str): the operand as it was typed.
|
|
quoted (bool): shell-quote each field. GNU only does so for a bare
|
|
``%N``; any flag, width or precision drops the quotes.
|
|
"""
|
|
parts = [name]
|
|
if s.type == FileType.SYMLINK:
|
|
target = s.extra.get(LINK_TARGET_KEY)
|
|
if target:
|
|
parts.append(str(target))
|
|
return [_quote_name(p) for p in parts] if quoted else parts
|
|
|
|
|
|
def _render_directive(d: _FormatDirective, s: FileStat, name: str) -> str:
|
|
"""Render one directive with its flags, width and precision applied.
|
|
|
|
Args:
|
|
d (_FormatDirective): the parsed directive.
|
|
s (FileStat): the stat being rendered.
|
|
name (str): the operand as it was typed.
|
|
"""
|
|
if d.spec == "N":
|
|
# GNU formats the name and a symlink's target as two separate
|
|
# fields, so a width pads each one rather than the joined line.
|
|
bare = not d.flags and not d.width and d.precision is None
|
|
return " -> ".join(
|
|
_apply_flags(part, d.flags, d.width, d.precision, d.spec)
|
|
for part in _name_parts(s, name, bare))
|
|
return _apply_flags(_directive_value(d.spec, s, name), d.flags, d.width,
|
|
d.precision, d.spec)
|
|
|
|
|
|
def _is_conversion(char: str) -> bool:
|
|
return char == "%" or "A" <= char <= "Z" or "a" <= char <= "z"
|
|
|
|
|
|
def _parse_format_directive(fmt: str, start: int) -> _FormatDirective | None:
|
|
"""Scan one GNU printf-style directive starting at a ``%``.
|
|
|
|
Walks flags, width and precision with an explicit cursor so a long run
|
|
of flag/width characters that never reaches a conversion char costs
|
|
linear time instead of backtracking (CodeQL #247).
|
|
|
|
Args:
|
|
fmt (str): the whole format string.
|
|
start (int): index of the leading ``%``.
|
|
"""
|
|
end = len(fmt)
|
|
cursor = start + 1
|
|
flags_start = cursor
|
|
while cursor < end and fmt[cursor] in _FORMAT_FLAGS:
|
|
cursor += 1
|
|
flags = fmt[flags_start:cursor]
|
|
|
|
width_start = cursor
|
|
while cursor < end and fmt[cursor] in _ASCII_DIGITS:
|
|
cursor += 1
|
|
width = fmt[width_start:cursor]
|
|
|
|
precision: str | None = None
|
|
if cursor < end and fmt[cursor] == ".":
|
|
cursor += 1
|
|
precision_start = cursor
|
|
while cursor < end and fmt[cursor] in _ASCII_DIGITS:
|
|
cursor += 1
|
|
precision = fmt[precision_start:cursor]
|
|
|
|
if cursor >= end or not _is_conversion(fmt[cursor]):
|
|
return None
|
|
spec = fmt[cursor]
|
|
cursor += 1
|
|
if spec in ("H", "L") and cursor < end and _is_conversion(fmt[cursor]):
|
|
spec += fmt[cursor]
|
|
cursor += 1
|
|
return _FormatDirective(end=cursor,
|
|
flags=flags,
|
|
width=width,
|
|
precision=precision,
|
|
spec=spec)
|
|
|
|
|
|
def _format_stat(fmt: str, s: FileStat, name: str) -> str:
|
|
parts: list[str] = []
|
|
cursor = 0
|
|
while cursor < len(fmt):
|
|
start = fmt.find("%", cursor)
|
|
if start == -1:
|
|
parts.append(fmt[cursor:])
|
|
break
|
|
parts.append(fmt[cursor:start])
|
|
directive = _parse_format_directive(fmt, start)
|
|
if directive is None:
|
|
parts.append("%")
|
|
cursor = start + 1
|
|
continue
|
|
parts.append(_render_directive(directive, s, name))
|
|
cursor = directive.end
|
|
return "".join(parts)
|
|
|
|
|
|
def _render_stat(s: FileStat) -> str:
|
|
"""Render the default (no -c) stat line.
|
|
|
|
Args:
|
|
s (FileStat): the stat to render.
|
|
"""
|
|
return (f"name={s.name} size={s.size} modified={s.modified}"
|
|
f" type={s.type.value if s.type else None}")
|
|
|
|
|
|
async def stat(
|
|
paths: list[PathSpec],
|
|
*,
|
|
stat_fn: Callable[..., Awaitable[FileStat]],
|
|
c: str | None = None,
|
|
f: str | None = None,
|
|
L: bool = False,
|
|
links: LinkView | None = None,
|
|
) -> tuple[ByteSource | None, IOResult]:
|
|
"""Report file status, GNU stat semantics.
|
|
|
|
Args:
|
|
paths (list[PathSpec]): operands to stat.
|
|
stat_fn (Callable): backend stat for a resolved path.
|
|
c (str | None): output format string.
|
|
f (str | None): output format string (alias of -c here).
|
|
L (bool): dereference symlinks instead of reporting the link.
|
|
links (LinkView | None): the namespace's symlink facts;
|
|
absent when the workspace holds no links.
|
|
"""
|
|
if not paths:
|
|
raise ValueError("stat: missing operand")
|
|
fmt = c if c is not None else f
|
|
lines: list[str] = []
|
|
err = b""
|
|
for p in paths:
|
|
# GNU stat lstats: a symlink operand reports the link itself,
|
|
# not its target, unless -L asks to dereference. A link has no
|
|
# backend inode, so the namespace is the only authority for it.
|
|
linked = None if L or links is None else links.stat_at(p.virtual)
|
|
if linked is not None:
|
|
if fmt is not None:
|
|
lines.append(_format_stat(fmt, linked, p.raw_path))
|
|
else:
|
|
lines.append(_render_stat(linked))
|
|
continue
|
|
try:
|
|
s = await stat_fn(p)
|
|
except FS_ERRORS as exc:
|
|
# GNU stat keeps reporting the remaining operands, exit 1.
|
|
err += fs_error_line("stat", p, exc).encode()
|
|
continue
|
|
if fmt is not None:
|
|
lines.append(_format_stat(fmt, s, p.raw_path))
|
|
else:
|
|
lines.append(_render_stat(s))
|
|
io = IOResult(exit_code=1 if err else 0, stderr=err or None)
|
|
if not lines:
|
|
return None, io
|
|
return format_records(lines), io
|
|
|
|
|
|
__all__ = ["stat"]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class StatFlags:
|
|
format: str | None = None
|
|
file_system: str | None = None
|
|
deref: bool = False
|
|
|
|
|
|
def parse_flags(flags: Mapping[str, FlagValue]) -> StatFlags:
|
|
fl = FlagView(flags, spec=SPECS["stat"])
|
|
return StatFlags(
|
|
format=fl.as_str("c"),
|
|
file_system=fl.as_str("f"),
|
|
deref=fl.as_bool("L"),
|
|
)
|
|
|
|
|
|
async def stat_generic(
|
|
paths: list[PathSpec],
|
|
texts: list[str],
|
|
opts: CommandOpts,
|
|
stat_fn: StatFn,
|
|
) -> tuple[ByteSource | None, IOResult]:
|
|
parsed = parse_flags(opts.flags)
|
|
return await stat(paths,
|
|
stat_fn=stat_fn,
|
|
c=parsed.format,
|
|
f=parsed.file_system,
|
|
L=parsed.deref,
|
|
links=opts.ns.links if opts.ns is not None else None)
|