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>
350 lines
14 KiB
Python
350 lines
14 KiB
Python
import posixpath
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from functools import partial
|
|
|
|
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
|
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.context import mount_allowed
|
|
from mirage.io.types import ByteSource, IOResult
|
|
from mirage.ops.types import MountView, ReaddirPath, StatPath
|
|
from mirage.types import FileStat, FileType, PathSpec, ReaddirFn
|
|
from mirage.utils.errors import WALK_ERRORS
|
|
from mirage.utils.fnmatch import fnmatch
|
|
from mirage.utils.key_prefix import rekey
|
|
|
|
# GNU tree's ASCII (C-locale) drawing set, matching `tree` in the battery's
|
|
# docker oracle; the vertical/indent continuations are 4 columns wide.
|
|
_BRANCH = "|-- "
|
|
_LAST = "`-- "
|
|
_VERTICAL = "| "
|
|
_INDENT = " "
|
|
|
|
Readdir = Callable[[PathSpec, IndexCacheStore | None], Awaitable[list[str]]]
|
|
Stat = Callable[[PathSpec, IndexCacheStore | None], Awaitable[FileStat]]
|
|
|
|
|
|
async def _cross_readdir(readdir_path: ReaddirPath, path: PathSpec,
|
|
index: IndexCacheStore | None) -> list[str]:
|
|
"""List a directory that belongs to another mount.
|
|
|
|
Args:
|
|
readdir_path (ReaddirPath): dispatcher-backed readdir.
|
|
path (PathSpec): the directory to list.
|
|
index (IndexCacheStore | None): unused; the owning mount indexes
|
|
its own listing inside the dispatched op.
|
|
"""
|
|
return await readdir_path(path.virtual)
|
|
|
|
|
|
async def _cross_stat(stat_path: StatPath, path: PathSpec,
|
|
index: IndexCacheStore | None) -> FileStat:
|
|
"""Stat an entry that belongs to another mount.
|
|
|
|
Args:
|
|
stat_path (StatPath): dispatcher-backed stat.
|
|
path (PathSpec): the entry to stat.
|
|
index (IndexCacheStore | None): unused, as for `_cross_readdir`.
|
|
"""
|
|
stat = await stat_path(path.virtual)
|
|
if stat is None:
|
|
raise FileNotFoundError(path.virtual)
|
|
return stat
|
|
|
|
|
|
def _child_mounts(mounts: MountView | None, directory: str) -> list[str]:
|
|
"""The mount roots mounted directly on this directory.
|
|
|
|
A mount point need not exist in the parent backend at all, and when
|
|
it does the parent lists a directory whose contents belong to
|
|
somebody else. Either way the name has to come from the mount table,
|
|
the same way `ls` injects it.
|
|
|
|
Session-filtered, because a crossing entry is drawn from the mount
|
|
table alone: its row is synthesized as a directory without asking
|
|
any backend, so the dispatcher never gets the chance to refuse it
|
|
and an ungranted mount's name would reach the drawing. `ls` filters
|
|
the same fact through `child_mount_names`. Note this is the opposite
|
|
of what `du` wants from the same view: there an ungranted mount
|
|
still shadows the parent's keys, so its prefix must stay in the
|
|
list even though the walk never enters it.
|
|
|
|
Args:
|
|
mounts (MountView | None): the boundary facts.
|
|
directory (str): absolute virtual path being listed.
|
|
"""
|
|
if mounts is None:
|
|
return []
|
|
base = directory.rstrip("/")
|
|
return [
|
|
root for root in mounts.descendants(directory)
|
|
if posixpath.dirname(root) == (base or "/") and mount_allowed(root)
|
|
]
|
|
|
|
|
|
async def _walk(
|
|
path: PathSpec,
|
|
readdir: Readdir,
|
|
stat: Stat,
|
|
*,
|
|
prefix: str,
|
|
depth: int,
|
|
max_depth: int | None,
|
|
show_hidden: bool,
|
|
ignore_pattern: str | None,
|
|
dirs_only: bool,
|
|
match_pattern: str | None,
|
|
warnings: list[str],
|
|
index: IndexCacheStore,
|
|
mounts: MountView | None = None,
|
|
cross_readdir: Readdir | None = None,
|
|
cross_stat: Stat | None = None,
|
|
) -> tuple[list[str], int, int]:
|
|
lines: list[str] = []
|
|
dirs = 0
|
|
files = 0
|
|
try:
|
|
entries = sorted(await readdir(path, index))
|
|
except WALK_ERRORS as exc:
|
|
warnings.append(f"tree: '{path.raw_path}': {exc}")
|
|
return lines, dirs, files
|
|
child_mounts = _child_mounts(mounts, path.virtual)
|
|
if child_mounts:
|
|
entries = sorted(set(entries) | set(child_mounts))
|
|
|
|
filtered: list[tuple[PathSpec, FileStat, bool]] = []
|
|
for entry in entries:
|
|
entry_spec = PathSpec(virtual=entry,
|
|
directory=entry,
|
|
resolved=False,
|
|
resource_path=rekey(path.virtual,
|
|
path.resource_path, entry))
|
|
crossing = entry in child_mounts and cross_readdir is not None
|
|
if crossing:
|
|
# The mount table already says this is a directory, and the
|
|
# backend serving it may not stat its own root (an empty
|
|
# mount, or a prefix store with no marker object).
|
|
s = FileStat(name=posixpath.basename(entry.rstrip("/")),
|
|
type=FileType.DIRECTORY)
|
|
else:
|
|
try:
|
|
s = await stat(entry_spec, index)
|
|
except WALK_ERRORS as exc:
|
|
warnings.append(f"tree: '{entry}': {exc}")
|
|
continue
|
|
if not show_hidden and s.name.startswith("."):
|
|
continue
|
|
if ignore_pattern and fnmatch(s.name, ignore_pattern):
|
|
continue
|
|
if dirs_only and s.type != FileType.DIRECTORY:
|
|
continue
|
|
not_dir = s.type != FileType.DIRECTORY
|
|
if match_pattern and not_dir and not fnmatch(s.name, match_pattern):
|
|
continue
|
|
filtered.append((entry_spec, s, crossing))
|
|
|
|
for i, (entry_spec, s, crossing) in enumerate(filtered):
|
|
is_last = i == len(filtered) - 1
|
|
connector = _LAST if is_last else _BRANCH
|
|
lines.append(prefix + connector + s.name)
|
|
if s.type != FileType.DIRECTORY:
|
|
files += 1
|
|
continue
|
|
dirs += 1
|
|
if max_depth is not None and depth + 1 >= max_depth:
|
|
continue
|
|
extension = _INDENT if is_last else _VERTICAL
|
|
# Past a mount root the subtree belongs to another resource, so
|
|
# the rest of this branch reads through the dispatcher. Deeper
|
|
# mounts under it need no second switch: the dispatcher already
|
|
# routes every path to its owner.
|
|
sub_readdir = cross_readdir if crossing and cross_readdir else readdir
|
|
sub_stat = cross_stat if crossing and cross_stat else stat
|
|
sub, sub_dirs, sub_files = await _walk(entry_spec,
|
|
sub_readdir,
|
|
sub_stat,
|
|
prefix=prefix + extension,
|
|
depth=depth + 1,
|
|
max_depth=max_depth,
|
|
show_hidden=show_hidden,
|
|
ignore_pattern=ignore_pattern,
|
|
dirs_only=dirs_only,
|
|
match_pattern=match_pattern,
|
|
warnings=warnings,
|
|
index=index,
|
|
mounts=mounts,
|
|
cross_readdir=cross_readdir,
|
|
cross_stat=cross_stat)
|
|
lines.extend(sub)
|
|
dirs += sub_dirs
|
|
files += sub_files
|
|
return lines, dirs, files
|
|
|
|
|
|
def _summary(dirs: int, files: int, dirs_only: bool) -> str:
|
|
dir_word = "directory" if dirs == 1 else "directories"
|
|
if dirs_only:
|
|
return f"{dirs} {dir_word}"
|
|
file_word = "file" if files == 1 else "files"
|
|
return f"{dirs} {dir_word}, {files} {file_word}"
|
|
|
|
|
|
def _unopenable(root_label: str, dirs_only: bool, files: int,
|
|
exit_code: int) -> tuple[bytes, IOResult]:
|
|
"""GNU's inline marker for a root it could not open.
|
|
|
|
``tree`` prints the marker and nothing on stderr, so the exit code and
|
|
the counted file carry the distinction: a path that is not a directory
|
|
exists and is counted (exit 0), a path that is not there is not
|
|
(exit 2).
|
|
|
|
Args:
|
|
root_label (str): the operand as typed.
|
|
dirs_only (bool): whether ``-d`` omits the file count.
|
|
files (int): files to report in the summary.
|
|
exit_code (int): process exit status.
|
|
"""
|
|
body = [
|
|
f"{root_label} [error opening dir]", "",
|
|
_summary(0, files, dirs_only)
|
|
]
|
|
return format_records(body), IOResult(exit_code=exit_code)
|
|
|
|
|
|
async def tree(
|
|
path: PathSpec,
|
|
*,
|
|
readdir: Readdir,
|
|
stat: Stat,
|
|
max_depth: int | None = None,
|
|
show_hidden: bool = False,
|
|
ignore_pattern: str | None = None,
|
|
dirs_only: bool = False,
|
|
match_pattern: str | None = None,
|
|
index: IndexCacheStore = NULL_INDEX,
|
|
stat_path: StatPath | None = None,
|
|
readdir_path: ReaddirPath | None = None,
|
|
mounts: MountView | None = None,
|
|
) -> tuple[bytes, IOResult]:
|
|
"""Render one directory tree, GNU ``tree``'s drawing and summary.
|
|
|
|
Unlike find and du, tree's output is a single document: one root
|
|
line, one drawing, one count. Concatenating a per-mount run would
|
|
print two of each, so a nested mount is crossed here instead, the
|
|
way real ``tree`` crosses one (pinned on tree 2.2.1: the mounted
|
|
filesystem's entries are drawn under the mount point, the covered
|
|
ones are not drawn at all, and the summary counts the whole thing).
|
|
|
|
Args:
|
|
path (PathSpec): the operand to draw.
|
|
readdir (Readdir): this mount's directory listing.
|
|
stat (Stat): this mount's stat.
|
|
max_depth (int | None): -L, deepest level to draw.
|
|
show_hidden (bool): -a.
|
|
ignore_pattern (str | None): -I.
|
|
dirs_only (bool): -d.
|
|
match_pattern (str | None): -P.
|
|
index (IndexCacheStore): listing cache for this mount.
|
|
stat_path (StatPath | None): dispatcher-backed stat, used for the
|
|
operand itself and for entries past a mount boundary.
|
|
readdir_path (ReaddirPath | None): dispatcher-backed readdir,
|
|
which is how a subtree on another mount is read at all.
|
|
mounts (MountView | None): where the mount boundaries are.
|
|
"""
|
|
warnings: list[str] = []
|
|
root_label = path.raw_path or path.virtual
|
|
# What the operand is decides the whole result, so it is resolved
|
|
# before the walk rather than inferred from how a backend answered
|
|
# readdir on it: an object store lists a file key as an empty prefix,
|
|
# lists a missing path as one too, and Graph 404s, which read as
|
|
# three different trees. The probe asks both channels a backend can
|
|
# answer on, so a directory that exists only as its children still
|
|
# reports as one and None means nothing is there.
|
|
if stat_path is not None:
|
|
start = await stat_path(path.virtual)
|
|
if start is None:
|
|
return _unopenable(root_label, dirs_only, 0, 2)
|
|
if start.type != FileType.DIRECTORY:
|
|
return _unopenable(root_label, dirs_only, 1, 0)
|
|
cross_readdir = (partial(_cross_readdir, readdir_path)
|
|
if readdir_path is not None else None)
|
|
cross_stat = (partial(_cross_stat, stat_path)
|
|
if stat_path is not None else None)
|
|
lines, dirs, files = await _walk(path,
|
|
readdir,
|
|
stat,
|
|
prefix="",
|
|
depth=0,
|
|
max_depth=max_depth,
|
|
show_hidden=show_hidden,
|
|
ignore_pattern=ignore_pattern,
|
|
dirs_only=dirs_only,
|
|
match_pattern=match_pattern,
|
|
warnings=warnings,
|
|
index=index,
|
|
mounts=mounts,
|
|
cross_readdir=cross_readdir,
|
|
cross_stat=cross_stat)
|
|
# GNU signals an unopenable path with the inline "[error opening dir]"
|
|
# marker and exit 2, and writes nothing to stderr. `warnings` therefore
|
|
# only decides the marker; emitting it would diverge. With stat_path
|
|
# wired the two clear cases are already answered above, so this covers
|
|
# a directory that exists but could not be read (a permission error).
|
|
if warnings and not lines:
|
|
return _unopenable(root_label, dirs_only, 0, 2)
|
|
# GNU counts the root as a directory once it has any listed entry (an
|
|
# empty root reports 0), then a blank line and the summary (the file
|
|
# count is omitted under -d).
|
|
root_dirs = dirs + 1 if lines else 0
|
|
body = [root_label] + lines + ["", _summary(root_dirs, files, dirs_only)]
|
|
return format_records(body), IOResult()
|
|
|
|
|
|
__all__ = ["tree"]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TreeFlags:
|
|
max_depth: int | None = None
|
|
show_hidden: bool = False
|
|
ignore_pattern: str | None = None
|
|
dirs_only: bool = False
|
|
match_pattern: str | None = None
|
|
|
|
|
|
def parse_flags(flags: Mapping[str, FlagValue]) -> TreeFlags:
|
|
fl = FlagView(flags, spec=SPECS["tree"])
|
|
depth_raw = fl.as_str("L")
|
|
return TreeFlags(
|
|
max_depth=int(depth_raw) if depth_raw is not None else None,
|
|
show_hidden=fl.as_bool("a"),
|
|
ignore_pattern=fl.as_str("args_I"),
|
|
dirs_only=fl.as_bool("d"),
|
|
match_pattern=fl.as_str("P"),
|
|
)
|
|
|
|
|
|
async def tree_generic(
|
|
paths: list[PathSpec],
|
|
texts: list[str],
|
|
opts: CommandOpts,
|
|
readdir: ReaddirFn,
|
|
stat: Stat,
|
|
) -> tuple[ByteSource | None, IOResult]:
|
|
parsed = parse_flags(opts.flags)
|
|
return await tree(paths[0],
|
|
readdir=readdir,
|
|
stat=stat,
|
|
max_depth=parsed.max_depth,
|
|
show_hidden=parsed.show_hidden,
|
|
ignore_pattern=parsed.ignore_pattern,
|
|
dirs_only=parsed.dirs_only,
|
|
match_pattern=parsed.match_pattern,
|
|
index=opts.index,
|
|
stat_path=opts.stat_path,
|
|
readdir_path=opts.readdir_path,
|
|
mounts=opts.ns.mounts if opts.ns is not None else None)
|