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>
158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
# ========= 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 base64
|
|
from dataclasses import dataclass
|
|
|
|
from mirage.accessor.github import GitHubAccessor
|
|
from mirage.core.github._client import (GitHubApiError, github_get,
|
|
github_request)
|
|
from mirage.core.github.config import GhConfig, GitHubConfig
|
|
from mirage.types import JsonValue
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RepoRef:
|
|
owner: str
|
|
repo: str
|
|
|
|
|
|
async def fetch_default_branch(config: GitHubConfig, owner: str,
|
|
repo: str) -> str:
|
|
data = await github_get(config.token,
|
|
"/repos/{owner}/{repo}",
|
|
base_url=config.base_url,
|
|
owner=owner,
|
|
repo=repo)
|
|
return data["default_branch"]
|
|
|
|
|
|
async def ensure_default_branch(accessor: GitHubAccessor, ) -> str:
|
|
"""Fetch the repo's default branch once, on the first read needing it.
|
|
|
|
The mount names a repository without contacting it, so this is the
|
|
hydration point for the one caller that compares against the default
|
|
branch (grep's code-search push-down, which GitHub only serves
|
|
there).
|
|
|
|
Args:
|
|
accessor (GitHubAccessor): the mount's accessor.
|
|
|
|
Returns:
|
|
str: the repository's default branch.
|
|
"""
|
|
if accessor.default_branch is not None:
|
|
return accessor.default_branch
|
|
async with accessor.branch_lock:
|
|
if accessor.default_branch is None:
|
|
accessor.default_branch = await fetch_default_branch(
|
|
accessor.config, accessor.owner, accessor.repo)
|
|
return accessor.default_branch
|
|
|
|
|
|
def parse_repo(spec: str) -> RepoRef:
|
|
"""Split gh's `[HOST/]OWNER/REPO`.
|
|
|
|
The host is optional and leading, so the owner and the repository are
|
|
always the last two segments. Taking the first two instead reads
|
|
`github.com/acme/tools` as owner `github.com`, repo `acme` -- a
|
|
different repository, reported as success.
|
|
|
|
Args:
|
|
spec (str): the repository as the line spelled it.
|
|
|
|
Returns:
|
|
RepoRef: the owner and repository names.
|
|
|
|
Raises:
|
|
ValueError: the spec is not one or two slashes of names.
|
|
"""
|
|
parts = spec.split("/")
|
|
# One extra segment is a host; two is not a repository any spelling
|
|
# of gh's format reaches.
|
|
if len(parts) not in (2, 3) or not all(parts[-2:]):
|
|
raise ValueError(
|
|
f'expected the "[HOST/]OWNER/REPO" format, got "{spec}"')
|
|
return RepoRef(owner=parts[-2], repo=parts[-1])
|
|
|
|
|
|
async def login(config: GhConfig) -> str:
|
|
"""The authenticated account's login name.
|
|
|
|
Args:
|
|
config (GhConfig): the install's configuration.
|
|
|
|
Returns:
|
|
str: the login, empty when the account reports none.
|
|
"""
|
|
me = await github_request(config.token,
|
|
"GET",
|
|
"/user",
|
|
base_url=config.base_url)
|
|
name = me.get("login") if isinstance(me, dict) else None
|
|
return name if isinstance(name, str) else ""
|
|
|
|
|
|
async def view_repo(config: GhConfig, ref: RepoRef) -> JsonValue:
|
|
return await github_request(config.token,
|
|
"GET",
|
|
f"/repos/{ref.owner}/{ref.repo}",
|
|
base_url=config.base_url)
|
|
|
|
|
|
async def read_readme(config: GhConfig, ref: RepoRef) -> str | None:
|
|
"""The repository's README as text, or None when it has none.
|
|
|
|
Args:
|
|
config (GhConfig): the install's configuration.
|
|
ref (RepoRef): the repository.
|
|
|
|
Returns:
|
|
str | None: the decoded README, None when the repo has none.
|
|
"""
|
|
try:
|
|
data = await github_request(config.token,
|
|
"GET",
|
|
f"/repos/{ref.owner}/{ref.repo}/readme",
|
|
base_url=config.base_url)
|
|
except GitHubApiError as exc:
|
|
if exc.status == 404:
|
|
return None
|
|
raise
|
|
if not isinstance(data, dict):
|
|
return None
|
|
content = data.get("content")
|
|
if not isinstance(content, str):
|
|
return None
|
|
return base64.b64decode(content).decode("utf-8", "replace")
|
|
|
|
|
|
async def fork_repo(config: GhConfig,
|
|
ref: RepoRef,
|
|
name: str | None = None) -> JsonValue:
|
|
body: JsonValue = {} if name is None else {"name": name}
|
|
return await github_request(config.token,
|
|
"POST",
|
|
f"/repos/{ref.owner}/{ref.repo}/forks",
|
|
body,
|
|
base_url=config.base_url)
|
|
|
|
|
|
async def rename_repo(config: GhConfig, ref: RepoRef, name: str) -> JsonValue:
|
|
return await github_request(config.token,
|
|
"PATCH",
|
|
f"/repos/{ref.owner}/{ref.repo}",
|
|
{"name": name},
|
|
base_url=config.base_url)
|