← Files Repo ScoutARCHIVED FILE

install.py

29.2 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 ImL1s
"""Copy the standalone skill into one documented agent directory, or inspect copies.

Dry-run by default. Python 3.10+, no dependencies. Existing installations are
never overwritten: the payload is staged next to the destination, hashed, given
an install marker, and then published with a no-replace rename
(renameat2 RENAME_NOREPLACE on Linux, renamex_np RENAME_EXCL on macOS,
os.rename on Windows). Where that primitive is unavailable the installer fails
instead of falling back to a replacing rename or a copy. It does not install or
authenticate any coding agent.

    python3 install.py --agent claude --apply      # install (dry-run without --apply)
    python3 install.py doctor                      # static inventory of known copies; writes nothing
"""
from __future__ import annotations

import argparse
import ctypes
from datetime import datetime, timezone
import errno
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
import shutil
import stat
import sys
import tempfile
import uuid

AGENT_DIRS = {"claude": ".claude", "codex": ".agents", "grok": ".grok"}
SOURCE = Path(__file__).resolve().parent / "skills" / "repo-scout"
MARKER_NAME = ".repo-scout-install.json"
MARKER_SCHEMA_VERSION = 1
MARKER_MAX_BYTES = 1 << 20  # a real marker is a few KiB; anything larger is not read
STAGING_PREFIX = ".repo-scout-install-"
# Same rule as source_version(): one non-empty token without whitespace.
VERSION_PATTERN = re.compile(r"^\S+$")
IGNORED_NAMES = ("__pycache__", "*.pyc")
_load_library = ctypes.CDLL  # indirection so tests can simulate a libc without the primitive


# --------------------------------------------------------------------------- no-follow classification

# Windows reparse points. A junction (IO_REPARSE_TAG_MOUNT_POINT) is not a symlink to
# is_symlink(), yet it redirects a path exactly like one. Every reparse tag with the
# name-surrogate bit set redirects (junctions, symlinks, WSL links); other reparse points
# (cloud placeholders, compressed files) are the file itself. Path.is_junction() makes the
# same distinction but needs Python 3.12.
_FILE_ATTRIBUTE_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
_REPARSE_NAME_SURROGATE = 0x20000000


def is_redirect_stat(st: os.stat_result) -> bool:
    """True if no-follow metadata (lstat, stat(follow_symlinks=False)) describes a redirect.

    A redirect is a symlink on every platform, plus a name-surrogate reparse point such as a
    junction on Windows. A reparse point whose tag is unknown is treated as a redirect."""
    if stat.S_ISLNK(st.st_mode):
        return True
    if sys.platform == "win32" and getattr(st, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT:
        tag = getattr(st, "st_reparse_tag", 0)
        return tag == 0 or bool(tag & _REPARSE_NAME_SURROGATE)
    return False


def is_redirect(path: Path) -> bool:
    """No-follow check of one path. A path that cannot be examined is not called a redirect;
    every later operation on it fails on its own (mkdir, the no-replace publish, scandir)."""
    try:
        return is_redirect_stat(os.lstat(path))
    except OSError:
        return False


def _open_regular_nofollow(path: Path, expected: os.stat_result | None = None) -> int:
    """Open `path` for reading without following a symlink, without blocking on a FIFO and
    without acquiring a controlling terminal; return the descriptor only if it refers to a
    regular file, and to the same file `expected` (a prior no-follow stat) described, else raise
    ValueError. O_NOFOLLOW/O_NONBLOCK do not exist on Windows, so the identity check is what
    rejects an entry swapped in between stat and open there; it compares (st_dev, st_ino) when
    both sides report an inode (Windows reports 0 when the metadata came from a directory
    listing, and that case is skipped rather than failed)."""
    flags = (os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
             | getattr(os, "O_NOCTTY", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_BINARY", 0))
    fd = os.open(path, flags)
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise ValueError(f"not a regular file: {path}")
        if (expected is not None and expected.st_ino and st.st_ino
                and (expected.st_dev, expected.st_ino) != (st.st_dev, st.st_ino)):
            raise ValueError(f"file changed between classification and open: {path}")
    except BaseException:
        os.close(fd)
        raise
    return fd


def read_small_regular_file(path: Path, limit: int | None = None) -> bytes:
    """Read a regular, non-redirected file of at most `limit` bytes.

    The entry is classified with no-follow metadata before it is opened, then re-checked on
    the open descriptor (type, identity, size): a symlink, junction, FIFO, device, directory or
    oversized file, or an entry replaced between the two checks, raises ValueError (or the
    OSError of the failing call) instead of being read or waited on."""
    if limit is None:
        limit = MARKER_MAX_BYTES
    st = os.lstat(path)
    if is_redirect_stat(st) or not stat.S_ISREG(st.st_mode):
        raise ValueError(f"not a regular file: {path}")
    if st.st_size > limit:
        raise ValueError(f"file larger than {limit} bytes: {path}")
    fd = _open_regular_nofollow(path, st)
    try:
        if os.fstat(fd).st_size > limit:
            raise ValueError(f"file larger than {limit} bytes: {path}")
        chunks: list[bytes] = []
        remaining = limit + 1
        while remaining > 0:
            chunk = os.read(fd, min(remaining, 1 << 16))
            if not chunk:
                break
            chunks.append(chunk)
            remaining -= len(chunk)
    finally:
        os.close(fd)
    data = b"".join(chunks)
    if len(data) > limit:
        raise ValueError(f"file larger than {limit} bytes: {path}")
    return data


# --------------------------------------------------------------------------- payload

def source_version(source: Path) -> str:
    """Version data travels inside the skill copy as a plain VERSION file."""
    try:
        version = read_small_regular_file(source / "VERSION", 4096).decode("utf-8").strip()
    except (OSError, ValueError) as error:
        raise ValueError(f"Source skill is missing a readable VERSION file: {error}") from error
    if not version or any(ch.isspace() for ch in version):
        raise ValueError("Source VERSION must be a single non-empty token")
    return version


def sha256_file(path: Path, expected: os.stat_result | None = None) -> str:
    """SHA-256 of a regular file, opened without following a symlink and, when `expected` (a
    prior no-follow stat) is given, only if it is still that file (ValueError otherwise)."""
    digest = hashlib.sha256()
    fd = _open_regular_nofollow(path, expected)
    try:
        while True:
            chunk = os.read(fd, 1 << 16)
            if not chunk:
                break
            digest.update(chunk)
    finally:
        os.close(fd)
    return digest.hexdigest()


def payload_files(root: Path) -> tuple[dict[str, str], list[str], list[dict[str, str]]]:
    """Enumerate the managed payload under root without following anything.

    Returns ({relative posix path: sha256} for regular files, [irregular entries], [errors]).
    The install marker and Python caches are never part of the payload. Symlinks, Windows
    junctions and other non-regular entries are listed as irregular and never descended.
    Every directory that could not be listed and every file that could not be hashed is an
    error: a non-empty error list means the enumeration is incomplete and the digests do not
    describe the whole copy."""
    digests: dict[str, str] = {}
    irregular: list[str] = []
    errors: list[dict[str, str]] = []
    pending: list[tuple[Path, tuple[str, ...]]] = [(root, ())]
    while pending:
        directory, parts = pending.pop()
        try:
            with os.scandir(directory) as stream:
                entries = sorted(stream, key=lambda e: e.name)
        except OSError as error:
            errors.append({"path": "/".join(parts) or ".", "error": type(error).__name__})
            continue
        for entry in entries:
            relative = "/".join(parts + (entry.name,))
            try:
                st = entry.stat(follow_symlinks=False)
            except OSError as error:
                errors.append({"path": relative, "error": type(error).__name__})
                continue
            if is_redirect_stat(st):
                irregular.append(relative)
            elif stat.S_ISDIR(st.st_mode):
                if entry.name != "__pycache__":
                    pending.append((Path(entry.path), parts + (entry.name,)))
            elif not stat.S_ISREG(st.st_mode):
                irregular.append(relative)
            elif relative == MARKER_NAME or entry.name.endswith(".pyc"):
                continue
            else:
                try:
                    digests[relative] = sha256_file(Path(entry.path), st)
                except ValueError:  # replaced by something non-regular between stat and open
                    irregular.append(relative)
                except OSError as error:
                    errors.append({"path": relative, "error": type(error).__name__})
    return digests, sorted(irregular), sorted(errors, key=lambda e: e["path"])


def payload_digest(digests: dict[str, str]) -> str:
    """One digest for the whole managed payload.

    The preimage is canonical JSON of the sorted [path, sha256] pairs (fixed separators,
    ASCII-only), so a path can never be confused with a path-plus-digest of another entry."""
    canonical = json.dumps(sorted(digests.items()), separators=(",", ":"), ensure_ascii=True)
    return hashlib.sha256(canonical.encode("ascii")).hexdigest()


_HEX64 = re.compile(r"^[0-9a-f]{64}$")
_UUID4 = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
_MARKER_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
# strptime accepts "2026-9-9T1:2:3Z" and, like \d, any Unicode decimal digit; the emitted shape
# is fixed-width ASCII, so require exactly that first.
_MARKER_TIME_SHAPE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")


def _marker_path_is_valid(path: object) -> bool:
    """Keys of files_sha256 are as_posix() paths relative to the copy: no NUL, no absolute or
    anchored form, no empty, '.' or '..' component, never the marker itself. A backslash is a
    separator only on Windows (where as_posix() never emits one); on POSIX it is an ordinary
    name character. Drive and UNC syntax is rejected with Windows path semantics on Windows."""
    if not isinstance(path, str) or not path or "\x00" in path or path.startswith("/"):
        return False
    if sys.platform == "win32" and ("\\" in path or ntpath.splitdrive(path)[0] or ntpath.isabs(path)):
        return False
    if any(part in ("", ".", "..") for part in path.split("/")):
        return False
    return path != MARKER_NAME


def validate_marker(marker: object) -> dict[str, str]:
    """Return the marker's file digests if it has exactly the emitted schema; else raise ValueError."""
    if not isinstance(marker, dict):
        raise ValueError("marker is not an object")
    if set(marker) != {"schema_version", "tool", "version", "install_id", "created_at",
                       "payload_complete", "files_sha256"}:
        raise ValueError("marker keys do not match the schema")
    # type() rather than isinstance(): True == 1 and 1.0 == 1 must not pass as schema 1.
    if type(marker["schema_version"]) is not int or marker["schema_version"] != MARKER_SCHEMA_VERSION:
        raise ValueError("unsupported marker schema")
    if marker["tool"] != "repo-scout":
        raise ValueError("marker written by a foreign tool")
    if not isinstance(marker["version"], str) or not VERSION_PATTERN.fullmatch(marker["version"]):
        raise ValueError("marker version is not a plain version string")
    if not isinstance(marker["install_id"], str) or not _UUID4.fullmatch(marker["install_id"]):
        raise ValueError("marker install_id is not a UUID4")
    if not isinstance(marker["created_at"], str) or not _MARKER_TIME_SHAPE.fullmatch(marker["created_at"]):
        raise ValueError("marker created_at is not a YYYY-MM-DDTHH:MM:SSZ timestamp")
    try:
        datetime.strptime(marker["created_at"], _MARKER_TIME_FORMAT)
    except ValueError as error:
        raise ValueError("marker created_at is not a valid RFC 3339 UTC timestamp") from error
    if marker["payload_complete"] is not True:
        raise ValueError("marker does not record a complete payload")
    files = marker["files_sha256"]
    if not isinstance(files, dict) or not files:
        raise ValueError("marker files_sha256 is not a non-empty object")
    for path, digest in files.items():
        if not _marker_path_is_valid(path):
            raise ValueError(f"marker lists an invalid path: {path!r}")
        if not isinstance(digest, str) or not _HEX64.fullmatch(digest):
            raise ValueError(f"marker digest is not SHA-256 hex: {path}")
    return files


def write_marker(staged: Path, version: str, digests: dict[str, str]) -> dict[str, object]:
    marker: dict[str, object] = {
        "schema_version": MARKER_SCHEMA_VERSION,
        "tool": "repo-scout",
        "version": version,
        "install_id": str(uuid.uuid4()),
        "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
        "payload_complete": True,
        "files_sha256": dict(sorted(digests.items())),
    }
    # Serialize once and write exactly those bytes; a marker the reader would refuse
    # (larger than MARKER_MAX_BYTES) is rejected here, before anything is published.
    data = (json.dumps(marker, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
    if len(data) > MARKER_MAX_BYTES:
        raise ValueError(f"install marker would be {len(data)} bytes, above the {MARKER_MAX_BYTES}-byte "
                         "limit its reader accepts; nothing published")
    (staged / MARKER_NAME).write_bytes(data)
    return marker


# --------------------------------------------------------------------------- publish

def _raise_rename_error(code: int, target: Path, primitive: str) -> None:
    if code in (errno.EEXIST, errno.ENOTEMPTY):
        raise FileExistsError(code, f"Destination appeared during installation; no overwrite performed: {target}")
    if code in (errno.EINVAL, errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EXDEV):
        raise OSError(code, f"{primitive} is unsupported for {target} ({os.strerror(code)}); "
                            "refusing to fall back to a replacing rename")
    raise OSError(code, f"{primitive} failed for {target}: {os.strerror(code)}")


def publish_noreplace(staged: Path, target: Path) -> None:
    """Atomically move the staged directory onto target, failing if target exists.

    Same filesystem only. No fallback: if the platform primitive is missing or the
    filesystem rejects it, the installer stops and target is left untouched."""
    src, dst = os.fsencode(str(staged)), os.fsencode(str(target))
    if sys.platform.startswith("linux"):
        libc = _load_library(None, use_errno=True)
        try:
            renameat2 = libc.renameat2
        except AttributeError as error:
            raise OSError("no-replace publish unavailable: this libc has no renameat2") from error
        renameat2.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint)
        renameat2.restype = ctypes.c_int
        at_fdcwd, rename_noreplace = -100, 1
        if renameat2(at_fdcwd, src, at_fdcwd, dst, rename_noreplace) != 0:
            _raise_rename_error(ctypes.get_errno(), target, "renameat2(RENAME_NOREPLACE)")
    elif sys.platform == "darwin":
        libc = _load_library("/usr/lib/libSystem.B.dylib", use_errno=True)
        try:
            renamex_np = libc.renamex_np
        except AttributeError as error:
            raise OSError("no-replace publish unavailable: libSystem has no renamex_np") from error
        renamex_np.argtypes = (ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint)
        renamex_np.restype = ctypes.c_int
        rename_excl = 0x4
        if renamex_np(src, dst, rename_excl) != 0:
            _raise_rename_error(ctypes.get_errno(), target, "renamex_np(RENAME_EXCL)")
    elif sys.platform == "win32":
        # MoveFileExW without MOVEFILE_REPLACE_EXISTING: fails when the destination exists.
        os.rename(staged, target)
    else:
        raise OSError(f"no-replace publish is not implemented for platform {sys.platform}")


# --------------------------------------------------------------------------- install

def destination(agent: str, scope: str, project: Path | None = None,
                home: Path | None = None) -> Path:
    if agent not in AGENT_DIRS:
        raise ValueError("Unsupported agent")
    if scope == "user":
        base = (home or Path.home()).expanduser().resolve(strict=True)
    elif scope == "project":
        if project is None:
            raise ValueError("--project is required for project scope")
        base = project.expanduser().resolve(strict=True)
    else:
        raise ValueError("Unsupported scope")
    if not base.is_dir():
        raise ValueError("Installation base must be a directory")
    path = base / AGENT_DIRS[agent] / "skills" / "repo-scout"
    # Reject redirects rather than following existing config-directory symlinks or junctions.
    redirect = symlink_component(base, path)
    if redirect is not None:
        raise ValueError(f"Refusing symlink or junction destination component: {redirect}")
    return path


def symlink_component(base: Path, path: Path) -> Path | None:
    """First component of `path` below `base` that is a symlink or junction (nothing followed), else None."""
    current = base
    for name in path.relative_to(base).parts:
        current = current / name
        if is_redirect(current):
            return current
    return None


def _preflight(target: Path, phase: str) -> None:
    if is_redirect(target.parent):
        raise ValueError(f"Refusing symlink or junction destination component ({phase}): {target.parent}")
    if is_redirect(target) or os.path.lexists(target):
        raise FileExistsError(f"Destination exists ({phase}); no overwrite performed: {target}")


def install(source: Path, target: Path, apply: bool = False) -> dict[str, object]:
    if not (source / "SKILL.md").is_file():
        raise ValueError("Source skill is missing SKILL.md")
    version = source_version(source)
    expected, irregular, errors = payload_files(source)
    if errors:
        raise ValueError(f"Source skill could not be fully enumerated; nothing installed: {errors}")
    if irregular:
        raise ValueError(f"Source skill contains symlinks, junctions or irregular entries: {irregular}")
    _preflight(target, "before staging")
    plan: dict[str, object] = {"source": str(source), "destination": str(target),
                               "version": version, "files": len(expected),
                               "status": "dry-run", "network": False}
    if not apply:
        return plan
    target.parent.mkdir(parents=True, exist_ok=True)
    # Stage a complete copy next to the destination (same filesystem). Treat the skill
    # package as trusted local input; do not run this installer in a concurrently
    # hostile filesystem. A killed process leaves only this private staging directory.
    with tempfile.TemporaryDirectory(prefix=STAGING_PREFIX, dir=target.parent) as temp:
        staged = Path(temp) / "repo-scout"
        shutil.copytree(source, staged, ignore=shutil.ignore_patterns(*IGNORED_NAMES))
        digests, irregular, errors = payload_files(staged)
        if errors:
            raise OSError(f"Staged payload could not be fully enumerated; nothing published: {errors}")
        if irregular or digests != expected:
            raise OSError(f"Staged payload does not match the source skill; nothing published: {staged}")
        marker = write_marker(staged, version, digests)
        _preflight(target, "after staging")
        publish_noreplace(staged, target)
        # Published: from here on nothing may remove or rewrite target, even on failure.
    plan.update({"status": "installed", "install_id": marker["install_id"],
                 "marker": str(target / MARKER_NAME), "files": len(digests),
                 "payload_sha256": payload_digest(digests)})
    return plan


# --------------------------------------------------------------------------- doctor

def _observed_version(path: Path) -> str | None:
    """The VERSION file of a copy, read independently of the marker; None if absent or unusable."""
    try:
        text = read_small_regular_file(path / "VERSION", 4096).decode("utf-8").strip()
    except (OSError, ValueError):
        return None
    return text if text and not any(ch.isspace() for ch in text) else None


def inspect_copy(path: Path, bundled_payload: str | None = None) -> dict[str, object]:
    """Static inspection of one candidate copy. Reads only; never follows symlinks or junctions."""
    report: dict[str, object] = {"path": str(path)}
    try:
        st = os.lstat(path)
    except FileNotFoundError:
        report["state"] = "absent"
        return report
    except OSError as error:
        report["state"] = "unreadable"
        report["error"] = type(error).__name__
        return report
    if is_redirect_stat(st):
        report["state"] = "symlink-not-followed"
        return report
    if not stat.S_ISDIR(st.st_mode):
        report["state"] = "not-a-directory"
        return report
    report["state"] = "present"
    digests, irregular, errors = payload_files(path)
    complete = not errors
    report["file_count"] = len(digests)
    report["irregular_entries"] = irregular
    report["enumeration_complete"] = complete
    report["enumeration_errors"] = errors
    report["has_skill_md"] = "SKILL.md" in digests
    report["skill_md_sha256"] = digests.get("SKILL.md")
    # An incomplete enumeration describes nothing: no whole-payload digest, no source verdict.
    report["payload_sha256"] = payload_digest(digests) if complete else None
    if bundled_payload is None or not complete:
        report["matches_bundled_source"] = None
    else:
        report["matches_bundled_source"] = not irregular and report["payload_sha256"] == bundled_payload

    version_recorded: str | None = None
    try:
        marker = json.loads(read_small_regular_file(path / MARKER_NAME).decode("utf-8"))
        files = validate_marker(marker)
    except FileNotFoundError:
        report["management"] = "unmanaged"
        report["marker"] = None
    except (OSError, ValueError, RecursionError) as error:  # symlink, FIFO, oversized, unreadable, malformed, too nested
        report["management"] = "marker_invalid"
        report["marker"] = {"error": type(error).__name__}
    else:
        report["management"] = "managed"
        report["marker"] = {k: marker[k] for k in ("schema_version", "install_id", "created_at")}
        report["marker"]["payload_complete_recorded"] = marker["payload_complete"]
        version_recorded = marker["version"]
        missing = sorted(p for p in files if p not in digests)
        modified = sorted(p for p in files if p in digests and digests[p] != files[p])
        extra = sorted(p for p in digests if p not in files)
        if not complete:
            status = "incomplete"
        elif missing or modified or extra or irregular:
            status = "modified"
        else:
            status = "intact"
        report["integrity"] = {"status": status, "missing": missing, "modified": modified,
                               "extra": extra, "irregular": irregular}
    # The marker records what was installed; the VERSION file is what is there now. Report
    # both, flag disagreement, and never present the recorded value as the current version
    # when an observed one exists.
    version_observed = _observed_version(path)
    report["version_recorded"] = version_recorded
    report["version_observed"] = version_observed
    report["version_mismatch"] = (version_recorded is not None and version_observed is not None
                                  and version_recorded != version_observed)
    if version_observed is not None:
        report["version"], report["version_source"] = version_observed, "version_file"
    elif version_recorded is not None:
        report["version"], report["version_source"] = version_recorded, "marker"
    else:
        report["version"], report["version_source"] = "unknown", None
    return report


def doctor(home: Path | None = None, projects: list[Path] | None = None,
           source: Path | None = SOURCE) -> dict[str, object]:
    """Inventory every repo-scout copy under the documented host roots. Writes nothing."""
    home = (home or Path.home()).expanduser().resolve(strict=True)
    bundled: str | None = None
    bundled_version: str | None = None
    if source is not None and (source / "SKILL.md").is_file():
        digests, irregular, errors = payload_files(source)
        # A source that is not fully readable or not installable is no reference to compare with.
        bundled = None if (errors or irregular) else payload_digest(digests)
        try:
            bundled_version = source_version(source)
        except ValueError:
            bundled_version = None
    bases = [("user", home)] + [("project", p.expanduser().resolve(strict=True)) for p in (projects or [])]
    copies: list[dict[str, object]] = []
    stale: list[str] = []
    for scope, base in bases:
        for agent, directory in AGENT_DIRS.items():
            skills_dir = base / directory / "skills"
            # A symlinked or junctioned `.claude`, `skills` or `repo-scout` component would let
            # the walk leave the declared root; report it and inspect nothing beneath it.
            redirect_dir = symlink_component(base, skills_dir)
            redirect = redirect_dir or symlink_component(base, skills_dir / "repo-scout")
            if redirect is not None:
                copy: dict[str, object] = {"path": str(skills_dir / "repo-scout"),
                                           "state": "symlink-not-followed", "symlink": str(redirect)}
            else:
                copy = inspect_copy(skills_dir / "repo-scout", bundled)
            copy.update({"scope": scope, "agent": agent})
            copies.append(copy)
            # Stale staging directories are siblings of the copy: enumerate them whenever the
            # skills directory itself is reached without a redirect, even if the copy is a link.
            if redirect_dir is None and skills_dir.is_dir():
                try:
                    for entry in sorted(os.listdir(skills_dir)):
                        if entry.startswith(STAGING_PREFIX):
                            stale.append(str(skills_dir / entry))
                except OSError:
                    pass
    return {
        "tool": "repo-scout",
        "bundled_source": None if source is None else str(source),
        "bundled_version": bundled_version,
        "bundled_payload_sha256": bundled,
        "discovery_scope": "known_roots_only",
        "active_copy": "unknown",
        "roots": [str(base) for _, base in bases],
        "copies": copies,
        "stale_staging": stale,
        "notes": [
            "Only the documented host roots were scanned; plugin caches, custom paths and parent projects are not covered.",
            "Which copy a host actually loads is not determined here; check the host's own skill list in a new session.",
            "A marker is management data, not proof of identity and not permission to delete anything.",
        ],
    }


# --------------------------------------------------------------------------- CLI

def doctor_main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(prog="install.py doctor",
                                     description="Static inventory of repo-scout copies under the known host roots. Writes nothing.")
    parser.add_argument("--home", type=Path, default=None, help="user-scope base (default: your home directory)")
    parser.add_argument("--project", type=Path, action="append", default=[], help="also scan this project root (repeatable)")
    args = parser.parse_args(argv)
    try:
        print(json.dumps(doctor(args.home, args.project), ensure_ascii=False, indent=2))
    except (OSError, ValueError) as error:
        parser.error(str(error))
    return 0


def main(argv: list[str] | None = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    if argv and argv[0] == "doctor":
        return doctor_main(argv[1:])
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--agent", required=True, choices=tuple(AGENT_DIRS))
    parser.add_argument("--scope", choices=("user", "project"), default="user")
    parser.add_argument("--project", type=Path)
    parser.add_argument("--apply", action="store_true", help="Actually copy files; default only prints a plan")
    args = parser.parse_args(argv)
    if args.scope == "user" and args.project is not None:
        parser.error("--project is valid only with --scope project")
    try:
        target = destination(args.agent, args.scope, args.project)
        print(json.dumps(install(SOURCE, target, args.apply), ensure_ascii=False, indent=2))
    except (OSError, ValueError) as error:
        parser.error(str(error))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: eed6e2891d76046cc6c4b80c3fcb7a9dd77a8ef6d0c5af87841b5f4fbe105e14