Files
Dokumentace/check_versions.py
T

497 lines
20 KiB
Python

"""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/<name>/SKILL.md``, whose installed copies Claude Code
actually runs from ``~/.claude/skills/<name>/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())