Add doubynet skill and extend check_versions.py to cover installed skills

This commit is contained in:
2026-10-07 17:44:00 +02:00
parent 39ad01fc09
commit 355a63fc43
4 changed files with 244 additions and 29 deletions
+145 -28
View File
@@ -1,18 +1,25 @@
"""Report sibling projects whose copies of the guideline documents are out of date, and offer to sync them.
"""Report stale copies of this repository's guideline documents and skills, and offer to sync them.
This repository is the source of truth for ``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 script walks the sibling directories of this
repository, reads the ``Document Version`` header of every copy it finds and reports the ones that
are behind, missing, or modified locally.
This repository is the source of truth for two independent things, both checked by this script:
It also finds 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``.
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``.
``--sync`` offers to overwrite the outdated copies with the master version, renaming the old names on
the way. Only ``BEHIND`` 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 name exist, which needs a human to decide.
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:
@@ -20,8 +27,8 @@ Standard library only, so it runs without a virtual environment:
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
python check_versions.py --quiet # print only projects needing attention
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
@@ -64,12 +71,21 @@ LEGACY_RUST_NAMES: dict[str, str] = {"DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_RUST
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)
@@ -91,8 +107,8 @@ class Result:
@property
def can_sync(self) -> bool:
"""Behind and legacy copies can be replaced; anything else may hold work we would destroy."""
return self.status in {STATUS_BEHIND, STATUS_LEGACY}
"""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:
@@ -115,6 +131,19 @@ def read_version(path: Path) -> str | None:
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
@@ -160,6 +189,21 @@ def sync_document(master: Path, copy: Path, target: Path) -> None:
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]] = {}
@@ -224,6 +268,51 @@ def check_legacy(project: Path, masters: dict[str, tuple[Path, str | None]]) ->
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(
@@ -241,6 +330,7 @@ def format_row(result: Result) -> str:
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}"
@@ -259,14 +349,14 @@ def confirm(prompt: str) -> bool:
def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int:
"""Copy the masters over the outdated project copies. Returns the number of documents left stale."""
"""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)} document(s) can be updated from this repository:")
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} -> {result.master_version}{note}")
print(f" {labels[result]:<{width}} {result.project_version or '-'} -> {result.master_version}{note}")
print()
if dry_run:
@@ -283,17 +373,23 @@ def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int:
failed = 0
for result in pending:
try:
sync_document(result.master_path, result.copy_path, result.target_path)
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:
verb = "renamed" if result.is_rename else "updated"
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} document(s) updated." if not failed else f"{failed} document(s) failed to update.")
print("Review and commit the changes in each project (a sync is its own commit).")
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
@@ -349,8 +445,29 @@ def main() -> int:
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("All projects are up to date.")
print("Everything is up to date.")
return 0
syncable = [result for result in attention if result.can_sync]
@@ -360,18 +477,18 @@ def main() -> int:
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 document(s) from this repository.")
print(f"Run with --sync to copy {len(syncable)} outdated item(s) from this repository.")
remaining = len(attention)
if blocked:
print(f"{blocked} document(s) left alone - MODIFIED holds local edits, NO VER. cannot be compared,")
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} document(s) need attention.")
print(f"{remaining} item(s) need attention.")
return 1
print("All projects are up to date.")
print("Everything is up to date.")
return 0