← Files Repo ScoutARCHIVED FILE
install.py
29.2 KB · Oct 2, 2026 · 00:33 UTC
#!/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