Add doubynet skill and extend check_versions.py to cover installed skills
This commit is contained in:
@@ -7,6 +7,32 @@ ships documents, so it uses the same `v#` scheme as the documents themselves.
|
||||
Individual guideline documents carry their own `Document Version` header, and the skills carry
|
||||
`metadata.version`; both are independent of the revisions below.
|
||||
|
||||
## v9 — 2026-10-07
|
||||
|
||||
### Added
|
||||
- `Claude/skills/doubynet/` — new personal skill, reconciles the DoubyNet home-network
|
||||
documentation (a separate Syncthing-synced tree, not part of this repository) against
|
||||
the live state of its hosts, reports discrepancies, and updates the affected `.md`
|
||||
files. Versioned `v1`, following the established `metadata.version` convention. Was
|
||||
already in use directly from `~/.claude/skills/doubynet/` without a master copy here;
|
||||
brought in after a session surfaced several gaps (stale hardcoded path, unclear
|
||||
historical-vs-live document handling, no mention of CHANGELOG.md, no guidance for
|
||||
facts that can't be verified from a machine at all) that were fixed in the same pass
|
||||
- `check_versions.py` now also checks the master skills in `Claude/skills/<name>/SKILL.md`
|
||||
against their installed copies in `~/.claude/skills/<name>/SKILL.md` — a fixed location,
|
||||
independent of `--root`. Reads `metadata.version` from the YAML frontmatter instead of
|
||||
the `**Document Version:**` marker used by guideline documents. A skill missing from
|
||||
`~/.claude/skills/` entirely is reported as a new status, `MISSING`, and is safe to
|
||||
`--sync` like `BEHIND`. Prompted by running the tool for the first time since `doubynet`
|
||||
was added: it found four of the five pre-existing skills installed without any version
|
||||
header at all, and `documentation_context` missing from `~/.claude/skills/` entirely
|
||||
|
||||
### Changed
|
||||
- `--sync`/report wording generalised from "document(s)" to "item(s)" now that the same
|
||||
machinery covers both guideline documents and skills
|
||||
|
||||
---
|
||||
|
||||
## v8 — 2026-09-29
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: doubynet
|
||||
description: Update doubynet dokumentation
|
||||
metadata:
|
||||
version: v1
|
||||
---
|
||||
|
||||
Tvým úkolem je zkontrolovat a případně aktualizovat dokumentaci sítě DoubyNet.
|
||||
|
||||
## 1. Načti dokumentaci
|
||||
`/home/honza/Syncthing/Programy/DoubyNet/` (lokální, Syncthing-synchronizovaná
|
||||
složka na Honza-PCL — žádný manuální upload/download, synchronizace na ostatní
|
||||
zařízení běží na pozadí sama):
|
||||
- `README.md` (index služeb + sekce "Router — port-forwarding")
|
||||
- `CLAUDE.md` (konvence zápisu dokumentace — dodržuj ji při jakékoli úpravě)
|
||||
- všechny `<Hostname>/<Služba>/<Služba>.md` soubory
|
||||
- `<Hostname>/CHANGELOG.md` (jeden na hosta, průběžný záznam změn — viz krok 4)
|
||||
|
||||
Vytvoř si z toho kompletní obraz zdokumentovaného stavu: hosty, služby, jak se spouští,
|
||||
sítě/porty, VM, autostart, propojení mezi službami (např. Caddy → cíl).
|
||||
|
||||
**Pozor na historické dokumenty.** Některé `.md` soubory popisují něco, co už
|
||||
neexistuje (např. staré, fyzicky rozebrané disky/pole), a mají to nahoře jasně
|
||||
označené (banner typu "⚠️ pole už neexistuje, dokument je od teď čistě
|
||||
historický"). Takový dokument **needituj aktuálními fakty** — zjištěné aktuální
|
||||
informace patří do dokumentu, který nahrazuje popisovanou věc (banner na to
|
||||
obvykle přímo odkazuje). Smysl historického dokumentu je zůstat nezměněný jako
|
||||
záznam toho, co bylo.
|
||||
|
||||
## 2. Ověř realitu
|
||||
Pro každé tvrzení v dokumentaci ověř skutečný stav na příslušném stroji (SSH, `systemctl`,
|
||||
`virsh`, `ss -tlnp`, obsah configů apod.). Ověřuj z relevantního hlediska — např. dostupnost
|
||||
webu přes DNAT/reverse proxy testuj z jiného stroje než je cíl (self-test ze stejného hostu
|
||||
může dát falešně negativní výsledek kvůli tomu, jak netfilter zachází s lokálně generovaným
|
||||
provozem).
|
||||
|
||||
**Některá fakta nejdou ověřit žádným příkazem** — typicky cokoliv, co žije jen v UI
|
||||
zařízení bez API/SSH přístupu (např. nastavení port-forwardingu na routeru). V
|
||||
takovém případě si o autoritativní export/screenshot řekni uživateli přímo, místo
|
||||
abys realitu odhadoval nebo nechal needokumentovanou.
|
||||
|
||||
Neprováděj destruktivní ani produkci ovlivňující příkazy bez potvrzení uživatele. Pokud
|
||||
narazíš na to, že nemáš potřebná oprávnění (sudo, SSH klíč), zeptej se uživatele, jak
|
||||
pokračovat — nezkoušej to obcházet.
|
||||
|
||||
## 3. Porovnej a nahlas rozdíly
|
||||
Sestav přehledný seznam nesrovnalostí mezi dokumentací a realitou (co dokument tvrdí vs.
|
||||
co je skutečně nastavené/běžící). Tenhle přehled ukaž uživateli **před** jakoukoli úpravou
|
||||
souborů.
|
||||
|
||||
## 4. Aktualizuj dokumentaci
|
||||
Až uživatel potvrdí (nebo pokud jde o triviální/jednoznačné doplnění), uprav příslušné
|
||||
`.md` soubory tak, aby odpovídaly realitě:
|
||||
- Dodržuj šablonu a konvence z `CLAUDE.md` (formát tabulek, PascalCase názvy, atd.)
|
||||
- Nikdy nezapisuj hesla/tokeny/klíče v plaintextu — nahraď je `<PLACEHOLDER>` tokenem
|
||||
- Pokud přidáváš novou službu/VM, přidej ji i do řádku v `README.md`
|
||||
- Fakta, co se netýkají jedné služby na jednom hostu (např. síťová topologie,
|
||||
port-forwarding na routeru), patří do vlastní sekce v `README.md` — nenuť je
|
||||
do složky jedné služby jen proto, že model "jedna složka = jedna služba" jinam
|
||||
nesedí
|
||||
- Zapiš stručný záznam do `<Hostname>/CHANGELOG.md` dotčeného hosta (nejnovější
|
||||
nahoře) — co se zjistilo, co se změnilo, proč
|
||||
- Po úpravách ověř, že žádný relativní markdown odkaz (`](...)`) nevede na
|
||||
neexistující soubor — procházka stromem a kontrola existence cíle každého odkazu
|
||||
|
||||
Žádný manuální "upload" na server není potřeba — soubory žijí lokálně v Syncthing
|
||||
složce, synchronizace běží sama.
|
||||
|
||||
Na konci stručně shrň, co bylo nekonzistentní a co jsi opravil.
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ Dokumentace/
|
||||
│ ├── check/ # /check — run tests, investigate failures
|
||||
│ ├── commit/ # /commit — changelog + commit message proposal
|
||||
│ ├── documentation/ # /documentation — write docs/
|
||||
│ └── documentation_context/ # /documentation_context — compress docs/ into CONTEXT.md
|
||||
│ ├── documentation_context/ # /documentation_context — compress docs/ into CONTEXT.md
|
||||
│ └── doubynet/ # /doubynet — reconcile DoubyNet network docs with reality
|
||||
│
|
||||
├── Python/ # Python development guidelines
|
||||
│ ├── DESIGN_DOCUMENT_PYTHON.md # Guidelines for Python applications
|
||||
|
||||
+145
-28
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user