The file /Users/agent/strukto/mirage/mirage/backends/object_storage/s3.py has been updated successfully.

import re
from datetime import timezone

from pydantic import BaseModel, ConfigDict

from mirage.backends.base import BaseBackend
from mirage.backends.memory import _guess_type
from mirage.types import FileStat, FileType

class S3Config(BaseModel):
    model_config = ConfigDict(frozen=True)

    bucket: str
    region: str | None = None
    endpoint_url: str | None = None
    aws_access_key_id: str | None = None
    aws_secret_access_key: str | None = None
    aws_profile: str | None = None
    timeout: int = 30
    proxy: str | None = None
    select_threshold_bytes: int | None = 1 * 1024 * 1024  # 1 MB

class S3Backend(BaseBackend):
    """
    S3-compatible backend using boto3.

    Requires: pip install mirage[s3]

    Key design decisions:
    - read()  → GetObject with Range header (partial reads)
    - readdir() → list_objects_v2 with paginator (handles >1000 keys)
    - write()  → full PutObject (S3 has no partial write)
    - grep()   → S3 Select for large files, full fetch for small files
    - ls_l()   → list_objects_v2 already returns size+mtime, skip per-file stat
    """

    def __init__(self, config: S3Config) -> None:
        self.config = config
        self._client = self._make_client()

    def _make_client(self):
        try:
            import boto3
            from botocore.config import Config
        except ImportError:
            raise ImportError(
                "boto3 is required for S3Backend: pip install 'mirage[s3]'")

        kwargs: dict = {"service_name": "s3"}
        c = self.config
        if c.region:
            kwargs["region_name"] = c.region
        if c.endpoint_url:
            kwargs["endpoint_url"] = c.endpoint_url
        if c.aws_access_key_id and c.aws_secret_access_key:
            kwargs["aws_access_key_id"] = c.aws_access_key_id
            kwargs["aws_secret_access_key"] = c.aws_secret_access_key

        cfg_kwargs: dict = {
            "connect_timeout": c.timeout,
            "read_timeout": c.timeout
        }
        if c.proxy:
            cfg_kwargs["proxies"] = {"https": c.proxy, "http": c.proxy}

        kwargs["config"] = Config(**cfg_kwargs)

        if c.aws_profile:
            session = boto3.Session(profile_name=c.aws_profile)
            return session.client(**kwargs)
        return boto3.client(**kwargs)

    # ── key helpers ─────────────────────────────────────────────────────────

    def _key(self, path: str) -> str:
        """Convert virtual path to S3 key (strip leading slash)."""
        return path.lstrip("/")

    def _prefix(self, path: str) -> str:
        """Directory prefix — always ends with '/' unless root."""
        key = self._key(path)
        if key and not key.endswith("/"):
            key += "/"
        return key

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        end = offset + size - 1
        resp = self._client.get_object(
            Bucket=self.config.bucket,
            Key=self._key(path),
            Range=f"bytes={offset}-{end}",
        )
        return resp["Body"].read()

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        prefix = self._prefix(path)
        kwargs: dict = {
            "Bucket": self.config.bucket,
            "Prefix": prefix,
            "Delimiter": "/",
        }
        if cursor:
            kwargs["ContinuationToken"] = cursor

        resp = self._client.list_objects_v2(**kwargs)

        names: list[str] = []
        # common prefixes = subdirectories
        for cp in resp.get("CommonPrefixes") or []:
            p = cp["Prefix"]
            child = p.rstrip("/")
            if child:
                names.append(child)
        # object keys = files
        for obj in resp.get("Contents") or []:
            key = obj["Key"]
            relative = key[len(prefix):]
            if relative and "/" not in relative:  # direct children only
                names.append(key)

        next_cursor = resp.get("NextContinuationToken")
        return sorted(names), next_cursor

    def stat(self, path: str) -> FileStat:
        try:
            resp = self._client.head_object(Bucket=self.config.bucket,
                                            Key=self._key(path))
            modified = resp["LastModified"].astimezone(
                timezone.utc).isoformat()
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1],
                size=resp["ContentLength"],
                modified=modified,
                type=_guess_type(path),
                extra={"etag": resp.get("ETag", "").strip('"')},
            )
        except self._client.exceptions.NoSuchKey:
            pass
        except Exception as exc:
            if not (hasattr(exc, "response")
                    and exc.response.get("Error", {}).get("Code") == "404"):
                raise
        # key not found — check if it's a directory prefix
        prefix = self._prefix(path)
        resp = self._client.list_objects_v2(Bucket=self.config.bucket,
                                            Prefix=prefix,
                                            MaxKeys=1)
        if resp.get("Contents") or resp.get("CommonPrefixes"):
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1] or "/",
                size=None,
                type=FileType.DIRECTORY,
            )
        raise FileNotFoundError(path)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        # S3 has no partial write — we must do full replace
        if offset != 0:
            # fetch existing, splice, re-upload
            existing = self.cat(path)
            data = existing[:offset] + data + existing[offset + len(data):]
        self._client.put_object(Bucket=self.config.bucket,
                                Key=self._key(path),
                                Body=data)
        return len(data)

    def create(self, path: str) -> None:
        self._client.put_object(Bucket=self.config.bucket,
                                Key=self._key(path),
                                Body=b"")

    def unlink(self, path: str) -> None:
        self._client.delete_object(Bucket=self.config.bucket,
                                   Key=self._key(path))

    def rmdir(self, path: str) -> None:
        prefix = self._prefix(path)
        paginator = self._client.get_paginator("list_objects_v2")
        for page in paginator.paginate(Bucket=self.config.bucket,
                                       Prefix=prefix):
            keys = [{"Key": obj["Key"]} for obj in page.get("Contents") or []]
            if keys:
                self._client.delete_objects(Bucket=self.config.bucket,
                                            Delete={"Objects": keys})

    def mkdir(self, path: str) -> None:
        # S3 has no real directories — key prefix convention
        prefix = self._prefix(path)
        if prefix:
            self._client.put_object(Bucket=self.config.bucket,
                                    Key=prefix,
                                    Body=b"")

    def rename(self, src: str, dst: str) -> None:
        self._client.copy_object(
            Bucket=self.config.bucket,
            CopySource={
                "Bucket": self.config.bucket,
                "Key": self._key(src)
            },
            Key=self._key(dst),
        )
        self._client.delete_object(Bucket=self.config.bucket,
                                   Key=self._key(src))

    def truncate(self, path: str, size: int) -> None:
        data = self.cat(path)
        if size < len(data):
            data = data[:size]
        else:
            data = data + b"\x00" * (size - len(data))
        self._client.put_object(Bucket=self.config.bucket,
                                Key=self._key(path),
                                Body=data)

    # ── native overrides ────────────────────────────────────────────────────

    def ls_l(self, path: str) -> list[FileStat]:
        """list_objects_v2 returns size+mtime — skip per-file stat."""
        prefix = self._prefix(path)
        stats: list[FileStat] = []
        paginator = self._client.get_paginator("list_objects_v2")
        for page in paginator.paginate(Bucket=self.config.bucket,
                                       Prefix=prefix,
                                       Delimiter="/"):
            for cp in page.get("CommonPrefixes") or []:
                name = cp["Prefix"][len(prefix):].rstrip("/")
                stats.append(
                    FileStat(name=name, size=None, type=FileType.DIRECTORY))
            for obj in page.get("Contents") or []:
                key = obj["Key"]
                name = key[len(prefix):]
                if name and "/" not in name:
                    modified = obj["LastModified"].astimezone(
                        timezone.utc).isoformat()
                    stats.append(
                        FileStat(name=name,
                                 size=obj["Size"],
                                 modified=modified,
                                 type=_guess_type(name)))
        return stats

    def head(self, path: str, lines: int = 10) -> bytes:
        threshold = self.config.select_threshold_bytes
        if threshold is not None:
            try:
                s = self.stat(path)
                if s.size and s.size > threshold:
                    return self._head_select(path, lines)
            except FileNotFoundError:
                pass
        return super().head(path, lines)

    def _head_select(self, path: str, lines: int) -> bytes:
        sql = f"SELECT * FROM S3Object LIMIT {int(lines)}"
        try:
            resp = self._client.select_object_content(
                Bucket=self.config.bucket,
                Key=self._key(path),
                ExpressionType="SQL",
                Expression=sql,
                InputSerialization={
                    "CSV": {
                        "FileHeaderInfo": "NONE"
                    },
                    "CompressionType": "NONE"
                },
                OutputSerialization={"CSV": {}},
            )
            parts: list[str] = []
            for event in resp["Payload"]:
                if "Records" in event:
                    parts.extend(
                        event["Records"]["Payload"].decode().splitlines())
            return "\n".join(parts[:lines]).encode()
        except Exception:
            return super().head(path, lines)

    def presign(self, path: str, expires: int = 3600) -> str:
        return self._client.generate_presigned_url(
            "get_object",
            Params={
                "Bucket": self.config.bucket,
                "Key": self._key(path)
            },
            ExpiresIn=expires,
        )

    def grep(self, pattern: str, path: str, flags: int = 0) -> list[str]:
        """S3 Select for large files; full fetch for small files."""
        threshold = self.config.select_threshold_bytes
        if threshold is not None:
            try:
                s = self.stat(path)
                if s.size and s.size > threshold:
                    return self._grep_select(pattern, path)
            except FileNotFoundError:
                pass
        return super().grep(pattern, path, flags)

    def _grep_select(self, pattern: str, path: str) -> list[str]:
        """S3 Select LIKE filter — regex unsupported, re-filtered in Python."""
        escaped = (pattern.replace("'",
                                   "''").replace("%",
                                                 "\\%").replace("_", "\\_"))
        sql = f"SELECT * FROM S3Object WHERE _1 LIKE '%{escaped}%'"
        try:
            resp = self._client.select_object_content(
                Bucket=self.config.bucket,
                Key=self._key(path),
                ExpressionType="SQL",
                Expression=sql,
                InputSerialization={
                    "CSV": {
                        "FileHeaderInfo": "NONE"
                    },
                    "CompressionType": "NONE"
                },
                OutputSerialization={"CSV": {}},
            )
            lines: list[str] = []
            for event in resp["Payload"]:
                if "Records" in event:
                    lines.extend(
                        event["Records"]["Payload"].decode().splitlines())
            compiled = re.compile(pattern)
            return [line for line in lines if compiled.search(line)]
        except Exception:
            return super().grep(pattern, path)


Edit

from mirage.backends.s3 import S3Backend, S3Config

from mirage.backends.object_storage.s3 import S3Backend, S3Config


from mirage.backends.memory import _guess_type

from mirage.backends.local.memory import _guess_type


The file /Users/agent/strukto/mirage/mirage/backends/object_storage/r2.py has been updated successfully.

from pydantic import BaseModel, ConfigDict

from mirage.backends.s3 import S3Backend, S3Config

class R2Config(BaseModel):
    model_config = ConfigDict(frozen=True)

    account_id: str
    bucket: str
    access_key_id: str
    secret_access_key: str
    region: str = "auto"
    timeout: int = 30

class R2Backend(S3Backend):
    """
    Cloudflare R2 backend — extends S3Backend with auto-computed endpoint.

    Differences from S3Backend:
    - endpoint_url auto-computed from account_id
    - S3 Select DISABLED (R2 does not support SelectObjectContent)
    - region: R2 location hint (wnam, enam, weur, eeur, apac, oc, auto)

    Requires: pip install mirage[r2]
    """

    def __init__(self, config: R2Config) -> None:
        endpoint = f"https://{config.account_id}.r2.cloudflarestorage.com"
        s3_config = S3Config(
            bucket=config.bucket,
            region=config.region,
            endpoint_url=endpoint,
            aws_access_key_id=config.access_key_id,
            aws_secret_access_key=config.secret_access_key,
            timeout=config.timeout,
            select_threshold_bytes=None,  # R2 does not support S3 Select
        )
        super().__init__(s3_config)
        self._endpoint = endpoint

-from mirage.backends.s3 import S3Backend, S3Config

+from mirage.backends.object_storage.s3 import S3Backend, S3Config


The file /Users/agent/strukto/mirage/mirage/backends/network/http.py has been updated successfully.

from pydantic import BaseModel, ConfigDict, Field

from mirage.backends.base import BaseBackend
from mirage.backends.memory import _guess_type
from mirage.types import FileStat

class HTTPConfig(BaseModel):
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)

    base_url: str
    headers: dict[str, str] = Field(default_factory=dict)
    timeout: int = 30
    readdir_fn: object = None

class HTTPBackend(BaseBackend):
    """
    Read-only HTTP backend using httpx.

    Maps virtual paths to URLs: base_url + path
    e.g. base_url="https://data.example.com", path="/q1.json"
         → GET https://data.example.com/q1.json

    Requires: pip install mirage[http]

    Write ops (write, create, unlink, etc.) raise NotImplementedError —
    HTTP is read-only by design. Override to support writable HTTP APIs.

    readdir() requires a user-supplied `readdir_fn` since HTTP has no
    standard directory listing protocol.
    """

    def __init__(self, config: HTTPConfig) -> None:
        self.config = config
        self._client = self._make_client()

    def _make_client(self):
        try:
            import httpx
        except ImportError:
            raise ImportError(
                "httpx is required for HTTPBackend: pip install 'mirage[http]'"
            )
        return httpx.Client(
            base_url=self.config.base_url,
            headers=self.config.headers,
            timeout=self.config.timeout,
            follow_redirects=True,
        )

    def _url(self, path: str) -> str:
        return self.config.base_url.rstrip("/") + "/" + path.lstrip("/")

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        headers = {"Range": f"bytes={offset}-{offset + size - 1}"}
        resp = self._client.get(self._url(path), headers=headers)
        if resp.status_code == 416:
            return b""
        resp.raise_for_status()
        return resp.content

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        if self.config.readdir_fn is None:
            raise NotImplementedError(
                "HTTPBackend.readdir() requires a readdir_fn in HTTPConfig. "
                "Provide a callable(path) -> list[str] for your API.")
        names = self.config.readdir_fn(path)
        return names, None

    def stat(self, path: str) -> FileStat:
        resp = self._client.head(self._url(path))
        if resp.status_code == 404:
            raise FileNotFoundError(path)
        resp.raise_for_status()
        size = int(resp.headers.get("content-length", 0)) or None
        modified = resp.headers.get("last-modified")
        etag = resp.headers.get("etag", "").strip('"') or None
        extra = {"etag": etag} if etag else {}
        return FileStat(
            name=path.rstrip("/").rsplit("/", 1)[-1],
            size=size,
            modified=modified,
            type=_guess_type(path),
            extra=extra,
        )

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    # ── write ops — not supported ────────────────────────────────────────────

    def write(self, path: str, offset: int, data: bytes) -> int:
        raise NotImplementedError("HTTPBackend is read-only")

    def create(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def unlink(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def rmdir(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def mkdir(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def rename(self, src: str, dst: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def truncate(self, path: str, size: int) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

-from mirage.backends.memory import _guess_type

+from mirage.backends.local.memory import _guess_type


Internal backend imports done. Now update all external imports across the codebase. I'll use parallel agents to handle the many files efficiently.


Agent

Update memory imports everywhere

Prompt:
Update all imports of `from mirage.backends.memory import MemoryBackend` and `from mirage.backends.memory import _guess_type` across the entire codebase OUTSIDE of mirage/backends/ (those are already done).

The new import paths are:
- `from mirage.backends.local.memory import MemoryBackend`
- `from mirage.backends.local.memory import _guess_type`

Files that need updating (from grep results):
- mirage/workspace/__init__.py (has both MemoryBackend via BaseBackend AND _guess_type)
- mirage/__init__.py
- mirage/cli/config.py
- mirage/observe/inspector.py
- tests/conftest.py
- tests/fuse_ops/test_remote.py
- tests/fuse_ops/test_default.py
- tests/fuse_ops/test_format_ops.py
- tests/fuse_ops/test_registry.py (multiple occurrences, some inside functions)
- tests/commands/test_init.py
- tests/runtime/test_memory.py
- tests/runtime/test_accessors.py
- tests/runtime/test_runtime.py
- tests/server/test_registry.py
- tests/server/test_arg_resolver.py
- tests/workspace/test_mount.py
- tests/workspace/test_write_ops.py
- tests/workspace/test_sync.py
- tests/workspace/test_init.py
- tests/workspace/test_lock.py
- tests/integration/test_remote_transfer.py
- tests/backends/test_loader.py (has both import AND a string literal with the old import path)
- tests/cli/test_config.py
- fuse/tests/test_fs.py
- fuse/README.md (example code)
- examples/claude-memory.py
- examples/claude-sdk.py

For each file:
1. Read the file
2. Use Edit with old_string/new_string to replace `from mirage.backends.memory import` with `from mirage.backends.local.memory import`
3. For tests/backends/test_loader.py, also update the string literal inside write_text()

Use replace_all=true for the Edit tool since we want to replace ALL occurrences in each file.

IMPORTANT: Just do the edits, don't read files unnecessarily - use replace_all=true on each file to replace the pattern.


Update s3/r2/http/proxy imports

Prompt:
Update all imports for s3, r2, http, and proxy backends across the codebase OUTSIDE of mirage/backends/ directory (those are already done).

The new import paths are:
- `from mirage.backends.s3 import ...` → `from mirage.backends.object_storage.s3 import ...`
- `from mirage.backends.r2 import ...` → `from mirage.backends.object_storage.r2 import ...`
- `from mirage.backends.http import ...` → `from mirage.backends.network.http import ...`
- `from mirage.backends.proxy import ...` → `from mirage.backends.network.proxy import ...`

Files that need updating:

**S3 imports:**
- mirage/cli/config.py: `from mirage.backends.s3 import S3Backend, S3Config` → `from mirage.backends.object_storage.s3 import S3Backend, S3Config`

**R2 imports:**
- mirage/cli/config.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- examples/r2.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- tests/backends/test_configs.py: `from mirage.backends.r2 import R2Config` → `from mirage.backends.object_storage.r2 import R2Config`

**S3Config in tests:**
- tests/backends/test_configs.py: `from mirage.backends.s3 import S3Config` → `from mirage.backends.object_storage.s3 import S3Config`

**HTTP imports:**
- mirage/cli/config.py: `from mirage.backends.http import HTTPBackend, HTTPConfig` → `from mirage.backends.network.http import HTTPBackend, HTTPConfig`

**Proxy imports:**
- mirage/runtime/remote.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- mirage/cli/commands/workspace.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- tests/backends/test_proxy.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`

**Local import (the file that imports LocalBackend):**
- mirage/runtime/local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- mirage/cli/config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/commands/test_read.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/backends/test_local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/cli/test_config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/fuse_ops/test_registry.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`

For each file, read it first, then use the Edit tool to make the replacement. Use replace_all=true when appropriate.


All edits are complete. Here is a summary of every change made:

**`/Users/agent/strukto/mirage/mirage/cli/config.py`** (4 changes):
- `from mirage.backends.local import LocalBackend` -> `from mirage.backends.local.local import LocalBackend`
- `from mirage.backends.s3 import S3Backend, S3Config` -> `from mirage.backends.object_storage.s3 import S3Backend, S3Config`
- `from mirage.backends.r2 import R2Backend, R2Config` -> `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- `from mirage.backends.http import HTTPBackend, HTTPConfig` -> `from mirage.backends.network.http import HTTPBackend, HTTPConfig`

**`/Users/agent/strukto/mirage/examples/r2.py`**:
- `from mirage.backends.r2 import R2Backend, R2Config` -> `from mirage.backends.object_storage.r2 import R2Backend, R2Config`

**`/Users/agent/strukto/mirage/tests/backends/test_configs.py`** (2 changes):
- `from mirage.backends.r2 import R2Config` -> `from mirage.backends.object_storage.r2 import R2Config`
- `from mirage.backends.s3 import S3Config` -> `from mirage.backends.object_storage.s3 import S3Config`

**`/Users/agent/strukto/mirage/mirage/runtime/remote.py`**:
- `from mirage.backends.proxy import ProxyBackend` -> `from mirage.backends.network.proxy import ProxyBackend`

**`/Users/agent/strukto/mirage/mirage/cli/commands/workspace.py`**:
- `from mirage.backends.proxy import ProxyBackend` -> `from mirage.backends.network.proxy import ProxyBackend`

**`/Users/agent/strukto/mirage/tests/backends/test_proxy.py`**:
- `from mirage.backends.proxy import ProxyBackend` -> `from mirage.backends.network.proxy import ProxyBackend`

**`/Users/agent/strukto/mirage/mirage/runtime/local.py`**:
- `from mirage.backends.local import LocalBackend` -> `from mirage.backends.local.local import LocalBackend`

**`/Users/agent/strukto/mirage/tests/commands/test_read.py`**:
- `from mirage.backends.local import LocalBackend` -> `from mirage.backends.local.local import LocalBackend`

**`/Users/agent/strukto/mirage/tests/backends/test_local.py`**:
- `from mirage.backends.local import LocalBackend` -> `from mirage.backends.local.local import LocalBackend`

**`/Users/agent/strukto/mirage/tests/cli/test_config.py`**:
- `from mirage.backends.local import LocalBackend` -> `from mirage.backends.local.local import LocalBackend`

**`/Users/agent/strukto/mirage/tests/fuse_ops/test_registry.py`**:
- `from mirage.backends.local import LocalBackend` -> `from mirage.backends.local.local import LocalBackend`

agentId: a50b6cc38a28e8af1 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 38017
tool_uses: 25
duration_ms: 52333</usage>

Prompt:
Update all imports for s3, r2, http, and proxy backends across the codebase OUTSIDE of mirage/backends/ directory (those are already done).

The new import paths are:
- `from mirage.backends.s3 import ...` → `from mirage.backends.object_storage.s3 import ...`
- `from mirage.backends.r2 import ...` → `from mirage.backends.object_storage.r2 import ...`
- `from mirage.backends.http import ...` → `from mirage.backends.network.http import ...`
- `from mirage.backends.proxy import ...` → `from mirage.backends.network.proxy import ...`

Files that need updating:

**S3 imports:**
- mirage/cli/config.py: `from mirage.backends.s3 import S3Backend, S3Config` → `from mirage.backends.object_storage.s3 import S3Backend, S3Config`

**R2 imports:**
- mirage/cli/config.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- examples/r2.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- tests/backends/test_configs.py: `from mirage.backends.r2 import R2Config` → `from mirage.backends.object_storage.r2 import R2Config`

**S3Config in tests:**
- tests/backends/test_configs.py: `from mirage.backends.s3 import S3Config` → `from mirage.backends.object_storage.s3 import S3Config`

**HTTP imports:**
- mirage/cli/config.py: `from mirage.backends.http import HTTPBackend, HTTPConfig` → `from mirage.backends.network.http import HTTPBackend, HTTPConfig`

**Proxy imports:**
- mirage/runtime/remote.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- mirage/cli/commands/workspace.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- tests/backends/test_proxy.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`

**Local import (the file that imports LocalBackend):**
- mirage/runtime/local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- mirage/cli/config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/commands/test_read.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/backends/test_local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/cli/test_config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/fuse_ops/test_registry.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`

For each file, read it first, then use the Edit tool to make the replacement. Use replace_all=true when appropriate.


Only `mirage/backends/__init__.py` remains, which is inside `mirage/backends/` and was already handled per the task instructions (excluded from this task).

All imports have been successfully updated. Here is a summary of every file changed:

**Source files:**
- `/Users/agent/strukto/mirage/mirage/workspace/__init__.py` — `_guess_type` import updated
- `/Users/agent/strukto/mirage/mirage/__init__.py` — `MemoryBackend` import updated
- `/Users/agent/strukto/mirage/mirage/cli/config.py` — `MemoryBackend` import updated
- `/Users/agent/strukto/mirage/mirage/observe/inspector.py` — `MemoryBackend` import updated

**Test files (all `from mirage.backends.memory import` → `from mirage.backends.local.memory import`):**
- `/Users/agent/strukto/mirage/tests/conftest.py`
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_remote.py`
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_default.py`
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_format_ops.py`
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_registry.py` (4 occurrences, including inline imports inside functions)
- `/Users/agent/strukto/mirage/tests/commands/test_init.py`
- `/Users/agent/strukto/mirage/tests/runtime/test_memory.py`
- `/Users/agent/strukto/mirage/tests/runtime/test_accessors.py`
- `/Users/agent/strukto/mirage/tests/runtime/test_runtime.py`
- `/Users/agent/strukto/mirage/tests/server/test_registry.py`
- `/Users/agent/strukto/mirage/tests/server/test_arg_resolver.py`
- `/Users/agent/strukto/mirage/tests/workspace/test_mount.py`
- `/Users/agent/strukto/mirage/tests/workspace/test_write_ops.py`
- `/Users/agent/strukto/mirage/tests/workspace/test_sync.py`
- `/Users/agent/strukto/mirage/tests/workspace/test_init.py`
- `/Users/agent/strukto/mirage/tests/workspace/test_lock.py`
- `/Users/agent/strukto/mirage/tests/integration/test_remote_transfer.py`
- `/Users/agent/strukto/mirage/tests/backends/test_loader.py` (both the real import and the string literal in `write_text()`)
- `/Users/agent/strukto/mirage/tests/cli/test_config.py`
- `/Users/agent/strukto/mirage/tests/observe/test_inspector.py` (found during verification, not in original list)

**Fuse and examples:**
- `/Users/agent/strukto/mirage/fuse/tests/test_fs.py`
- `/Users/agent/strukto/mirage/fuse/README.md` (example code block)
- `/Users/agent/strukto/mirage/examples/claude-memory.py`
- `/Users/agent/strukto/mirage/examples/claude-sdk.py`

agentId: a14059b9c227baa4b (for resuming to continue this agent's work if needed)
<usage>total_tokens: 77001
tool_uses: 87
duration_ms: 129587</usage>

Prompt:
Update all imports of `from mirage.backends.memory import MemoryBackend` and `from mirage.backends.memory import _guess_type` across the entire codebase OUTSIDE of mirage/backends/ (those are already done).

The new import paths are:
- `from mirage.backends.local.memory import MemoryBackend`
- `from mirage.backends.local.memory import _guess_type`

Files that need updating (from grep results):
- mirage/workspace/__init__.py (has both MemoryBackend via BaseBackend AND _guess_type)
- mirage/__init__.py
- mirage/cli/config.py
- mirage/observe/inspector.py
- tests/conftest.py
- tests/fuse_ops/test_remote.py
- tests/fuse_ops/test_default.py
- tests/fuse_ops/test_format_ops.py
- tests/fuse_ops/test_registry.py (multiple occurrences, some inside functions)
- tests/commands/test_init.py
- tests/runtime/test_memory.py
- tests/runtime/test_accessors.py
- tests/runtime/test_runtime.py
- tests/server/test_registry.py
- tests/server/test_arg_resolver.py
- tests/workspace/test_mount.py
- tests/workspace/test_write_ops.py
- tests/workspace/test_sync.py
- tests/workspace/test_init.py
- tests/workspace/test_lock.py
- tests/integration/test_remote_transfer.py
- tests/backends/test_loader.py (has both import AND a string literal with the old import path)
- tests/cli/test_config.py
- fuse/tests/test_fs.py
- fuse/README.md (example code)
- examples/claude-memory.py
- examples/claude-sdk.py

For each file:
1. Read the file
2. Use Edit with old_string/new_string to replace `from mirage.backends.memory import` with `from mirage.backends.local.memory import`
3. For tests/backends/test_loader.py, also update the string literal inside write_text()

Use replace_all=true for the Edit tool since we want to replace ALL occurrences in each file.

IMPORTANT: Just do the edits, don't read files unnecessarily - use replace_all=true on each file to replace the pattern.


Now update the top-level `backends/__init__.py`.


TodoWrite

Grep all import patterns for backends across codebase

Create subdirectories and move files with git mv

Creating subdirectories and moving files

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Run pre-commit and tests


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Grep all import patterns for backends across codebase

Create subdirectories and move files with git mv

Creating subdirectories and moving files

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Run pre-commit and tests

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Run pre-commit and tests


     1→from mirage.backends.base import BaseBackend
     2→from mirage.backends.local import LocalBackend
     3→from mirage.backends.memory import MemoryBackend
     4→from mirage.backends.proxy import ProxyBackend
     5→
     6→__all__ = ["BaseBackend", "LocalBackend", "MemoryBackend", "ProxyBackend"]
     7→
     8→# S3Backend, R2Backend, HTTPBackend imported lazily to avoid hard deps
     9→

from mirage.backends.base import BaseBackend
from mirage.backends.local import LocalBackend
from mirage.backends.memory import MemoryBackend
from mirage.backends.proxy import ProxyBackend

__all__ = ["BaseBackend", "LocalBackend", "MemoryBackend", "ProxyBackend"]

# S3Backend, R2Backend, HTTPBackend imported lazily to avoid hard deps


Write

from mirage.backends.base import BaseBackend
from mirage.backends.local import LocalBackend, MemoryBackend
from mirage.backends.network import ProxyBackend

__all__ = ["BaseBackend", "LocalBackend", "MemoryBackend", "ProxyBackend"]


The file /Users/agent/strukto/mirage/mirage/backends/__init__.py has been updated successfully.

from mirage.backends.base import BaseBackend
from mirage.backends.local import LocalBackend, MemoryBackend
from mirage.backends.network import ProxyBackend

__all__ = ["BaseBackend", "LocalBackend", "MemoryBackend", "ProxyBackend"]

-from mirage.backends.local import LocalBackend

-from mirage.backends.memory import MemoryBackend

-from mirage.backends.proxy import ProxyBackend

+from mirage.backends.local import LocalBackend, MemoryBackend

+from mirage.backends.network import ProxyBackend

-# S3Backend, R2Backend, HTTPBackend imported lazily to avoid hard deps

from mirage.backends.base import BaseBackend
from mirage.backends.local import LocalBackend
from mirage.backends.memory import MemoryBackend
from mirage.backends.proxy import ProxyBackend

__all__ = ["BaseBackend", "LocalBackend", "MemoryBackend", "ProxyBackend"]

# S3Backend, R2Backend, HTTPBackend imported lazily to avoid hard deps


TodoWrite

Grep all import patterns for backends across codebase

Create subdirectories and move files with git mv

Creating subdirectories and moving files

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Verify no stale imports remain

Run pre-commit and tests


Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Run pre-commit and tests

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Verify no stale imports remain

Run pre-commit and tests


No matches found


Grep

from mirage\.backends\.local import LocalBackend


mirage/__init__.py:1:from mirage.backends.local import LocalBackend
mirage/backends/__init__.py:2:from mirage.backends.local import LocalBackend, MemoryBackend


Good - the remaining `from mirage.backends.local import LocalBackend` are in:
1. `mirage/__init__.py` - this is fine because `mirage.backends.local` is now a package with `__init__.py` that exports `LocalBackend`
2. `mirage/backends/__init__.py` - same, this works via the local package's `__init__.py`

These are correct since `mirage.backends.local` is now a package that re-exports `LocalBackend` from its `__init__.py`. Let me verify the mirage/__init__.py to make sure it looks right.


Those are correct — `mirage.backends.local` is now a package whose `__init__.py` exports `LocalBackend` and `MemoryBackend`. Let me also check `mirage/__init__.py`.


     1→from mirage.backends.local import LocalBackend
     2→from mirage.backends.local.memory import MemoryBackend
     3→from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
     4→                          SyncPolicy, SyncResult)
     5→from mirage.workspace import Workspace
     6→
     7→__all__ = [
     8→    "Workspace",
     9→    "MemoryBackend",
    10→    "LocalBackend",
    11→    "ConsistencyPolicy",
    12→    "FileStat",
    13→    "Inode",
    14→    "MountMode",
    15→    "SyncPolicy",
    16→    "SyncResult",
    17→]
    18→

from mirage.backends.local import LocalBackend
from mirage.backends.local.memory import MemoryBackend
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult)
from mirage.workspace import Workspace

__all__ = [
    "Workspace",
    "MemoryBackend",
    "LocalBackend",
    "ConsistencyPolicy",
    "FileStat",
    "Inode",
    "MountMode",
    "SyncPolicy",
    "SyncResult",
]


Edit

from mirage.backends.local import LocalBackend
from mirage.backends.local.memory import MemoryBackend

from mirage.backends.local import LocalBackend, MemoryBackend


The file /Users/agent/strukto/mirage/mirage/__init__.py has been updated successfully.

from mirage.backends.local import LocalBackend, MemoryBackend

from mirage.backends.local import LocalBackend
from mirage.backends.local.memory import MemoryBackend
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult)
from mirage.workspace import Workspace

__all__ = [
    "Workspace",
    "MemoryBackend",
    "LocalBackend",
    "ConsistencyPolicy",
    "FileStat",
    "Inode",
    "MountMode",
    "SyncPolicy",
    "SyncResult",
]

-from mirage.backends.local.memory import MemoryBackend


TodoWrite

Grep all import patterns for backends across codebase

Create subdirectories and move files with git mv

Creating subdirectories and moving files

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Verify no stale imports remain

Run pre-commit and tests


Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Verify no stale imports remain

Run pre-commit and tests

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Verify no stale imports remain

Run pre-commit and tests


Bash

Run tests


mirage/runtime/remote.py                                   47      1    98%   58
mirage/server/__init__.py                                   0      0   100%
mirage/server/constants.py                                  9      0   100%
mirage/server/fastapi/__init__.py                           0      0   100%
mirage/server/fastapi/app.py                               14      0   100%
mirage/server/fastapi/config.py                            60      0   100%
mirage/server/fastapi/daemon.py                            22     22     0%   1-31
mirage/server/fastapi/routers/__init__.py                   0      0   100%
mirage/server/fastapi/routers/command_router.py            65      2    97%   25, 59
mirage/server/fastapi/routers/io_router.py                110     33    70%   22-25, 35-38, 59-60, 69-70, 79-82, 91-92, 98-105, 113-116, 125-126, 136
mirage/server/fastapi/routers/workspace_router.py          60     10    83%   23-26, 83-88
mirage/server/routes.py                                    27      0   100%
mirage/server/service/__init__.py                           0      0   100%
mirage/server/service/base.py                               7      0   100%
mirage/server/service/cache.py                             24      1    96%   22
mirage/server/service/local.py                            260     15    94%   56, 92-93, 107, 124, 205, 225-227, 338-340, 372, 389-390
mirage/types.py                                            90      0   100%
mirage/workspace/__init__.py                              241     16    93%   88, 150-151, 162, 170, 172, 176, 219-220, 223-224, 272-275, 333
mirage/workspace/cache/__init__.py                          3      0   100%
mirage/workspace/cache/entry.py                             7      0   100%
mirage/workspace/cache/policy.py                           26      3    88%   24-25, 37
mirage/workspace/cache/store.py                            45      1    98%   14
mirage/workspace/mount.py                                  34      1    97%   63
mirage/workspace/sync.py                                   56      0   100%
-------------------------------------------------------------------------------------
TOTAL                                                    4414   1104    75%
=========================== short test summary info ============================
FAILED tests/backends/test_loader.py::test_load_from_module - ModuleNotFoundE...
FAILED tests/backends/test_loader.py::test_load_missing_class - ModuleNotFoun...
2 failed, 607 passed, 1 skipped in 2.09s


2 test failures in `test_loader.py`. Let me check what's going on.


     1→import pytest
     2→
     3→from mirage.backends.loader import load_backend_class
     4→
     5→
     6→def test_load_from_module():
     7→    cls = load_backend_class("mirage.backends.memory:MemoryBackend")
     8→    from mirage.backends.local.memory import MemoryBackend
     9→    assert cls is MemoryBackend
    10→
    11→
    12→def test_load_from_script_file(tmp_path):
    13→    script = tmp_path / "custom_backend.py"
    14→    script.write_text("from mirage.backends.local.memory import MemoryBackend\n"
    15→                      "class CustomBackend(MemoryBackend):\n"
    16→                      "    pass\n")
    17→    cls = load_backend_class(f"{script}:CustomBackend")
    18→    assert cls.__name__ == "CustomBackend"
    19→    instance = cls()
    20→    instance.put("/test.txt", b"hello")
    21→    assert instance.cat("/test.txt") == b"hello"
    22→
    23→
    24→def test_load_invalid_spec_no_colon():
    25→    with pytest.raises(ValueError, match="invalid backend spec"):
    26→        load_backend_class("mirage.backends.memory")
    27→
    28→
    29→def test_load_missing_file():
    30→    with pytest.raises(ValueError, match="cannot load script"):
    31→        load_backend_class("/nonexistent/path.py:Cls")
    32→
    33→
    34→def test_load_missing_class():
    35→    with pytest.raises(ValueError, match="not found"):
    36→        load_backend_class("mirage.backends.memory:DoesNotExist")
    37→

import pytest

from mirage.backends.loader import load_backend_class

def test_load_from_module():
    cls = load_backend_class("mirage.backends.memory:MemoryBackend")
    from mirage.backends.local.memory import MemoryBackend
    assert cls is MemoryBackend

def test_load_from_script_file(tmp_path):
    script = tmp_path / "custom_backend.py"
    script.write_text("from mirage.backends.local.memory import MemoryBackend\n"
                      "class CustomBackend(MemoryBackend):\n"
                      "    pass\n")
    cls = load_backend_class(f"{script}:CustomBackend")
    assert cls.__name__ == "CustomBackend"
    instance = cls()
    instance.put("/test.txt", b"hello")
    assert instance.cat("/test.txt") == b"hello"

def test_load_invalid_spec_no_colon():
    with pytest.raises(ValueError, match="invalid backend spec"):
        load_backend_class("mirage.backends.memory")

def test_load_missing_file():
    with pytest.raises(ValueError, match="cannot load script"):
        load_backend_class("/nonexistent/path.py:Cls")

def test_load_missing_class():
    with pytest.raises(ValueError, match="not found"):
        load_backend_class("mirage.backends.memory:DoesNotExist")


Lines 7 and 36 still use the old module path `mirage.backends.memory` as a string argument (not an import). These are dotpaths passed to the loader.


The file /Users/agent/strukto/mirage/tests/backends/test_loader.py has been updated successfully.

import pytest

from mirage.backends.loader import load_backend_class

def test_load_from_module():
    cls = load_backend_class("mirage.backends.memory:MemoryBackend")
    from mirage.backends.local.memory import MemoryBackend
    assert cls is MemoryBackend

def test_load_from_script_file(tmp_path):
    script = tmp_path / "custom_backend.py"
    script.write_text("from mirage.backends.local.memory import MemoryBackend\n"
                      "class CustomBackend(MemoryBackend):\n"
                      "    pass\n")
    cls = load_backend_class(f"{script}:CustomBackend")
    assert cls.__name__ == "CustomBackend"
    instance = cls()
    instance.put("/test.txt", b"hello")
    assert instance.cat("/test.txt") == b"hello"

def test_load_invalid_spec_no_colon():
    with pytest.raises(ValueError, match="invalid backend spec"):
        load_backend_class("mirage.backends.memory")

def test_load_missing_file():
    with pytest.raises(ValueError, match="cannot load script"):
        load_backend_class("/nonexistent/path.py:Cls")

def test_load_missing_class():
    with pytest.raises(ValueError, match="not found"):
        load_backend_class("mirage.backends.memory:DoesNotExist")

-    cls = load_backend_class("mirage.backends.memory:MemoryBackend")

+    cls = load_backend_class("mirage.backends.local.memory:MemoryBackend")


import pytest

from mirage.backends.loader import load_backend_class

def test_load_from_module():
    cls = load_backend_class("mirage.backends.local.memory:MemoryBackend")
    from mirage.backends.local.memory import MemoryBackend
    assert cls is MemoryBackend

def test_load_from_script_file(tmp_path):
    script = tmp_path / "custom_backend.py"
    script.write_text("from mirage.backends.local.memory import MemoryBackend\n"
                      "class CustomBackend(MemoryBackend):\n"
                      "    pass\n")
    cls = load_backend_class(f"{script}:CustomBackend")
    assert cls.__name__ == "CustomBackend"
    instance = cls()
    instance.put("/test.txt", b"hello")
    assert instance.cat("/test.txt") == b"hello"

def test_load_invalid_spec_no_colon():
    with pytest.raises(ValueError, match="invalid backend spec"):
        load_backend_class("mirage.backends.memory")

def test_load_missing_file():
    with pytest.raises(ValueError, match="cannot load script"):
        load_backend_class("/nonexistent/path.py:Cls")

def test_load_missing_class():
    with pytest.raises(ValueError, match="not found"):
        load_backend_class("mirage.backends.memory:DoesNotExist")

-        load_backend_class("mirage.backends.memory:DoesNotExist")

+        load_backend_class("mirage.backends.local.memory:DoesNotExist")


uv run pytest tests/backends/test_loader.py -v 2>&1

Rerun loader tests


============================= test session starts ==============================
platform darwin -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0
rootdir: /Users/agent/strukto/mirage
configfile: pyproject.toml
plugins: anyio-4.12.1, httpx-0.36.0, cov-7.0.0
collected 5 items

tests/backends/test_loader.py .....                                      [100%]

================================ tests coverage ================================
______________ coverage: platform darwin, python 3.12.10-final-0 _______________

Name                                                    Stmts   Miss  Cover   Missing
-------------------------------------------------------------------------------------
mirage/__init__.py                                          4      0   100%
mirage/backends/__init__.py                                 4      0   100%
mirage/backends/base.py                                    81     44    46%   16, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 58, 66, 70-77, 81, 87-89, 96-103, 107-115, 118
mirage/backends/loader.py                                  20      1    95%   30
mirage/backends/local/__init__.py                           3      0   100%
mirage/backends/local/local.py                             69     47    32%   20, 26-30, 33-41, 49-52, 57-65, 68-71, 74, 77, 80-84, 87-89, 92-93, 96-97, 100-101, 104-106, 109-110
mirage/backends/local/memory.py                           121     61    50%   42, 48-66, 71, 86, 89, 94, 98, 108, 114-118, 121-131, 134-140, 143-158, 163, 166, 174-175
mirage/backends/network/__init__.py                         2      0   100%
mirage/backends/network/http.py                            62     62     0%   1-119
mirage/backends/network/proxy.py                           51     31    39%   16-18, 21-29, 32-33, 38-39, 42-43, 46, 49, 52-58, 61, 64, 67, 70, 73, 76, 79-80
mirage/backends/object_storage/__init__.py                  1      1     0%   1
mirage/backends/object_storage/r2.py                       16     16     0%   1-41
mirage/backends/object_storage/s3.py                      184    184     0%   1-341
mirage/cli/__init__.py                                      0      0   100%
mirage/cli/app.py                                          20     20     0%   1-30
mirage/cli/client.py                                       97     97     0%   1-163
mirage/cli/commands/__init__.py                             0      0   100%
mirage/cli/commands/command.py                             64     64     0%   1-111
mirage/cli/commands/observe.py                             66     66     0%   1-98
mirage/cli/commands/server.py                              12     12     0%   1-20
mirage/cli/commands/workspace.py                          188    188     0%   1-316
mirage/cli/config.py                                      125    125     0%   1-183
mirage/cli/helpers.py                                      30     30     0%   1-41
mirage/commands/__init__.py                                 2      2     0%   1-3
mirage/commands/builtin/__init__.py                         0      0   100%
mirage/commands/builtin/handlers.py                       437    437     0%   1-640
mirage/commands/builtin/nav.py                            112    112     0%   3-187
mirage/commands/builtin/net.py                             16     16     0%   4-37
mirage/commands/builtin/read.py                            55     55     0%   3-97
mirage/commands/builtin/search.py                         159    159     0%   3-274
mirage/commands/builtin/write.py                           47     47     0%   3-82
mirage/commands/custom/__init__.py                          0      0   100%
mirage/commands/custom/arg_resolver.py                     68     68     0%   1-93
mirage/commands/custom/executor.py                         90     90     0%   1-116
mirage/commands/custom/registry.py                         72     72     0%   1-120
mirage/commands/remote/__init__.py                          0      0   100%
mirage/commands/remote/auth.py                             50     50     0%   1-83
mirage/commands/remote/client.py                           93     93     0%   1-188
mirage/commands/remote/fastapi/__init__.py                  0      0   100%
mirage/commands/remote/fastapi/app.py                      43     43     0%   1-64
mirage/commands/remote/grpc/__init__.py                     0      0   100%
mirage/commands/remote/grpc/proto/__init__.py               0      0   100%
mirage/commands/remote/grpc/proto/command_pb2.py           43     43     0%   6-59
mirage/commands/remote/grpc/proto/command_pb2_grpc.py      45     45     0%   3-182
mirage/commands/remote/grpc/server.py                      43     43     0%   1-74
mirage/fuse_ops/__init__.py                                 9      9     0%   1-18
mirage/fuse_ops/base.py                                    34     34     0%   1-48
mirage/fuse_ops/csv_op.py                                  26     26     0%   1-48
mirage/fuse_ops/default.py                                  3      3     0%   1-6
mirage/fuse_ops/loader.py                                  38     38     0%   1-52
mirage/fuse_ops/local.py                                    5      5     0%   1-9
mirage/fuse_ops/memory.py                                   9      9     0%   1-14
mirage/fuse_ops/parquet.py                                 33     33     0%   1-52
mirage/fuse_ops/registry.py                                32     32     0%   1-53
mirage/fuse_ops/remote.py                                  20     20     0%   1-38
mirage/fuse_ops/s3.py                                       5      5     0%   1-9
mirage/observe/__init__.py                                  3      3     0%   1-4
mirage/observe/entries.py                                  56     56     0%   1-74
mirage/observe/fastapi/__init__.py                          0      0   100%
mirage/observe/fastapi/router.py                           59     59     0%   1-98
mirage/observe/inspector.py                                78     78     0%   1-177
mirage/observe/session_log.py                              46     46     0%   1-59
mirage/runtime/__init__.py                                  5      5     0%   1-6
mirage/runtime/accessors/__init__.py                        0      0   100%
mirage/runtime/accessors/base.py                            6      6     0%   1-29
mirage/runtime/accessors/local.py                          36     36     0%   1-59
mirage/runtime/accessors/remote.py                         40     40     0%   1-77
mirage/runtime/base.py                                     51     51     0%   1-102
mirage/runtime/local.py                                    11     11     0%   1-21
mirage/runtime/memory.py                                    7      7     0%   1-14
mirage/runtime/remote.py                                   47     47     0%   1-68
mirage/server/__init__.py                                   0      0   100%
mirage/server/constants.py                                  9      9     0%   1-14
mirage/server/fastapi/__init__.py                           0      0   100%
mirage/server/fastapi/app.py                               14     14     0%   1-18
mirage/server/fastapi/config.py                            60     60     0%   1-89
mirage/server/fastapi/daemon.py                            22     22     0%   1-31
mirage/server/fastapi/routers/__init__.py                   0      0   100%
mirage/server/fastapi/routers/command_router.py            65     65     0%   1-101
mirage/server/fastapi/routers/io_router.py                110    110     0%   1-139
mirage/server/fastapi/routers/workspace_router.py          60     60     0%   1-95
mirage/server/routes.py                                    27     27     0%   1-30
mirage/server/service/__init__.py                           0      0   100%
mirage/server/service/base.py                               7      7     0%   1-64
mirage/server/service/cache.py                             24     24     0%   1-33
mirage/server/service/local.py                            260    260     0%   1-429
mirage/types.py                                            90      0   100%
mirage/workspace/__init__.py                              241    199    17%   18-19, 22, 25, 58-78, 88, 91-92, 97-141, 144-177, 180-181, 186-196, 199-203, 206-207, 210-211, 214-220, 223-224, 229-237, 240-242, 245-252, 255-275, 278-281, 286-298, 301, 304-305, 310, 313-314, 317-319, 322-323, 329-333, 336-339, 342-344, 352-367
mirage/workspace/cache/__init__.py                          3      0   100%
mirage/workspace/cache/entry.py                             7      0   100%
mirage/workspace/cache/policy.py                           26     20    23%   15-26, 30-37
mirage/workspace/cache/store.py                            45     30    33%   8-14, 20-22, 25-28, 36-47, 50-52, 55-56, 60, 64, 67-69
mirage/workspace/mount.py                                  34     24    29%   12-16, 29, 36-41, 50-56, 59-63, 66
mirage/workspace/sync.py                                   56     41    27%   20, 25-33, 43-50, 60-63, 72-93, 102, 105, 108, 111, 115
-------------------------------------------------------------------------------------
TOTAL                                                    4414   4055     8%
============================== 5 passed in 0.15s ===============================


Now let me run the full suite to confirm everything passes.


Run full test suite


mirage/workspace/mount.py                                  34      1    97%   63
mirage/workspace/sync.py                                   56      0   100%
-------------------------------------------------------------------------------------
TOTAL                                                    4414   1103    75%
609 passed, 1 skipped in 1.88s


All 609 tests pass. Let me run pre-commit too.


Run pre-commit hooks


The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.

Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.


[Request interrupted by user for tool use]


Update all imports of `from mirage.backends.memory import MemoryBackend` and `from mirage.backends.memory import _guess_type` across the entire codebase OUTSIDE of mirage/backends/ (those are already done).

The new import paths are:
- `from mirage.backends.local.memory import MemoryBackend`
- `from mirage.backends.local.memory import _guess_type`

Files that need updating (from grep results):
- mirage/workspace/__init__.py (has both MemoryBackend via BaseBackend AND _guess_type)
- mirage/__init__.py
- mirage/cli/config.py
- mirage/observe/inspector.py
- tests/conftest.py
- tests/fuse_ops/test_remote.py
- tests/fuse_ops/test_default.py
- tests/fuse_ops/test_format_ops.py
- tests/fuse_ops/test_registry.py (multiple occurrences, some inside functions)
- tests/commands/test_init.py
- tests/runtime/test_memory.py
- tests/runtime/test_accessors.py
- tests/runtime/test_runtime.py
- tests/server/test_registry.py
- tests/server/test_arg_resolver.py
- tests/workspace/test_mount.py
- tests/workspace/test_write_ops.py
- tests/workspace/test_sync.py
- tests/workspace/test_init.py
- tests/workspace/test_lock.py
- tests/integration/test_remote_transfer.py
- tests/backends/test_loader.py (has both import AND a string literal with the old import path)
- tests/cli/test_config.py
- fuse/tests/test_fs.py
- fuse/README.md (example code)
- examples/claude-memory.py
- examples/claude-sdk.py

For each file:
1. Read the file
2. Use Edit with old_string/new_string to replace `from mirage.backends.memory import` with `from mirage.backends.local.memory import`
3. For tests/backends/test_loader.py, also update the string literal inside write_text()

Use replace_all=true for the Edit tool since we want to replace ALL occurrences in each file.

IMPORTANT: Just do the edits, don't read files unnecessarily - use replace_all=true on each file to replace the pattern.

Prompt:
Update all imports of `from mirage.backends.memory import MemoryBackend` and `from mirage.backends.memory import _guess_type` across the entire codebase OUTSIDE of mirage/backends/ (those are already done).

The new import paths are:
- `from mirage.backends.local.memory import MemoryBackend`
- `from mirage.backends.local.memory import _guess_type`

Files that need updating (from grep results):
- mirage/workspace/__init__.py (has both MemoryBackend via BaseBackend AND _guess_type)
- mirage/__init__.py
- mirage/cli/config.py
- mirage/observe/inspector.py
- tests/conftest.py
- tests/fuse_ops/test_remote.py
- tests/fuse_ops/test_default.py
- tests/fuse_ops/test_format_ops.py
- tests/fuse_ops/test_registry.py (multiple occurrences, some inside functions)
- tests/commands/test_init.py
- tests/runtime/test_memory.py
- tests/runtime/test_accessors.py
- tests/runtime/test_runtime.py
- tests/server/test_registry.py
- tests/server/test_arg_resolver.py
- tests/workspace/test_mount.py
- tests/workspace/test_write_ops.py
- tests/workspace/test_sync.py
- tests/workspace/test_init.py
- tests/workspace/test_lock.py
- tests/integration/test_remote_transfer.py
- tests/backends/test_loader.py (has both import AND a string literal with the old import path)
- tests/cli/test_config.py
- fuse/tests/test_fs.py
- fuse/README.md (example code)
- examples/claude-memory.py
- examples/claude-sdk.py

For each file:
1. Read the file
2. Use Edit with old_string/new_string to replace `from mirage.backends.memory import` with `from mirage.backends.local.memory import`
3. For tests/backends/test_loader.py, also update the string literal inside write_text()

Use replace_all=true for the Edit tool since we want to replace ALL occurrences in each file.

IMPORTANT: Just do the edits, don't read files unnecessarily - use replace_all=true on each file to replace the pattern.


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


Update all imports for s3, r2, http, and proxy backends across the codebase OUTSIDE of mirage/backends/ directory (those are already done).

The new import paths are:
- `from mirage.backends.s3 import ...` → `from mirage.backends.object_storage.s3 import ...`
- `from mirage.backends.r2 import ...` → `from mirage.backends.object_storage.r2 import ...`
- `from mirage.backends.http import ...` → `from mirage.backends.network.http import ...`
- `from mirage.backends.proxy import ...` → `from mirage.backends.network.proxy import ...`

Files that need updating:

**S3 imports:**
- mirage/cli/config.py: `from mirage.backends.s3 import S3Backend, S3Config` → `from mirage.backends.object_storage.s3 import S3Backend, S3Config`

**R2 imports:**
- mirage/cli/config.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- examples/r2.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- tests/backends/test_configs.py: `from mirage.backends.r2 import R2Config` → `from mirage.backends.object_storage.r2 import R2Config`

**S3Config in tests:**
- tests/backends/test_configs.py: `from mirage.backends.s3 import S3Config` → `from mirage.backends.object_storage.s3 import S3Config`

**HTTP imports:**
- mirage/cli/config.py: `from mirage.backends.http import HTTPBackend, HTTPConfig` → `from mirage.backends.network.http import HTTPBackend, HTTPConfig`

**Proxy imports:**
- mirage/runtime/remote.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- mirage/cli/commands/workspace.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- tests/backends/test_proxy.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`

**Local import (the file that imports LocalBackend):**
- mirage/runtime/local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- mirage/cli/config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/commands/test_read.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/backends/test_local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/cli/test_config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/fuse_ops/test_registry.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`

For each file, read it first, then use the Edit tool to make the replacement. Use replace_all=true when appropriate.

Prompt:
Update all imports for s3, r2, http, and proxy backends across the codebase OUTSIDE of mirage/backends/ directory (those are already done).

The new import paths are:
- `from mirage.backends.s3 import ...` → `from mirage.backends.object_storage.s3 import ...`
- `from mirage.backends.r2 import ...` → `from mirage.backends.object_storage.r2 import ...`
- `from mirage.backends.http import ...` → `from mirage.backends.network.http import ...`
- `from mirage.backends.proxy import ...` → `from mirage.backends.network.proxy import ...`

Files that need updating:

**S3 imports:**
- mirage/cli/config.py: `from mirage.backends.s3 import S3Backend, S3Config` → `from mirage.backends.object_storage.s3 import S3Backend, S3Config`

**R2 imports:**
- mirage/cli/config.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- examples/r2.py: `from mirage.backends.r2 import R2Backend, R2Config` → `from mirage.backends.object_storage.r2 import R2Backend, R2Config`
- tests/backends/test_configs.py: `from mirage.backends.r2 import R2Config` → `from mirage.backends.object_storage.r2 import R2Config`

**S3Config in tests:**
- tests/backends/test_configs.py: `from mirage.backends.s3 import S3Config` → `from mirage.backends.object_storage.s3 import S3Config`

**HTTP imports:**
- mirage/cli/config.py: `from mirage.backends.http import HTTPBackend, HTTPConfig` → `from mirage.backends.network.http import HTTPBackend, HTTPConfig`

**Proxy imports:**
- mirage/runtime/remote.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- mirage/cli/commands/workspace.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`
- tests/backends/test_proxy.py: `from mirage.backends.proxy import ProxyBackend` → `from mirage.backends.network.proxy import ProxyBackend`

**Local import (the file that imports LocalBackend):**
- mirage/runtime/local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- mirage/cli/config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/commands/test_read.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/backends/test_local.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/cli/test_config.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`
- tests/fuse_ops/test_registry.py: `from mirage.backends.local import LocalBackend` → `from mirage.backends.local.local import LocalBackend`

For each file, read it first, then use the Edit tool to make the replacement. Use replace_all=true when appropriate.


     1→import os
     2→
     3→import yaml
     4→
     5→from mirage.backends.base import BaseBackend
     6→from mirage.backends.local import LocalBackend
     7→from mirage.backends.memory import MemoryBackend
     8→from mirage.types import BackendType, ConsistencyPolicy, MountMode
     9→
    10→
    11→def _build_backend(type_name: str, connection: str) -> BaseBackend:
    12→    if type_name == BackendType.MEMORY:
    13→        return MemoryBackend()
    14→    if type_name == BackendType.LOCAL:
    15→        return LocalBackend(root=os.path.abspath(connection))
    16→    if type_name == BackendType.S3:
    17→        from mirage.backends.s3 import S3Backend, S3Config
    18→        rest = connection[len("s3://"):]
    19→        bucket = rest.split("/", 1)[0]
    20→        return S3Backend(S3Config(bucket=bucket))
    21→    if type_name == BackendType.R2:
    22→        from mirage.backends.r2 import R2Backend, R2Config
    23→        rest = connection[len("r2://"):]
    24→        parts = rest.split("/", 1)
    25→        return R2Backend(
    26→            R2Config(
    27→                account_id=parts[0],
    28→                bucket=parts[1] if len(parts) > 1 else "",
    29→                access_key_id=os.environ.get("R2_ACCESS_KEY_ID", ""),
    30→                secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY", ""),
    31→            ))
    32→    if type_name in (BackendType.HTTP, BackendType.HTTPS):
    33→        from mirage.backends.http import HTTPBackend, HTTPConfig
    34→        return HTTPBackend(HTTPConfig(base_url=connection))
    35→    raise ValueError(f"Unknown backend type: {type_name!r}. "
    36→                     "Use built-ins: memory, local, s3, r2, http")
    37→
    38→
    39→def parse_mount_uri(uri: str) -> BaseBackend:
    40→    if uri == BackendType.MEMORY:
    41→        return MemoryBackend()
    42→    if uri == BackendType.LOCAL:
    43→        return LocalBackend(root=os.path.abspath("."))
    44→
    45→    colon = uri.find(":")
    46→    if colon > 0 and "//" not in uri[:colon + 3]:
    47→        return _build_backend(uri[:colon], uri[colon + 1:])
    48→
    49→    if uri.startswith("s3://"):
    50→        return _build_backend("s3", uri)
    51→    if uri.startswith("r2://"):
    52→        return _build_backend("r2", uri)
    53→    if uri.startswith("http://") or uri.startswith("https://"):
    54→        return _build_backend("http", uri)
    55→
    56→    if "://" in uri:
    57→        scheme = uri.split("://")[0]
    58→        raise ValueError(
    59→            f"Unknown URI scheme {scheme!r}. "
    60→            "Accepted: s3://, r2://, https://. "
    61→            "For simple backends use: memory, local, local:./path")
    62→    return LocalBackend(root=os.path.abspath(uri))
    63→
    64→
    65→def split_mount_spec(spec: str) -> tuple[str, str]:
    66→    """Split 'backend:/path' into (backend, path).
    67→
    68→    Args:
    69→        spec (str): Mount spec like 'memory:/data/' or 's3://bucket:/data/'.
    70→
    71→    Returns:
    72→        tuple[str, str]: (backend, mount_path) pair.
    73→    """
    74→    i = len(spec) - 1
    75→    while i >= 0:
    76→        if spec[i] == ":" and i + 1 < len(spec) and spec[i + 1] == "/":
    77→            if i >= 2 and spec[i - 1:i + 2] == "://":
    78→                i -= 1
    79→                continue
    80→            return spec[:i], spec[i + 1:]
    81→        i -= 1
    82→    raise ValueError(
    83→        f"Invalid mount spec: {spec!r}. "
    84→        "Use format: backend:/path (e.g. memory:/data/, s3://bucket:/)")
    85→
    86→
    87→_MODE_SUFFIXES = ("@read", "@write", "@exec")
    88→
    89→
    90→def parse_mount_uri_with_mode(
    91→    uri: str,
    92→    default_mode: MountMode = MountMode.READ,
    93→) -> tuple[BaseBackend, MountMode]:
    94→    for suffix in _MODE_SUFFIXES:
    95→        if uri.endswith(suffix):
    96→            return parse_mount_uri(uri[:-len(suffix)]), MountMode(suffix[1:])
    97→    return parse_mount_uri(uri), default_mode
    98→
    99→
   100→_CONSISTENCY_SUFFIXES = ("#none", "#lazy", "#ttl", "#always")
   101→
   102→
   103→def parse_mount_uri_full(
   104→    uri: str,
   105→    default_mode: MountMode = MountMode.READ,
   106→    default_consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
   107→) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
   108→    consistency = default_consistency
   109→    for suffix in _CONSISTENCY_SUFFIXES:
   110→        if uri.endswith(suffix):
   111→            consistency = ConsistencyPolicy(suffix[1:])
   112→            uri = uri[:-len(suffix)]
   113→            break
   114→    backend, mode = parse_mount_uri_with_mode(uri, default_mode)
   115→    return backend, mode, consistency
   116→
   117→
   118→def parse_mounts_from_cli(
   119→    mount_specs: list[str],
   120→    default_mode: MountMode = MountMode.READ,
   121→) -> dict[str, BaseBackend | tuple[BaseBackend, MountMode]]:
   122→    mounts: dict[str, BaseBackend | tuple[BaseBackend, MountMode]] = {}
   123→    for spec in mount_specs:
   124→        uri, prefix = split_mount_spec(spec)
   125→        backend, mode = parse_mount_uri_with_mode(uri, default_mode)
   126→        if mode != default_mode:
   127→            mounts[prefix] = (backend, mode)
   128→        else:
   129→            mounts[prefix] = backend
   130→    return mounts
   131→
   132→
   133→def load_yaml_config(path: str) -> dict:
   134→    with open(path) as f:
   135→        return yaml.safe_load(f)
   136→
   137→
   138→def load_workspace_yaml(path: str) -> dict:
   139→    with open(path) as f:
   140→        data = yaml.safe_load(f)
   141→    mounts_raw = data.get("mounts", {})
   142→    mounts: dict[str, str | dict] = {}
   143→    for prefix, value in mounts_raw.items():
   144→        if isinstance(value, str):
   145→            mounts[prefix] = value
   146→        elif isinstance(value, dict):
   147→            backend_spec = value.get("backend")
   148→            if backend_spec:
   149→                entry: dict[str, str | dict] = {"backend": backend_spec}
   150→                if value.get("args"):
   151→                    entry["args"] = value["args"]
   152→                if value.get("mode"):
   153→                    entry["mode"] = value["mode"]
   154→                if value.get("consistency"):
   155→                    entry["consistency"] = value["consistency"]
   156→                mounts[prefix] = entry
   157→            else:
   158→                uri = value.get("uri", "")
   159→                mount_mode = value.get("mode")
   160→                mount_consistency = value.get("consistency")
   161→                entry_uri: dict[str, str] = {"uri": uri}
   162→                if mount_mode:
   163→                    entry_uri["mode"] = mount_mode
   164→                if mount_consistency:
   165→                    entry_uri["consistency"] = mount_consistency
   166→                if len(entry_uri) == 1:
   167→                    mounts[prefix] = uri
   168→                else:
   169→                    mounts[prefix] = entry_uri
   170→    result: dict = {"mounts": mounts, "commands": data.get("commands", {})}
   171→    if "consistency" in data:
   172→        result["consistency"] = data["consistency"]
   173→    if "ttl" in data:
   174→        result["ttl"] = data["ttl"]
   175→    if "agent_id" in data:
   176→        result["agent_id"] = data["agent_id"]
   177→    fuse_ops = data.get("fuse_ops", {})
   178→    if fuse_ops:
   179→        result["fuse_ops"] = fuse_ops
   180→    for key in ("runtime", "url", "ws_id", "command_url", "fuse_ops_url"):
   181→        if key in data:
   182→            result[key] = data[key]
   183→    return result
   184→


     1→"""
     2→Example: R2Backend + MemoryRuntime
     3→
     4→Demonstrates read commands against a real Cloudflare R2 bucket.
     5→Requires: uv sync --extra r2
     6→
     7→Credentials are loaded from .env.development (see .env.example).
     8→"""
     9→
    10→import os
    11→
    12→from dotenv import load_dotenv
    13→
    14→from mirage import MountMode, SyncPolicy, Workspace
    15→from mirage.backends.r2 import R2Backend, R2Config
    16→from mirage.runtime.memory import MemoryRuntime
    17→
    18→load_dotenv(".env.development")
    19→
    20→CHOSEN_PDF = os.environ.get("CHOSEN_PDF",
    21→                            "")  # set to a filename within Review/
    22→
    23→config = R2Config(
    24→    account_id=os.environ["R2_ACCOUNT_ID"],
    25→    bucket=os.environ["R2_BUCKET"],
    26→    access_key_id=os.environ["R2_ACCESS_KEY_ID"],
    27→    secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
    28→    region=os.environ.get("R2_REGION", "auto"),
    29→)
    30→
    31→ws = Workspace(
    32→    {"/r2/": R2Backend(config)},
    33→    sync_policy=SyncPolicy.IMMEDIATE,
    34→    mode=MountMode.READ,
    35→)
    36→
    37→rt = MemoryRuntime(ws)
    38→
    39→print("=== ls /r2/Review ===")
    40→print(rt.run("ls /r2/Review"))
    41→
    42→print("\n=== tree /r2/Review ===")
    43→print(rt.run("tree /r2/Review"))
    44→
    45→print("\n=== find /r2/Review ===")
    46→print(rt.run("find /r2/Review"))
    47→
    48→print("\n=== stat first file ===")
    49→files = ws.ls("/r2/Review")
    50→if files:
    51→    first = files[0]
    52→    s = ws.stat(first)
    53→    print(f"{first}")
    54→    print(f"  size: {s.size}  modified: {s.modified}")
    55→    if first.lower().endswith(".pdf"):
    56→        print("  (PDF — use a PDF parser for text extraction)")
    57→    else:
    58→        print(ws.cat(first).decode(errors="replace"))
    59→
    60→print("\n=== stat /r2/Review ===")
    61→print(rt.run("stat /r2/Review"))
    62→
    63→print("\n=== du /r2/Review ===")
    64→print(rt.run("du /r2/Review"), "bytes")
    65→


     1→import pytest
     2→from pydantic import ValidationError
     3→
     4→from mirage.backends.r2 import R2Config
     5→from mirage.backends.s3 import S3Config
     6→from mirage.commands.custom.registry import ExternalCommand
     7→
     8→
     9→def test_s3config_defaults():
    10→    c = S3Config(bucket="my-bucket")
    11→    assert c.region is None
    12→    assert c.timeout == 30
    13→    assert c.select_threshold_bytes == 1024 * 1024
    14→
    15→
    16→def test_s3config_immutable():
    17→    c = S3Config(bucket="x")
    18→    with pytest.raises(ValidationError):
    19→        c.bucket = "y"
    20→
    21→
    22→def test_r2config_fields():
    23→    c = R2Config(account_id="acc",
    24→                 bucket="bkt",
    25→                 access_key_id="k",
    26→                 secret_access_key="s")
    27→    assert c.region == "auto"
    28→    assert c.timeout == 30
    29→
    30→
    31→def test_r2config_immutable():
    32→    c = R2Config(account_id="a",
    33→                 bucket="b",
    34→                 access_key_id="k",
    35→                 secret_access_key="s")
    36→    with pytest.raises(ValidationError):
    37→        c.bucket = "other"
    38→
    39→
    40→def test_external_command_fields():
    41→    cmd = ExternalCommand(name="linecount", exec="python lc.py {file}")
    42→    assert cmd.description == ""
    43→
    44→
    45→def test_external_command_immutable():
    46→    cmd = ExternalCommand(name="x", exec="echo {file}")
    47→    with pytest.raises(ValidationError):
    48→        cmd.name = "y"
    49→


     1→import shlex
     2→
     3→import httpx
     4→
     5→from mirage.backends.proxy import ProxyBackend
     6→from mirage.runtime.accessors.remote import (RemoteCommandsAccessor,
     7→                                             RemoteFuseOpsAccessor,
     8→                                             RemoteWorkspaceOpsAccessor)
     9→from mirage.runtime.base import _BLOCKED, CommandNotAllowedError, Runtime
    10→from mirage.server import routes
    11→from mirage.types import MountMode, SyncPolicy
    12→from mirage.workspace import Workspace
    13→
    14→
    15→class RemoteRuntime(Runtime):
    16→
    17→    def __init__(self,
    18→                 url: str,
    19→                 ws_id: str,
    20→                 agent_id: str | None = None,
    21→                 timeout: int = 30,
    22→                 http_client: httpx.Client | None = None) -> None:
    23→        self._url = url.rstrip("/")
    24→        self._ws_id = ws_id
    25→        backend = ProxyBackend(url, ws_id, timeout, http_client=http_client)
    26→        ws = Workspace({"/": backend},
    27→                       mode=MountMode.WRITE,
    28→                       sync_policy=SyncPolicy.IMMEDIATE)
    29→        self._http = http_client or httpx.Client(base_url=self._url,
    30→                                                 timeout=timeout)
    31→        super().__init__(ws, registry=None, agent_id=agent_id)
    32→        self.commands = RemoteCommandsAccessor(self._http, ws_id)
    33→        self.fuse_ops = RemoteFuseOpsAccessor(self._http, ws_id)
    34→        self.workspace = RemoteWorkspaceOpsAccessor(self._http, ws_id)
    35→
    36→    def run(self, command: str) -> str:
    37→        parts = shlex.split(command)
    38→        if not parts:
    39→            return ""
    40→        cmd = parts[0]
    41→        if cmd in _BLOCKED:
    42→            raise CommandNotAllowedError(f"{cmd!r} is not allowed in Runtime.")
    43→        if cmd == "cd":
    44→            return self._handle_cd(parts[1:])
    45→        if cmd == "whoami":
    46→            return self._whoami()
    47→        if cmd == "identify":
    48→            if parts[1:]:
    49→                self.agent_id = " ".join(parts[1:])
    50→            return self.agent_id or ""
    51→        resp = self._http.post(routes.WORKSPACE_EXEC.format(ws_id=self._ws_id),
    52→                               json={
    53→                                   "command": command,
    54→                                   "cwd": self._cwd
    55→                               })
    56→        data = resp.json()
    57→        if not data.get("ok"):
    58→            raise RuntimeError(data.get("error", "unknown error"))
    59→        return data.get("output", "")
    60→
    61→    def _handle_cd(self, argv: list[str]) -> str:
    62→        from mirage.commands import _resolve
    63→        from mirage.commands.builtin import nav
    64→        if not argv:
    65→            self._cwd = "/"
    66→            return ""
    67→        self._cwd = nav.cd(self.ws, self._cwd, _resolve(self._cwd, argv[0]))
    68→        return ""
    69→


     1→import os
     2→import sys
     3→import threading
     4→import time
     5→from pathlib import Path
     6→from typing import TYPE_CHECKING, Annotated
     7→
     8→import typer
     9→
    10→from mirage.backends.loader import load_backend_class
    11→from mirage.cli.client import ClientError
    12→from mirage.cli.config import (load_workspace_yaml, parse_mount_uri,
    13→                               parse_mount_uri_full, split_mount_spec)
    14→from mirage.cli.helpers import _client, _ensure_server
    15→from mirage.fuse_ops.loader import build_registry_from_config
    16→from mirage.fuse_ops.registry import FuseOpRegistry
    17→from mirage.observe.inspector import (workspace_status_from_ws,
    18→                                      workspace_transfers_from_ws)
    19→from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
    20→from mirage.workspace import Workspace
    21→
    22→if TYPE_CHECKING:
    23→    from mirage_fuse import MirageFS
    24→
    25→workspace_app = typer.Typer(no_args_is_help=True)
    26→
    27→REPORT_INTERVAL = 2
    28→
    29→
    30→def _report_loop(ws: Workspace, mountpoint: str, fs: "MirageFS") -> None:
    31→    from mirage.cli.client import MirageClient
    32→    client = MirageClient()
    33→    time.sleep(2)
    34→    while True:
    35→        try:
    36→            status = workspace_status_from_ws(ws)
    37→            transfers = workspace_transfers_from_ws(ws)
    38→            ops = fs.drain_ops()
    39→            client.report_fuse_stats(mountpoint, status, transfers, ops)
    40→        except Exception:
    41→            pass
    42→        time.sleep(REPORT_INTERVAL)
    43→
    44→
    45→def _parse_mounts_for_fuse(
    46→    specs: list[str],
    47→    cfg: dict,
    48→    default_mode: MountMode,
    49→    default_consistency: ConsistencyPolicy,
    50→    ttl: int,
    51→    agent_id: str | None,
    52→) -> tuple[dict, MountMode, ConsistencyPolicy, int, str | None]:
    53→    resolved_agent_id = agent_id
    54→    mounts: dict = {}
    55→    if cfg:
    56→        if "consistency" in cfg:
    57→            default_consistency = ConsistencyPolicy(cfg["consistency"])
    58→        if "ttl" in cfg:
    59→            ttl = cfg["ttl"]
    60→        if not resolved_agent_id and "agent_id" in cfg:
    61→            resolved_agent_id = cfg["agent_id"]
    62→        for prefix, value in cfg.get("mounts", {}).items():
    63→            if isinstance(value, str):
    64→                backend_obj, m, c = parse_mount_uri_full(
    65→                    value, default_mode, default_consistency)
    66→            elif isinstance(value, dict) and "backend" in value:
    67→                cls = load_backend_class(value["backend"])
    68→                backend_obj = cls(**(value.get("args") or {}))
    69→                m = (MountMode(value["mode"])
    70→                     if "mode" in value else default_mode)
    71→                c = (ConsistencyPolicy(value["consistency"])
    72→                     if "consistency" in value else default_consistency)
    73→            elif isinstance(value, dict):
    74→                backend_obj = parse_mount_uri(value.get("uri", ""))
    75→                m = (MountMode(value["mode"])
    76→                     if "mode" in value else default_mode)
    77→                c = (ConsistencyPolicy(value["consistency"])
    78→                     if "consistency" in value else default_consistency)
    79→            else:
    80→                continue
    81→            has_override = (m != default_mode or c != default_consistency)
    82→            if has_override:
    83→                mounts[prefix] = (backend_obj, m, c)
    84→            else:
    85→                mounts[prefix] = backend_obj
    86→    for spec in specs:
    87→        uri, prefix = split_mount_spec(spec)
    88→        backend_obj, m, c = parse_mount_uri_full(uri, default_mode,
    89→                                                 default_consistency)
    90→        has_override = (m != default_mode or c != default_consistency)
    91→        if has_override:
    92→            mounts[prefix] = (backend_obj, m, c)
    93→        else:
    94→            mounts[prefix] = backend_obj
    95→    return mounts, default_mode, default_consistency, ttl, resolved_agent_id
    96→
    97→
    98→def mount(
    99→    mount_specs: Annotated[
   100→        list[str] | None,
   101→        typer.Argument(help="Mount specs: backend:/path "
   102→                       "(e.g. memory:/data/, s3://bucket:/). "
   103→                       "Append #none|#lazy|#ttl|#always for "
   104→                       "consistency, @read|@write|@exec for mode.")] = None,
   105→    sync_policy: str = typer.Option("staged", help="staged or immediate"),
   106→    mode: MountMode = typer.Option(MountMode.WRITE, help="read or write"),
   107→    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.NONE),
   108→    ttl: int = typer.Option(300, help="TTL in seconds for ttl consistency"),
   109→    config: str | None = typer.Option(None,
   110→                                      help="Path to YAML workspace config"),
   111→    runtime: RuntimeType = typer.Option(RuntimeType.MEMORY,
   112→                                        help="Runtime: memory or local"),
   113→    fuse: bool = typer.Option(False, help="Also mount as FUSE filesystem"),
   114→    mountpoint: str | None = typer.Option(
   115→        None, help="Local directory for FUSE mount (requires --fuse)"),
   116→    agent_id: str | None = typer.Option(
   117→        None, help="Agent identity exposed via /.mirage/whoami"),
   118→    backend: str | None = typer.Option(
   119→        None,
   120→        help="Backend URI mounted at /. Prefix specs override for subtree."),
   121→) -> None:
   122→    """Create a workspace and mount backends.
   123→
   124→    Each backend URI is routed by prefix. The longest matching prefix wins,
   125→    so /data/logs/ takes priority over /data/ for paths under /data/logs/.
   126→
   127→    With --fuse, also mounts the workspace as a local FUSE filesystem
   128→    (requires macFUSE / libfuse). The process blocks while FUSE is mounted.
   129→
   130→    URI formats:
   131→      memory             in-memory (no disk, no deps)
   132→      local              local filesystem at current directory
   133→      local:./path       local filesystem at ./path (converted to abs)
   134→      s3://bucket/pfx    AWS S3  (requires mirage[s3])
   135→      r2://account/bkt   Cloudflare R2  (requires mirage[s3])
   136→      https://host       HTTP read-only (requires mirage[http])
   137→    """
   138→    specs = list(mount_specs or [])
   139→    if backend:
   140→        specs.insert(0, f"{backend}:/")
   141→
   142→    if fuse:
   143→        _mount_fuse(specs, config, mode, consistency, ttl, agent_id,
   144→                    mountpoint, runtime)
   145→        return
   146→
   147→    _ensure_server()
   148→    mounts: dict[str, str | dict] = {}
   149→    if config:
   150→        cfg = load_workspace_yaml(config)
   151→        mounts.update(cfg.get("mounts", {}))
   152→        consistency_str = cfg.get("consistency")
   153→        if consistency_str:
   154→            consistency = ConsistencyPolicy(consistency_str)
   155→        ttl = cfg.get("ttl", ttl)
   156→    consistency_suffixes = ("#none", "#lazy", "#ttl", "#always")
   157→    for spec in specs:
   158→        try:
   159→            uri, prefix = split_mount_spec(spec)
   160→        except ValueError as e:
   161→            raise typer.BadParameter(str(e))
   162→        mount_consistency = None
   163→        for suffix in consistency_suffixes:
   164→            if uri.endswith(suffix):
   165→                mount_consistency = suffix[1:]
   166→                uri = uri[:-len(suffix)]
   167→                break
   168→        mount_mode = None
   169→        for suffix in ("@read", "@write", "@exec"):
   170→            if uri.endswith(suffix):
   171→                mount_mode = suffix[1:]
   172→                uri = uri[:-len(suffix)]
   173→                break
   174→        entry: dict[str, str] = {"uri": uri}
   175→        if mount_mode:
   176→            entry["mode"] = mount_mode
   177→        if mount_consistency:
   178→            entry["consistency"] = mount_consistency
   179→        if len(entry) == 1:
   180→            mounts[prefix] = uri
   181→        else:
   182→            mounts[prefix] = entry
   183→    ws_id = _client().create_workspace(
   184→        mounts,
   185→        sync_policy=sync_policy,
   186→        mode=mode.value,
   187→        consistency=consistency.value,
   188→        ttl_seconds=ttl,
   189→        runtime=runtime.value,
   190→    )
   191→    print(ws_id)
   192→
   193→
   194→def _mount_fuse(
   195→    specs: list[str],
   196→    config: str | None,
   197→    mode: MountMode,
   198→    consistency: ConsistencyPolicy,
   199→    ttl: int,
   200→    agent_id: str | None,
   201→    mountpoint: str | None,
   202→    runtime: RuntimeType,
   203→) -> None:
   204→    from mirage_fuse import MirageFS
   205→    from mirage_fuse import mount as fuse_mount_fn
   206→
   207→    from mirage.backends.proxy import ProxyBackend
   208→    from mirage.fuse_ops.remote import RemoteFuseOp
   209→
   210→    if not mountpoint:
   211→        raise typer.BadParameter("--mountpoint is required when using --fuse")
   212→    if not specs and not config:
   213→        raise typer.BadParameter(
   214→            "No backends specified. Use --backend URI or provide mount specs.")
   215→    for spec in specs:
   216→        if ":" not in spec:
   217→            raise typer.BadParameter(
   218→                f"Invalid mount spec: {spec!r}. Use backend:/path")
   219→
   220→    Path(mountpoint).mkdir(parents=True, exist_ok=True)
   221→    _ensure_server()
   222→    cfg = load_workspace_yaml(config) if config else {}
   223→
   224→    if runtime == RuntimeType.REMOTE:
   225→        url = cfg.get("url", "")
   226→        ws_id_remote = cfg.get("ws_id", "")
   227→        proxy = ProxyBackend(url, ws_id_remote)
   228→        ws = Workspace({"/": proxy}, mode=MountMode.WRITE)
   229→        fuse_registry = FuseOpRegistry()
   230→        fuse_registry.set_default(RemoteFuseOp(proxy))
   231→        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
   232→    else:
   233→        mounts, mode, consistency, ttl, agent_id = _parse_mounts_for_fuse(
   234→            specs, cfg, mode, consistency, ttl, agent_id)
   235→        ws = Workspace(mounts,
   236→                       mode=mode,
   237→                       consistency=consistency,
   238→                       ttl_seconds=ttl)
   239→        fuse_ops_config = cfg.get("fuse_ops", {}) if cfg else {}
   240→        fuse_registry = build_registry_from_config(fuse_ops_config)
   241→        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
   242→
   243→    pid = os.getpid()
   244→    _client().register_fuse(mountpoint, pid, specs, runtime=runtime.value)
   245→
   246→    thread = threading.Thread(target=_report_loop,
   247→                              args=(ws, mountpoint, fs),
   248→                              daemon=True)
   249→    thread.start()
   250→
   251→    fuse_mount_fn(mountpoint=mountpoint, foreground=True, fs=fs)
   252→
   253→
   254→def exec_cmd(
   255→        ws_id: str = typer.Argument(),
   256→        command: list[str] = typer.Argument(),
   257→) -> None:
   258→    """Execute a command in a running workspace."""
   259→    cmd_str = " ".join(command)
   260→    try:
   261→        output = _client().exec(ws_id, cmd_str)
   262→        if output:
   263→            print(output)
   264→    except ClientError as e:
   265→        print(f"Error: {e}", file=sys.stderr)
   266→        raise typer.Exit(1)
   267→
   268→
   269→def kill(ws_id: str = typer.Argument()) -> None:
   270→    """Stop a workspace daemon."""
   271→    _client().delete_workspace(ws_id)
   272→    print(f"Killed workspace {ws_id}")
   273→
   274→
   275→def sync(ws_id: str = typer.Argument()) -> None:
   276→    """Flush dirty inodes to backends for a workspace."""
   277→    try:
   278→        results = _client().sync(ws_id)
   279→        if results:
   280→            for r in results:
   281→                print(f"{r['path']}: {r['status']}")
   282→        else:
   283→            print("All clean.")
   284→    except ClientError as e:
   285→        print(f"Error: {e}", file=sys.stderr)
   286→        raise typer.Exit(1)
   287→
   288→
   289→def status(ws_id: str = typer.Argument()) -> None:
   290→    """Show dirty/clean inode status for a workspace."""
   291→    try:
   292→        inodes = _client().status(ws_id)
   293→        if inodes:
   294→            for i in inodes:
   295→                marker = "D" if i["dirty"] else " "
   296→                print(f"{marker} {i['path']}")
   297→        else:
   298→            print("No tracked files.")
   299→    except ClientError as e:
   300→        print(f"Error: {e}", file=sys.stderr)
   301→        raise typer.Exit(1)
   302→
   303→
   304→@workspace_app.command(name="ls")
   305→def workspace_ls() -> None:
   306→    """List active workspaces."""
   307→    _ensure_server()
   308→    workspaces = _client().list_workspaces()
   309→    if not workspaces:
   310→        print("No active workspaces.")
   311→        return
   312→    for w in workspaces:
   313→        parts = [w["id"], w.get("type", "workspace")]
   314→        if w.get("mountpoint"):
   315→            parts.append(w["mountpoint"])
   316→        print("\t".join(parts))
   317→