"""Report stale copies of this repository's guideline documents and skills, and offer to sync them. This repository is the source of truth for two independent things, both checked by this script: 1. ``CLAUDE.md``, ``AGENTS.md`` and the ``DESIGN_DOCUMENT*.md`` family. Every project keeps a committed copy in its root, so the copies drift as soon as a document here is bumped. This part walks the sibling directories of this repository (or ``--root``), reads the ``Document Version`` header of every copy it finds and reports the ones that are behind, missing, or modified locally. 2. The personal skills under ``Claude/skills//SKILL.md``, whose installed copies Claude Code actually runs from ``~/.claude/skills//SKILL.md`` — a fixed location, independent of ``--root``. Versions live in the YAML frontmatter as ``metadata.version``. A skill missing from ``~/.claude/skills/`` entirely is reported as ``MISSING`` and treated like ``BEHIND``: both are safe to install/overwrite with ``--sync``. It also finds guideline copies still using a pre-rename file name (``DESIGN_DOCUMENT.md`` and friends, from before the language became part of the name) and reports them as ``OLD NAME``. ``--sync`` offers to overwrite the outdated copies with the master version (installing missing skills fresh), renaming the old guideline names on the way. Only ``BEHIND``, ``MISSING`` and ``OLD NAME`` copies are ever touched: a ``MODIFIED`` copy holds local edits that overwriting would discard, an unversioned copy cannot be compared, and ``DUPLICATE`` means both the old and the new guideline name exist, which needs a human to decide. Standard library only, so it runs without a virtual environment: python check_versions.py # report only python check_versions.py --sync # report, then offer to copy the outdated ones python check_versions.py --sync --yes # copy without asking (for scripts) python check_versions.py --sync --dry-run # show what --sync would copy, change nothing python check_versions.py --root .. # scan a different directory for guideline copies python check_versions.py --quiet # print only projects/skills needing attention """ from __future__ import annotations import argparse import os import re import shutil import sys from dataclasses import dataclass from pathlib import Path # Guideline documents owned by this repository, mapped to their master copy. Design documents are named # after what they scope - a language, or a cross-cutting concern such as the API surface - so a project # may carry several of them without any collision. MASTERS: dict[str, str] = { "CLAUDE.md": "Claude/CLAUDE.md", "AGENTS.md": "Claude/AGENTS.md", "DESIGN_DOCUMENT_PYTHON.md": "Python/DESIGN_DOCUMENT_PYTHON.md", "DESIGN_DOCUMENT_PYTHON_MODULE.md": "Python/DESIGN_DOCUMENT_PYTHON_MODULE.md", "DESIGN_DOCUMENT_RUST.md": "Rust/DESIGN_DOCUMENT_RUST.md", "DESIGN_DOCUMENT_RUST_LIB.md": "Rust/DESIGN_DOCUMENT_RUST_LIB.md", "DESIGN_DOCUMENT_GODOT.md": "Godot/DESIGN_DOCUMENT_GODOT.md", "DESIGN_DOCUMENT_API.md": "API/DESIGN_DOCUMENT_API.md", "DESIGN_DOCUMENT_WEBAPP.md": "WEB/DESIGN_DOCUMENT_WEBAPP.md", } # Names used before the language became part of the file name. A project still carrying one of these # is reported as LEGACY and renamed by --sync. This mapping is migration code: once every project has # been synchronised it can be deleted, and with it the last place where the old collision matters. LEGACY_NAMES: dict[str, str] = { "DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_PYTHON.md", "DESIGN_DOCUMENT_MODULE.md": "DESIGN_DOCUMENT_PYTHON_MODULE.md", "DESIGN_DOCUMENT_LIB.md": "DESIGN_DOCUMENT_RUST_LIB.md", } # The old DESIGN_DOCUMENT.md is ambiguous on its own - Python and Rust applications shared the name. # Only the project manifest can tell them apart, and only for legacy copies. LEGACY_RUST_NAMES: dict[str, str] = {"DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_RUST.md"} VERSION_PATTERN = re.compile(r"^\*\*Document Version:\*\*\s*(v\d+)", re.MULTILINE) # Where the master skills live in this repository, and where Claude Code actually runs them from. # The installed location is fixed - unlike guideline copies, it does not move with --root. SKILLS_SUBDIR = "Claude/skills" INSTALLED_SKILLS_DIR = Path.home() / ".claude" / "skills" FRONTMATTER_PATTERN = re.compile(r"\A---\n(.*?)\n---", re.DOTALL) SKILL_VERSION_PATTERN = re.compile(r"^\s*version:\s*(v\d+)\s*$", re.MULTILINE) STATUS_OK = "ok" STATUS_BEHIND = "behind" STATUS_MODIFIED = "modified" STATUS_UNVERSIONED = "unversioned" STATUS_LEGACY = "legacy" STATUS_DUPLICATE = "duplicate" STATUS_MISSING = "missing" @dataclass(frozen=True) class Result: """Outcome of comparing one project copy against its master.""" project: str document: str project_version: str | None master_version: str | None status: str copy_path: Path master_path: Path target_path: Path @property def needs_attention(self) -> bool: return self.status != STATUS_OK @property def can_sync(self) -> bool: """Behind, missing and legacy copies can be replaced; anything else may hold work we would destroy.""" return self.status in {STATUS_BEHIND, STATUS_LEGACY, STATUS_MISSING} @property def is_rename(self) -> bool: return self.copy_path != self.target_path def read_text(path: Path) -> str: """Read a document, tolerating the BOM some editors leave behind.""" return path.read_text(encoding="utf-8-sig") def normalise(text: str) -> str: """Strip line-ending differences so CRLF and LF copies compare equal.""" return text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") def read_version(path: Path) -> str | None: """Return the ``Document Version`` declared in a guideline document, if it has one.""" match = VERSION_PATTERN.search(read_text(path)) return match.group(1) if match else None def read_skill_version(path: Path) -> str | None: """Return the ``metadata.version`` declared in a skill's YAML frontmatter, if any. Deliberately restricted to the frontmatter block (between the first pair of ``---`` lines) so the word "version" appearing in a skill's instructions is never mistaken for the real field. """ frontmatter = FRONTMATTER_PATTERN.match(read_text(path)) if not frontmatter: return None match = SKILL_VERSION_PATTERN.search(frontmatter.group(1)) return match.group(1) if match else None def version_key(version: str | None) -> int: """Order versions numerically; a missing version sorts lowest.""" return int(version[1:]) if version else -1 def is_rust_project(project: Path) -> bool: return (project / "Cargo.toml").is_file() def detect_newline(path: Path) -> str: """Return the line ending a file already uses, so rewriting it does not churn every line.""" data = path.read_bytes() crlf = data.count(b"\r\n") lf = data.count(b"\n") - crlf if crlf and crlf >= lf: return "\r\n" if lf: return "\n" return os.linesep def sync_document(master: Path, copy: Path, target: Path) -> None: """Overwrite a project copy with its master, renaming it first when the file name changed. The copy keeps the line endings it already used: writing the master's bytes verbatim would flip every line on a project that uses the other convention, turning a one-line version bump into a whole-file diff. Renaming before writing lets git see one rename instead of a delete plus an add. """ newline = detect_newline(copy) backup = copy.with_suffix(copy.suffix + ".bak") shutil.copyfile(copy, backup) renamed = False try: if copy != target: os.replace(copy, target) renamed = True target.write_text(normalise(read_text(master)) + "\n", encoding="utf-8", newline=newline) except OSError: if renamed and target.exists(): os.replace(target, copy) shutil.copyfile(backup, copy) raise backup.unlink() def install_or_sync(master: Path, copy: Path, target: Path) -> None: """Install a missing file fresh, or overwrite an outdated one via :func:`sync_document`. A missing skill has no existing copy to detect line endings from or back up, so it is simply written out using the master's own line endings instead of going through the backup/restore dance that only makes sense when a previous version is there to protect. """ target.parent.mkdir(parents=True, exist_ok=True) if copy.is_file(): sync_document(master, copy, target) else: newline = detect_newline(master) target.write_text(normalise(read_text(master)) + "\n", encoding="utf-8", newline=newline) def load_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: """Map each document name to its master path and version.""" masters: dict[str, tuple[Path, str | None]] = {} for name, relative in MASTERS.items(): path = repo / relative if path.is_file(): masters[name] = (path, read_version(path)) return masters def check_project(project: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]: """Compare every guideline document present in one project against its master.""" results: list[Result] = [] for name, (master_path, master_version) in masters.items(): copy = project / name if not copy.is_file(): continue copy_version = read_version(copy) if copy_version is None or master_version is None: status = STATUS_UNVERSIONED elif version_key(copy_version) < version_key(master_version): status = STATUS_BEHIND elif normalise(read_text(copy)) != normalise(read_text(master_path)): # Same version number but different content - someone edited the copy in place. status = STATUS_MODIFIED else: status = STATUS_OK results.append(Result(project.name, name, copy_version, master_version, status, copy, master_path, copy)) results.extend(check_legacy(project, masters)) return results def check_legacy(project: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]: """Report copies still using a pre-rename file name, and where each one should move to.""" results: list[Result] = [] rust = is_rust_project(project) for old_name, new_name in LEGACY_NAMES.items(): copy = project / old_name if not copy.is_file(): continue if rust and old_name in LEGACY_RUST_NAMES: new_name = LEGACY_RUST_NAMES[old_name] target = project / new_name master_path, master_version = masters[new_name] # Both names present: the rename already happened and the old file is a leftover. Removing # it is not this script's call, so only report it. status = STATUS_DUPLICATE if target.is_file() else STATUS_LEGACY label = f"{old_name} -> {new_name}" results.append( Result(project.name, label, read_version(copy), master_version, status, copy, master_path, target) ) return results def find_skill_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: """Map each skill name (a folder under ``Claude/skills/``) to its master ``SKILL.md`` and version.""" masters: dict[str, tuple[Path, str | None]] = {} skills_dir = repo / SKILLS_SUBDIR if not skills_dir.is_dir(): return masters for entry in sorted(skills_dir.iterdir()): skill_file = entry / "SKILL.md" if entry.is_dir() and skill_file.is_file(): masters[entry.name] = (skill_file, read_skill_version(skill_file)) return masters def check_skills(masters: dict[str, tuple[Path, str | None]], installed_dir: Path) -> list[Result]: """Compare every master skill against its installed copy in ``~/.claude/skills/``.""" results: list[Result] = [] for name, (master_path, master_version) in masters.items(): installed_path = installed_dir / name / "SKILL.md" if not installed_path.is_file(): results.append( Result(name, "SKILL.md", None, master_version, STATUS_MISSING, installed_path, master_path, installed_path) ) continue installed_version = read_skill_version(installed_path) if installed_version is None or master_version is None: status = STATUS_UNVERSIONED elif version_key(installed_version) < version_key(master_version): status = STATUS_BEHIND elif normalise(read_text(installed_path)) != normalise(read_text(master_path)): # Same version number but different content - someone edited the installed copy in place. status = STATUS_MODIFIED else: status = STATUS_OK results.append( Result(name, "SKILL.md", installed_version, master_version, status, installed_path, master_path, installed_path) ) return results def find_projects(root: Path, repo: Path) -> list[Path]: """Return candidate project directories: siblings of this repository, excluding itself.""" return sorted( path for path in root.iterdir() if path.is_dir() and path.resolve() != repo.resolve() and not path.name.startswith(".") ) def format_row(result: Result) -> str: marker = { STATUS_OK: "ok ", STATUS_BEHIND: "BEHIND ", STATUS_MODIFIED: "MODIFIED ", STATUS_UNVERSIONED: "NO VER. ", STATUS_LEGACY: "OLD NAME ", STATUS_DUPLICATE: "DUPLICATE", STATUS_MISSING: "MISSING ", }[result.status] versions = f"{result.project_version or '-'} -> {result.master_version or '-'}" return f" {marker} {result.document:<50} {versions}" def confirm(prompt: str) -> bool: """Ask a yes/no question. Anything other than an explicit yes - including a closed stdin - is no.""" if not sys.stdin or not sys.stdin.isatty(): print(f"{prompt} [y/N] (stdin is not a terminal, assuming no; use --yes to copy)") return False try: return input(f"{prompt} [y/N] ").strip().lower() in {"y", "yes"} except (EOFError, KeyboardInterrupt): print() return False def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int: """Copy the masters over outdated or missing copies. Returns the number of items left stale.""" renames = sum(1 for result in pending if result.is_rename) print(f"{len(pending)} item(s) can be updated from this repository:") labels = {result: f"{result.project}/{result.document}" for result in pending} width = max(len(label) for label in labels.values()) for result in pending: note = " (rename)" if result.is_rename else "" print(f" {labels[result]:<{width}} {result.project_version or '-'} -> {result.master_version}{note}") print() if dry_run: print("Dry run - nothing was written.") return len(pending) action = f"Overwrite {len(pending)} file(s)" if renames: action += f", renaming {renames} of them" if not assume_yes and not confirm(f"{action}?"): print("Nothing was written.") return len(pending) failed = 0 for result in pending: try: install_or_sync(result.master_path, result.copy_path, result.target_path) except OSError as error: print(f" FAILED {result.project}/{result.document}: {error}", file=sys.stderr) failed += 1 else: if result.is_rename: verb = "renamed" elif result.status == STATUS_MISSING: verb = "installed" else: verb = "updated" print(f" {verb} {result.project}/{result.target_path.name} -> {result.master_version}") print() print(f"{len(pending) - failed} item(s) updated." if not failed else f"{failed} item(s) failed to update.") print("Review and commit the changes (a sync is its own commit; for a newly-installed skill,") print("there is nothing in ~/.claude/skills/ to commit, but consider committing it here too if new).") return failed def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( "--root", type=Path, default=None, help="directory holding the projects (default: the parent of this repository)", ) parser.add_argument("--quiet", action="store_true", help="list only projects that need attention") parser.add_argument("--sync", action="store_true", help="offer to overwrite outdated copies with the master") parser.add_argument("--yes", action="store_true", help="with --sync, copy without asking for confirmation") parser.add_argument("--dry-run", action="store_true", help="with --sync, show what would be copied and stop") args = parser.parse_args() repo = Path(__file__).resolve().parent root = (args.root or repo.parent).resolve() masters = load_masters(repo) if not masters: print(f"No master documents found in {repo}", file=sys.stderr) return 2 print(f"Master documents in {repo.name}:") width = max(len(name) for name in masters) for name, (_, version) in sorted(masters.items()): print(f" {name:<{width}} {version or '(no version header)'}") print() attention: list[Result] = [] unmanaged: list[str] = [] for project in find_projects(root, repo): results = check_project(project, masters) if not results: unmanaged.append(project.name) continue stale = [result for result in results if result.needs_attention] attention.extend(stale) if args.quiet and not stale: continue print(f"{project.name}") for result in results if not args.quiet else stale: print(format_row(result)) print() if unmanaged: print(f"No guideline documents: {', '.join(unmanaged)}") print() # Second, independent check: master skills in Claude/skills/ vs. what is actually installed in # ~/.claude/skills/. Unlike guideline copies this location is fixed, not relative to --root. skill_masters = find_skill_masters(repo) if skill_masters: print(f"Skill masters in {repo.name}/{SKILLS_SUBDIR}:") width = max(len(name) for name in skill_masters) for name, (_, version) in sorted(skill_masters.items()): print(f" {name:<{width}} {version or '(no version header)'}") print() skill_results = check_skills(skill_masters, INSTALLED_SKILLS_DIR) stale_skills = [result for result in skill_results if result.needs_attention] attention.extend(stale_skills) if not (args.quiet and not stale_skills): print(f"Installed in {INSTALLED_SKILLS_DIR}:") for result in skill_results if not args.quiet else stale_skills: print(f"{result.project}") print(format_row(result)) print() if not attention: print("Everything is up to date.") return 0 syncable = [result for result in attention if result.can_sync] blocked = len(attention) - len(syncable) if args.sync and syncable: remaining = run_sync(syncable, args.yes, args.dry_run) + blocked else: if syncable and not args.sync: print(f"Run with --sync to copy {len(syncable)} outdated item(s) from this repository.") remaining = len(attention) if blocked: print(f"{blocked} item(s) left alone - MODIFIED holds local edits, NO VER. cannot be compared,") print("and DUPLICATE means both the old and the new file name exist; remove the old one by hand.") if remaining: print(f"{remaining} item(s) need attention.") return 1 print("Everything is up to date.") return 0 if __name__ == "__main__": raise SystemExit(main())