Files
othmanadi__planning-with-files/scripts/sync-ide-folders.py
T
Dylan Pulver 5fddf55cda refactor: move language variants under skills/i18n so the plugin registers one skill
The Claude Code plugin scan reads skills/*/SKILL.md and does not recurse, so
each of the five language variants was registered for every plugin user and
paid its description in every session's system prompt. Measured with
claude plugin details on v3.10.1, that was about 1,010 of the plugin's
roughly 2,254 always-on tokens.

Moving the variants to skills/i18n/planning-with-files-<lang>/ takes them out
of the plugin scan while keeping every skill name, every skill-route install
command and every install destination unchanged: npx skills add resolves
--skill by name over a recursive scan and still installs to
~/.claude/skills/planning-with-files-<lang>/.

The five language slash commands can no longer invoke a plugin-registered
skill by name, so they now read the translated SKILL.md from the skill-route
path or the plugin marketplace path, which keeps the translated body and the
translated templates in play. They also state that the status tokens stay
literal English, since check-complete.sh matches them with grep -F.
2026-08-20 21:53:19 +02:00

446 lines
16 KiB
Python

#!/usr/bin/env python3
"""
sync-ide-folders.py — Syncs shared files from the canonical source
(skills/planning-with-files/) to all IDE-specific folders.
Run this from the repo root before releases:
python scripts/sync-ide-folders.py
What it syncs:
- Templates (findings.md, progress.md, task_plan.md)
- References (examples.md, reference.md)
- Scripts (check-complete.sh/.ps1, init-session.sh/.ps1, session-catchup.py)
What it NEVER touches:
- SKILL.md (IDE-specific frontmatter differs per IDE)
- IDE-specific files (hooks, prompts, package.json, steering files)
Use --dry-run to preview changes without writing anything.
Use --verify to check for drift without making changes (exits 1 if drift found).
"""
import argparse
import shutil
import sys
import hashlib
from pathlib import Path
# ─── Canonical source ──────────────────────────────────────────────
CANONICAL = Path("skills/planning-with-files")
# ─── Shared source files (relative to CANONICAL) ──────────────────
TEMPLATES = [
"templates/findings.md",
"templates/progress.md",
"templates/task_plan.md",
"templates/analytics_task_plan.md",
"templates/analytics_findings.md",
]
REFERENCES = [
"examples.md",
"reference.md",
]
SCRIPTS = [
"scripts/check-complete.sh",
"scripts/check-complete.ps1",
"scripts/init-session.sh",
"scripts/init-session.ps1",
"scripts/session-catchup.py",
"scripts/resolve-plan-dir.sh",
"scripts/resolve-plan-dir.ps1",
"scripts/set-active-plan.sh",
"scripts/set-active-plan.ps1",
"scripts/attest-plan.sh",
"scripts/attest-plan.ps1",
"scripts/plan-doctor.sh",
]
# Hook dispatch targets (issue #212): every hook-bearing SKILL.md dispatches
# its UserPromptSubmit/PreToolUse/PreCompact scalars to inject-plan.sh, and
# inject-plan.sh shells its sibling ledger-summary.sh in autonomous/gated mode.
# ledger-summary.sh in turn shells its sibling resolve-plan-dir.sh; when the
# resolver is missing it falls back to plan_dir="." and injects a false
# "phases: 0/0 complete" into an autonomous loop. All three must ship in every
# variant's own scripts/ dir or the dispatch resolves to nothing, or to a lying
# summary, on that host (tests/test_skill_hook_dispatch_parity.py pins this).
HOOK_DISPATCH_SCRIPTS = [
"scripts/inject-plan.sh",
"scripts/ledger-summary.sh",
"scripts/resolve-plan-dir.sh",
]
# .agents/ ships the FULL canonical surface (no IDE adapter layer exists to
# carry the rest): hook dispatchers, ledger tooling, and every template.
AGENTS_EXTRA_SCRIPTS = [
"scripts/gate-stop.sh",
"scripts/inject-plan.sh",
"scripts/ledger-append.sh",
"scripts/ledger-append.ps1",
"scripts/ledger-summary.sh",
"scripts/ledger-summary.ps1",
"scripts/phase-status.sh",
"scripts/phase-status.ps1",
]
AGENTS_EXTRA_TEMPLATES = [
"templates/task_plan_autonomous.md",
]
# ─── IDE sync manifests ───────────────────────────────────────────
# Each IDE maps: canonical_source_file -> target_path (relative to repo root)
# Only files listed here are synced. Everything else is untouched.
def _build_manifest(base, *, ref_style="flat", template_dirs=None,
include_scripts=True, extra_template_dirs=None,
extra_scripts=None):
"""Build a sync manifest for an IDE folder.
Args:
base: IDE skill folder path (e.g. ".gemini/skills/planning-with-files")
ref_style: "flat" = examples.md at root, "subdir" = references/examples.md
template_dirs: list of template subdirs (default: ["templates/"])
include_scripts: whether to sync scripts
extra_template_dirs: additional dirs to also receive template copies
extra_scripts: scripts beyond the shared SCRIPTS list (e.g. the hook
dispatch targets in HOOK_DISPATCH_SCRIPTS)
"""
manifest = {}
b = Path(base)
# Templates
if template_dirs is None:
template_dirs = ["templates/"]
for tdir in template_dirs:
for t in TEMPLATES:
filename = Path(t).name # e.g. "findings.md"
manifest[t] = str(b / tdir / filename)
# Extra template locations (e.g. assets/templates/ in codex, codebuddy)
if extra_template_dirs:
for tdir in extra_template_dirs:
for t in TEMPLATES:
filename = Path(t).name
manifest[f"{t}__extra_{tdir}"] = str(b / tdir / filename)
# References
if ref_style == "flat":
for r in REFERENCES:
manifest[r] = str(b / r)
elif ref_style == "subdir":
for r in REFERENCES:
manifest[r] = str(b / "references" / r)
# ref_style == "skip" means don't sync references (IDE uses custom format)
# Scripts
if include_scripts:
for s in SCRIPTS:
manifest[s] = str(b / s)
# Extra scripts (hook dispatch targets and other per-IDE additions)
if extra_scripts:
for s in extra_scripts:
manifest[s] = str(b / s)
return manifest
IDE_MANIFESTS = {
".cursor": _build_manifest(
".cursor/skills/planning-with-files",
ref_style="flat",
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
# Cursor hooks (.cursor/hooks/) are IDE-specific, not synced. The
# skill's own scripts/ ARE synced: the SKILL.md hook scalars dispatch
# to them (issue #212), so shipping none left the dispatch dead.
),
".gemini": _build_manifest(
".gemini/skills/planning-with-files",
ref_style="subdir",
include_scripts=True,
),
".codex": _build_manifest(
".codex/skills/planning-with-files",
ref_style="subdir",
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
# .openclaw, .kilocode, .adal, .agent removed in v2.24.0 (IDE audit)
# These IDEs use the standard Agent Skills spec — install via npx skills add
# .pi is not just another IDE mirror: this directory IS the root of the
# npm package `planning-with-files`, so whatever it contains is what
# `npm install planning-with-files` delivers. It shipped 12 of the 20
# canonical scripts, missing inject-plan.sh itself, which meant the npm
# channel carried a skill that could not inject a plan at all. Everything
# language-neutral is synced here for that reason.
# package.json and README.md stay npm-specific and are not synced.
".pi": _build_manifest(
".pi/skills/planning-with-files",
ref_style="flat",
include_scripts=True,
extra_scripts=[
"scripts/inject-plan.sh",
"scripts/gate-stop.sh",
"scripts/ledger-append.sh",
"scripts/ledger-append.ps1",
"scripts/ledger-summary.sh",
"scripts/ledger-summary.ps1",
"scripts/phase-status.sh",
"scripts/phase-status.ps1",
],
),
".continue": _build_manifest(
".continue/skills/planning-with-files",
ref_style="flat",
template_dirs=[], # Continue has no templates dir
include_scripts=True,
# .continue/prompts/ is IDE-specific, not synced
),
".codebuddy": _build_manifest(
".codebuddy/skills/planning-with-files",
ref_style="subdir",
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
".factory": _build_manifest(
".factory/skills/planning-with-files",
ref_style="skip", # Uses combined references.md, not synced
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
".opencode": _build_manifest(
".opencode/skills/planning-with-files",
ref_style="flat",
include_scripts=False,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
# Mastracode: templates/references maintained under .mastracode/; only the
# hook dispatch targets are synced from canonical.
".mastracode": _build_manifest(
".mastracode/skills/planning-with-files",
ref_style="skip",
template_dirs=[],
include_scripts=False,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
# Kiro: maintained under .kiro/ (skill + wrappers); not synced from canonical scripts/.
".kiro": {},
}
# Scripts the language variants ship in TRANSLATED form: check-complete and
# init-session carry localized user-facing output, and session-catchup.py
# carries localized recovery prose (verified: -de/session-catchup.py contains
# German text, five -ar scripts contain Arabic). include_scripts=True would
# overwrite all of them with the English canonical, so these are never synced
# and stay owned by their translators.
LANG_TRANSLATED_SCRIPTS = [
"scripts/check-complete.sh",
"scripts/check-complete.ps1",
"scripts/init-session.sh",
"scripts/init-session.ps1",
"scripts/session-catchup.py",
]
# Everything else the canonical skill ships. These 12 never existed in any
# variant, so delivering them is purely additive: non-English users were
# missing the completion gate, the ledger, phase-status, plan-doctor and
# attestation entirely, which is the drift issue #130 describes. Their few
# user-facing strings are English until a translator PR localizes them; a
# missing script is a silently dead feature, an English string is not.
LANG_EXTRA_SCRIPTS = HOOK_DISPATCH_SCRIPTS + [
"scripts/attest-plan.sh",
"scripts/attest-plan.ps1",
"scripts/gate-stop.sh",
"scripts/ledger-append.sh",
"scripts/ledger-append.ps1",
"scripts/ledger-summary.ps1",
"scripts/phase-status.sh",
"scripts/phase-status.ps1",
"scripts/plan-doctor.sh",
"scripts/resolve-plan-dir.ps1",
"scripts/set-active-plan.sh",
"scripts/set-active-plan.ps1",
]
# Language variants (skills/i18n/planning-with-files-<lang>/): SKILL.md, templates
# and references are real translations and must NEVER be overwritten with the
# English canonical. Same for LANG_TRANSLATED_SCRIPTS above. The rest of the
# canonical script surface is synced so non-English installs get the same
# features, not a subset (issue #212 covered only the 3 dispatch targets).
for _lang in ("ar", "de", "es", "zh", "zht"):
_base = f"skills/i18n/planning-with-files-{_lang}"
IDE_MANIFESTS[_base] = _build_manifest(
_base,
ref_style="skip",
template_dirs=[],
include_scripts=False,
extra_scripts=LANG_EXTRA_SCRIPTS,
)
def _build_agents_manifest():
"""Manifest for .agents/skills/ — the cross-tool Agent Skills standard.
Shipped in-repo so a plain git clone is natively discoverable by every
tool that reads .agents/skills/ (Zed, Amp, Warp, Devin, Antigravity,
Gemini CLI, Cursor). SKILL.md itself is version-bumped by bump-version.py
and deliberately not synced here, matching every other IDE folder.
"""
base = ".agents/skills/planning-with-files"
manifest = _build_manifest(base, ref_style="flat", include_scripts=True)
b = Path(base)
for s in AGENTS_EXTRA_SCRIPTS:
manifest[s] = str(b / s)
for t in AGENTS_EXTRA_TEMPLATES:
manifest[t] = str(b / "templates" / Path(t).name)
return manifest
IDE_MANIFESTS[".agents"] = _build_agents_manifest()
# ─── Utility functions ─────────────────────────────────────────────
def file_hash(path):
"""Return SHA-256 hash of a file, or None if it doesn't exist."""
try:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
except FileNotFoundError:
return None
def sync_file(src, dst, *, dry_run=False):
"""Copy src to dst. Returns (action, detail) tuple.
Actions: "updated", "created", "skipped" (already identical), "missing_src"
"""
if not src.exists():
return "missing_src", f"Canonical file not found: {src}"
src_hash = file_hash(src)
dst_hash = file_hash(dst)
if src_hash == dst_hash:
return "skipped", "Already up to date"
action = "created" if dst_hash is None else "updated"
if not dry_run:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return action, f"{'Would ' if dry_run else ''}{action}: {dst}"
# ─── Main ──────────────────────────────────────────────────────────
def parse_args(argv=None):
"""Parse CLI arguments for sync behavior."""
parser = argparse.ArgumentParser(
description=(
"Sync shared planning-with-files assets from canonical source "
"to IDE-specific folders."
)
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without writing files.",
)
parser.add_argument(
"--verify",
action="store_true",
help="Check for drift only; exit with code 1 if drift is found.",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
dry_run = args.dry_run
verify = args.verify
# Must run from repo root
if not CANONICAL.exists():
print(f"Error: Canonical source not found at {CANONICAL}/")
print("Run this script from the repo root.")
sys.exit(1)
print(f"{'[DRY RUN] ' if dry_run else ''}{'[VERIFY] ' if verify else ''}"
f"Syncing from {CANONICAL}/\n")
stats = {"updated": 0, "created": 0, "skipped": 0, "missing_src": 0, "drift": 0}
for ide_name, manifest in sorted(IDE_MANIFESTS.items()):
# Skip IDEs whose base directory doesn't exist
ide_root = Path(ide_name)
if not ide_root.exists():
continue
print(f" {ide_name}/")
ide_changes = 0
for canonical_key, target_path in sorted(manifest.items()):
# Handle __extra_ keys (canonical key contains __extra_ suffix)
canonical_rel = canonical_key.split("__extra_")[0]
src = CANONICAL / canonical_rel
dst = Path(target_path)
if verify:
# Verify mode: just check for drift
src_hash = file_hash(src)
dst_hash = file_hash(dst)
if src_hash and dst_hash and src_hash != dst_hash:
print(f" DRIFT: {dst}")
stats["drift"] += 1
ide_changes += 1
elif src_hash and not dst_hash:
print(f" MISSING: {dst}")
stats["drift"] += 1
ide_changes += 1
else:
action, detail = sync_file(src, dst, dry_run=dry_run)
stats[action] += 1
if action in ("updated", "created"):
print(f" {action.upper()}: {dst}")
ide_changes += 1
if ide_changes == 0:
print(" (up to date)")
# Summary
print(f"\n{'-' * 50}")
if verify:
total_drift = stats["drift"]
if total_drift > 0:
print(f"DRIFT DETECTED: {total_drift} file(s) out of sync.")
print("Run 'python scripts/sync-ide-folders.py' to fix.")
sys.exit(1)
else:
print("All IDE folders are in sync.")
sys.exit(0)
else:
print(f" Updated: {stats['updated']}")
print(f" Created: {stats['created']}")
print(f" Skipped: {stats['skipped']} (already up to date)")
if stats["missing_src"] > 0:
print(f" Missing: {stats['missing_src']} (canonical source not found)")
if dry_run:
print("\n This was a dry run. No files were modified.")
print(" Run without --dry-run to apply changes.")
if __name__ == "__main__":
main()