diff --git a/HERMES-PORT.md b/HERMES-PORT.md new file mode 100644 index 00000000..362c0515 --- /dev/null +++ b/HERMES-PORT.md @@ -0,0 +1,84 @@ +# Hermes Port — validate-skills & committer (Python) + +Port Python dari script upstream, dipakai untuk kolam skill Hermes kita +(`~/.hermes/skills`, multi-kategori bersarang). + +**Kenapa ada:** script upstream `validate-skills` ditulis Ruby dan hanya memindai +pola flat `skills/*/SKILL.md`. Server Hermes kita **tidak punya Ruby**, dan struktur +skill kita bersarang (`/[/]/SKILL.md`). Jadi di-port +ke Python + pola glob disesuaikan. + +Catatan: `scripts/committer` **sudah tidak ada** di upstream (diganti `sync-skills`). +Versi di sini dibuat dari filosofi aslinya, disesuaikan alur kerja kita. + +## Isi + +| File | Fungsi | +|---|---| +| `scripts/validate-skills-python` | Validasi semua SKILL.md (front matter, `name`, `description`, nama kembar, anti-pola `gh secret --body -`) | +| `scripts/committer` | Commit aman: stage eksplisit, pesan wajib, validasi dulu, tolak file rahasia | +| `hooks/pre-commit-python` | Hook git: jalankan validator sebelum commit | + +## Pemakaian + +```bash +# validasi seluruh kolam +python3 scripts/validate-skills-python --root ~/.hermes/skills + +# hanya file yang berubah (cepat) +python3 scripts/validate-skills-python --only-changed --json + +# commit aman (stage EKSPLISIT — tidak akan menyedot file sampah) +python3 scripts/committer --repo -m "pesan" file1 file2 + +# lihat rencana tanpa commit +python3 scripts/committer --repo -m "pesan" --dry-run file1 + +# pasang hook +git config core.hooksPath /opt/data/scripts/hooks +``` + +Exit code: `0` lolos, `1` ada error, `2` salah pemakaian. + +## Yang diperiksa validator + +1. Front matter ada, mulai baris 1, ditutup `---`, valid YAML mapping +2. `name` + `description` string non-kosong +3. **`name` duplikat lintas skill** — paling sering ketemu di kolam besar +4. `description` berupa block scalar (`|`) yang gagal parse +5. Anti-pola `gh secret set ... --body -` (dari upstream) + +## Temuan nyata saat pertama dipakai + +Validasi 491 skill Hermes langsung menemukan 2 bug yang tidak terdeteksi sebelumnya: + +1. **YAML rusak** — satu skill menaruh `Source: repo/name` di dalam `description` + tanpa tanda kutip → YAML gagal parse, skill itu tidak pernah terbaca router. + *Fix:* kutip `description` yang mengandung tanda `:`. + +2. **Nama skill kembar** — `seo-audit` ada di dua kategori berbeda (workflow umum + vs varian claude-seo). Nama skill harus unik **global**, bukan cuma per kategori. + *Fix:* rename folder + field `name`. + +## Pitfall + +- **Jangan `cp -a` folder skill ke dalam folder skill lain.** Backup bisa nyasar + jadi `///` — validator lapor nama kembar dari file tak + terduga dan `references/` ikut kebawa. Backup SELALU ke luar tree (`/opt/backups/`). +- **Folder `skills/skills/`** di dalam kolam = sisa migrasi, isinya salinan kolam. + Berbahaya: salinannya bisa memuat skill yang sudah tidak ada di tree utama. + Hitung dulu mana yang unik sebelum membuang. +- **`.archive/`** — banyak skill "hilang" ternyata sengaja diarsipkan. Cek dulu. + +## Protokol bersih-bersih kolam (terbukti) + +1. Backup ke luar tree: `cp -a /opt/backups/skills_cleanup_$(date +%Y%m%d_%H%M%S)/` +2. Bangun daftar nama skill sebelum & sesudah (`os.walk` cari `SKILL.md`) +3. Kalau membuang duplikat: hitung dulu mana yang **cuma ada di salinan** +4. Yang unik → pulihkan ke `root//` (pertahankan kategori) +5. Verifikasi: `set(before) - set(after)` HARUS kosong +6. Baru jalankan validator — harus exit 0 + +## Lisensi + +Mengikuti lisensi repo upstream (MIT). Lihat `LICENSE`. diff --git a/hooks/pre-commit-python b/hooks/pre-commit-python new file mode 100755 index 00000000..d28edf81 --- /dev/null +++ b/hooks/pre-commit-python @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# pre-commit: validasi SKILL.md sebelum commit (adaptasi steipete/agent-scripts). +# Pasang dengan: git config core.hooksPath /opt/data/scripts/hooks +set -euo pipefail +SKILLS_ROOT="${HERMES_SKILLS_ROOT:-$HOME/.hermes/skills}" +exec python3 /opt/data/scripts/validate_skills.py --root "$SKILLS_ROOT" --quiet diff --git a/scripts/committer b/scripts/committer new file mode 100755 index 00000000..5ca6a896 --- /dev/null +++ b/scripts/committer @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +committer.py — Commit aman: stage file eksplisit, pesan wajib, validasi dulu. + +Hasil adaptasi dari scripts/committer milik steipete/agent-scripts +(https://github.com/steipete/agent-scripts, MIT) — di-port ke Python dan +disesuaikan dengan alur kerja kita. + +Filosofi (sama seperti aslinya): + - TIDAK pernah `git add -A` / `git add .` — cuma file yang benar-benar disebut. + Ini mencegah commit sampah (file backup, .env, output cron, dsb) ikut masuk. + - Pesan commit WAJIB non-kosong. + - Validasi dijalankan DULU sebelum commit; gagal = batal. + +Usage: + committer.py -m "pesan commit" [file2 ...] + committer.py -m "pesan" --all-changed # semua file yg sudah di-stage/diubah + committer.py -m "pesan" --skills # shorthand: file di dalam kolam skill + committer.py -m "pesan" --dry-run # cuma tampilkan rencana + +Opsi: + --repo DIR repo target (default: cwd) + --skills DIR root kolam skill (default: ~/.hermes/skills) + --skip-validate lewati validasi skill (jarang dipakai) + --no-push commit saja, jangan push + --allow-empty-msg TIDAK disediakan — pesan wajib. Ini memang disengaja. + +Exit code: 0 = sukses, 1 = gagal, 2 = salah pemakaian. +""" +import argparse +import os +import subprocess +import sys + +SKILL_VALIDATOR = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "validate_skills.py") +DEFAULT_SKILLS = os.environ.get("HERMES_SKILLS_ROOT", + os.path.expanduser("~/.hermes/skills")) + +# Pola file yang TIDAK boleh ikut commit walau disebut (jaring pengaman) +DENY_PATTERNS = (".env", ".env.", "credentials", "id_rsa", ".pem", ".key", + ".bak-", ".bak.", "~") + + +def run(cmd, cwd=None, check=True, capture=True): + r = subprocess.run(cmd, cwd=cwd, capture_output=capture, text=True) + if check and r.returncode != 0: + raise RuntimeError( + f"perintah gagal ({r.returncode}): {' '.join(cmd)}\n" + f"stdout: {r.stdout}\nstderr: {r.stderr}") + return r + + +def in_repo(path): + try: + run(["git", "-C", path, "rev-parse", "--is-inside-work-tree"]) + return True + except Exception: + return False + + +def deny_check(files, repo): + """Tolak file yang kelihatan seperti rahasia.""" + bad = [] + for f in files: + base = os.path.basename(f) + if any(p in base for p in DENY_PATTERNS) or base.startswith(".env"): + bad.append(f) + return bad + + +def changed_files(repo): + r = run(["git", "-C", repo, "diff", "--name-only", "HEAD"], check=False) + return [os.path.join(repo, p) for p in r.stdout.split() if p] + + +def main(): + ap = argparse.ArgumentParser( + description="Commit aman: stage eksplisit + pesan wajib + validasi dulu.") + ap.add_argument("files", nargs="*", help="file yang akan di-commit (eksplisit)") + ap.add_argument("-m", "--message", required=True, help="pesan commit (wajib)") + ap.add_argument("--repo", default=os.getcwd(), help="repo target (default: cwd)") + ap.add_argument("--skills", default=DEFAULT_SKILLS, + help=f"root kolam skill (default: {DEFAULT_SKILLS})") + ap.add_argument("--all-changed", action="store_true", + help="commit semua file yang berubah (hati-hati!)") + ap.add_argument("--skip-validate", action="store_true", + help="lewati validasi skill") + ap.add_argument("--no-push", action="store_true", help="commit saja, tanpa push") + ap.add_argument("--dry-run", action="store_true", help="tampilkan rencana saja") + args = ap.parse_args() + + repo = os.path.abspath(os.path.expanduser(args.repo)) + + # --- 1. pesan wajib --- + msg = (args.message or "").strip() + if not msg: + print("error: pesan commit wajib diisi dan tidak boleh kosong", file=sys.stderr) + return 2 + + # --- 2. repo valid? --- + if not in_repo(repo): + print(f"error: '{repo}' bukan git repository", file=sys.stderr) + return 2 + + # --- 3. tentukan file --- + files = list(args.files) + if args.all_changed: + files = changed_files(repo) + print(f"⚠️ --all-changed: {len(files)} file berubah ikut di-commit") + if not files: + print("error: tidak ada file yang disebut. Pakai --all-changed kalau memang mau semua.", + file=sys.stderr) + return 2 + + # normalisasi path relatif terhadap repo + norm = [] + for f in files: + p = f if os.path.isabs(f) else os.path.join(repo, f) + p = os.path.abspath(p) + if not os.path.exists(p): + print(f"error: file tidak ada: {f}", file=sys.stderr) + return 1 + norm.append(p) + files = sorted(set(norm)) + + # --- 4. jaring pengaman rahasia --- + bad = deny_check(files, repo) + if bad: + print("error: file berikut kelihatan berisi rahasia, DITOLAK:", file=sys.stderr) + for b in bad: + print(f" - {os.path.relpath(b, repo)}", file=sys.stderr) + return 1 + + # --- 5. validasi skill (kalau ada yang tersentuh) --- + skills_touched = [f for f in files + if os.path.abspath(args.skills) in os.path.abspath(f)] + if skills_touched and not args.skip_validate: + if os.path.exists(SKILL_VALIDATOR): + print(f"→ validasi skill ({len(skills_touched)} file tersentuh)...") + r = subprocess.run([sys.executable, SKILL_VALIDATOR, "--root", args.skills, + "--quiet"], capture_output=True, text=True) + if r.returncode != 0: + print("error: validasi skill GAGAL — commit dibatalkan:", file=sys.stderr) + print(r.stderr or r.stdout, file=sys.stderr) + return 1 + print(" validasi OK") + else: + print(f"⚠️ validator tidak ada di {SKILL_VALIDATOR} — dilewati") + + # --- 6. tampilkan rencana --- + print(f"\nrepo : {repo}") + print(f"pesan : {msg}") + print(f"file : {len(files)}") + for f in files: + print(f" + {os.path.relpath(f, repo)}") + + if args.dry_run: + print("\n(dry-run — tidak ada yang di-commit)") + return 0 + + # --- 7. stage EKSPLISIT (satu per satu, bukan -A) --- + for f in files: + r = run(["git", "-C", repo, "add", "--", f], check=False) + if r.returncode != 0: + print(f"error: gagal stage {f}: {r.stderr}", file=sys.stderr) + return 1 + + # --- 8. commit --- + r = run(["git", "-C", repo, "commit", "-m", msg], check=False) + if r.returncode != 0: + print(f"error: commit gagal:\n{r.stdout}\n{r.stderr}", file=sys.stderr) + return 1 + print(r.stdout.strip()) + + # --- 9. push --- + if not args.no_push: + r = run(["git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD"], check=False) + branch = r.stdout.strip() + if branch and branch not in ("HEAD", ""): + r = run(["git", "-C", repo, "push", "origin", branch], check=False) + if r.returncode != 0: + print(f"⚠️ commit OK tapi push gagal:\n{r.stderr}", file=sys.stderr) + return 1 + print(f"→ pushed ke origin/{branch}") + else: + print("⚠️ detached HEAD — push dilewati") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-skills-py b/scripts/validate-skills-py new file mode 100755 index 00000000..4f175a65 --- /dev/null +++ b/scripts/validate-skills-py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +validate-skills (Python port) — validate SKILL.md front matter. + +Why this exists +--------------- +The original `scripts/validate-skills` is Ruby. Ruby is not installed on every +machine (notably Linux servers / CI images that only ship Python), so the +pre-commit hook silently cannot run there. This is a dependency-light Python +port that behaves the same where it matters and adds one check that matters +for large skill pools. + +Behavioral parity with the Ruby version +--------------------------------------- + * front matter must start on line 1 and close with `---` + * front matter must parse as a YAML mapping + * `name` and `description` must be non-empty strings + * duplicate `name` across skills is an error (NEW: cross-directory, see below) + * `gh secret set ... --body -` is rejected (must read stdin) + +Differences from the Ruby version +--------------------------------- + * Discovery is recursive instead of `skills/*/SKILL.md`. Pools that nest + skills by category (`/[/]/SKILL.md`) are + common, and the flat glob silently validates nothing there. + * Duplicate `name` detection is global. `name` is the routing key, so two + skills sharing one name is a routing collision regardless of directory. + * A `description` that is a YAML block scalar (`|`) which failed to parse is + reported specifically, instead of as a generic "missing description". + +Usage +----- + scripts/validate-skills-py [--root DIR] [--only-changed] [--json] [--quiet] + + --root DIR skill pool root (default: $HERMES_SKILLS_ROOT or ./skills) + --only-changed only check files changed vs HEAD (fast pre-commit mode) + --json machine-readable output + --quiet print nothing unless something fails + +Exit codes: 0 = pass, 1 = errors found, 2 = bad usage. +""" +import argparse +import json +import os +import re +import subprocess +import sys + +DEFAULT_ROOT = os.environ.get("SKILLS_ROOT", "skills") +SKIP_DIRS = {".archive", ".git", "__pycache__", "node_modules", ".curator_backups"} + +FM_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.S) + +GH_SECRET_BODY_DASH = re.compile( + r"\bgh[ \t]+secret[ \t]+set\b[^;\r\n|&]{0,500}?--body[ \t]+-(?=$|[\s`'\";|&)\\]])" +) + +try: + import yaml + HAVE_YAML = True +except ImportError: + HAVE_YAML = False + + +def find_skill_files(root): + out = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + if "SKILL.md" in filenames: + out.append(os.path.join(dirpath, "SKILL.md")) + return sorted(out) + + +def load_front_matter(text): + m = FM_RE.match(text) + if not m: + return None, "front matter must start on the first line and close with ---" + raw = m.group(1) + if HAVE_YAML: + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + return None, f"YAML: {e}" + else: + data = {} + for line in raw.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if re.match(r"^[ \t]", line): + continue + mm = re.match(r"^([A-Za-z0-9_.-]+):[ \t]*(.*)$", line) + if mm: + data[mm.group(1)] = mm.group(2) + if not isinstance(data, dict): + return None, "front matter must be a YAML mapping" + return data, None + + +def gh_secret_body_dash_line(text): + lines = text.splitlines() + for i, line in enumerate(lines): + if GH_SECRET_BODY_DASH.search(line): + return i + 1 + for i in range(len(lines) - 1): + merged = lines[i] + " " + lines[i + 1] + m = GH_SECRET_BODY_DASH.search(merged) + if m and m.start() < len(lines[i]) < m.end(): + return i + 1 + return None + + +def is_block_scalar(val): + return isinstance(val, str) and val.strip() in ("|", ">", "|-", ">-", "|+", ">+") + + +def validate(root, only_changed=False): + files = find_skill_files(root) + changed = None + if only_changed: + try: + r = subprocess.run(["git", "-C", root, "diff", "--name-only", "HEAD"], + capture_output=True, text=True, timeout=20) + if r.returncode == 0: + changed = {os.path.join(root, p) for p in r.stdout.split() if p} + except Exception: + changed = None + + errors, names, checked = [], {}, 0 + for path in files: + if changed is not None and path not in changed: + continue + checked += 1 + rel = os.path.relpath(path, root) + try: + text = open(path, encoding="utf-8", errors="replace").read() + except OSError as e: + errors.append((rel, f"cannot read: {e}")) + continue + + bad = gh_secret_body_dash_line(text) + if bad: + errors.append((rel, f"line {bad}: `gh secret set` must read stdin by " + "omitting `--body`; never use `--body -`")) + + data, err = load_front_matter(text) + if err: + errors.append((rel, err)) + continue + + for field in ("name", "description"): + val = data.get(field) + if isinstance(val, str) and val.strip(): + continue + if field == "description" and is_block_scalar(data.get(field)): + errors.append((rel, "description is a YAML block scalar that did not " + "parse — quote it on a single line")) + else: + errors.append((rel, f"missing non-empty string field: {field}")) + + name = data.get("name") + if isinstance(name, str) and name.strip(): + n = name.strip() + if n in names: + errors.append((rel, f"duplicate skill name {n!r} also used by {names[n]}")) + else: + names[n] = rel + + return checked, len(files), errors + + +def main(): + ap = argparse.ArgumentParser(description="Validate SKILL.md front matter.") + ap.add_argument("--root", default=DEFAULT_ROOT) + ap.add_argument("--only-changed", action="store_true") + ap.add_argument("--json", action="store_true") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + root = os.path.abspath(os.path.expanduser(args.root)) + if not os.path.isdir(root): + print(f"error: root not found: {root}", file=sys.stderr) + return 2 + + checked, total, errors = validate(root, args.only_changed) + + if args.json: + print(json.dumps({"root": root, "total_found": total, "checked": checked, + "ok": not errors, + "errors": [{"file": f, "error": e} for f, e in errors]}, + indent=1)) + return 0 if not errors else 1 + + if total == 0: + print(f"No SKILL.md found under {root}", file=sys.stderr) + return 0 + + if errors: + print("Skill validation failed:", file=sys.stderr) + for f, e in errors: + print(f"- {f}: {e}", file=sys.stderr) + return 1 + + if not args.quiet: + print(f"Validated {checked} skill(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-skills-python b/scripts/validate-skills-python new file mode 100755 index 00000000..4df47cfb --- /dev/null +++ b/scripts/validate-skills-python @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +validate_skills.py — Validator SKILL.md untuk kolam skill bersama. + +Hasil adaptasi dari scripts/validate-skills milik steipete/agent-scripts +(https://github.com/steipete/agent-scripts, MIT) — di-port dari Ruby ke Python +dan disesuaikan dengan struktur kita yang multi-kategori bersarang +(: /[/]/SKILL.md), bukan flat skills/*/. + +Yang diperiksa per SKILL.md: + 1. Front matter ada, mulai di baris 1, ditutup '---', dan valid YAML mapping + 2. field 'name' : string non-kosong + 3. field 'description': string non-kosong + 4. 'name' duplikat lintas skill (mengindikasikan salinan ganda / skill kembar) + 5. Anti-pola: `gh secret set ... --body -` (harus baca stdin tanpa --body) + +Usage: + validate_skills.py [--root DIR] [--quiet] [--json] [--only-changed] + validate_skills.py --help + +Exit code: 0 = lolos, 1 = ada error, 2 = salah pemakaian. +""" +import argparse +import json +import os +import re +import subprocess +import sys + +DEFAULT_ROOT = os.environ.get("HERMES_SKILLS_ROOT", os.path.expanduser("~/.hermes/skills")) +SKIP_DIRS = {".archive", ".git", "__pycache__", "node_modules"} + +# Front matter: wajib mulai baris 1, ditutup --- di baris sendiri +FM_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.S) + +# gh secret set ... --body - (harus baca stdin, jangan pakai --body -) +GH_SECRET_BODY_DASH = re.compile( + r"\bgh[ \t]+secret[ \t]+set\b[^;\r\n|&]{0,500}?--body[ \t]+-(?=$|[\s`'\";|&)\\]])" +) + +try: + import yaml # PyYAML + HAVE_YAML = True +except ImportError: + HAVE_YAML = False + + +def find_skill_files(root): + """Kumpulkan semua SKILL.md, lewati dir yang di-skip.""" + out = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + if "SKILL.md" in filenames: + out.append(os.path.join(dirpath, "SKILL.md")) + return sorted(out) + + +def load_front_matter(text): + """Kembalikan (dict, None) atau (None, pesan_error).""" + m = FM_RE.match(text) + if not m: + return None, "front matter must start on the first line and close with ---" + raw = m.group(1) + if HAVE_YAML: + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + return None, f"YAML: {e}" + else: + # fallback parser minimal (tanpa PyYAML): key: value satu baris + data = {} + for line in raw.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if re.match(r"^[ \t]", line): # lanjutan/nested — lewati + continue + mm = re.match(r"^([A-Za-z0-9_.-]+):[ \t]*(.*)$", line) + if mm: + data[mm.group(1)] = mm.group(2) + if not isinstance(data, dict): + return None, "front matter must be a YAML mapping" + return data, None + + +def gh_secret_body_dash_line(text): + """Cari baris yang melanggar aturan gh secret set --body -.""" + lines = text.splitlines() + for i, line in enumerate(lines): + if GH_SECRET_BODY_DASH.search(line): + return i + 1 + for i in range(len(lines) - 1): + merged = lines[i] + " " + lines[i + 1] + m = GH_SECRET_BODY_DASH.search(merged) + if m and m.start() < len(lines[i]) < m.end(): + return i + 1 + return None + + +def is_placeholder_desc(desc): + """Deteksi description '|' / '>' (multiline YAML yang gagal ke-parse).""" + d = (desc or "").strip().strip("\"'").strip() + return d in ("", "|", ">", "|-", ">-", "|+", ">+") + + +def validate(root, only_changed=False): + files = find_skill_files(root) + changed = None + if only_changed: + try: + r = subprocess.run( + ["git", "-C", root, "diff", "--name-only", "HEAD"], + capture_output=True, text=True, timeout=20, + ) + if r.returncode == 0: + changed = {os.path.join(root, p) for p in r.stdout.split() if p} + except Exception: + changed = None + + errors = [] + names = {} + checked = 0 + + for path in files: + if changed is not None and path not in changed: + continue + checked += 1 + rel = os.path.relpath(path, root) + try: + text = open(path, encoding="utf-8", errors="replace").read() + except OSError as e: + errors.append((rel, f"cannot read: {e}")) + continue + + bad = gh_secret_body_dash_line(text) + if bad: + errors.append((rel, f"line {bad}: `gh secret set` must read stdin by " + "omitting `--body`; never use `--body -`")) + + data, err = load_front_matter(text) + if err: + errors.append((rel, err)) + continue + + for field in ("name", "description"): + val = data.get(field) + if not (isinstance(val, str) and val.strip()): + # bedakan: ada tapi pipeline-char (rusak) vs benar-benar kosong + if field == "description" and is_placeholder_desc( + data.get("description") if isinstance(data.get("description"), str) else None): + errors.append((rel, "description is a YAML block scalar that " + "did not parse (use a quoted single line)")) + else: + errors.append((rel, f"missing non-empty string field: {field}")) + + name = data.get("name") + if isinstance(name, str) and name.strip(): + n = name.strip() + if n in names: + errors.append((rel, f"duplicate skill name {n!r} also used by {names[n]}")) + else: + names[n] = rel + + return checked, len(files), errors + + +def main(): + ap = argparse.ArgumentParser( + description="Validate SKILL.md front matter (ported from steipete/agent-scripts).") + ap.add_argument("--root", default=DEFAULT_ROOT, + help=f"root kolam skill (default: {DEFAULT_ROOT})") + ap.add_argument("--only-changed", action="store_true", + help="hanya periksa file yang berubah (git diff HEAD)") + ap.add_argument("--json", action="store_true", help="keluarkan hasil sebagai JSON") + ap.add_argument("--quiet", action="store_true", help="hanya cetak saat gagal") + args = ap.parse_args() + + root = os.path.abspath(os.path.expanduser(args.root)) + if not os.path.isdir(root): + print(f"error: root tidak ditemukan: {root}", file=sys.stderr) + return 2 + + checked, total, errors = validate(root, args.only_changed) + + if args.json: + print(json.dumps({ + "root": root, "total_found": total, "checked": checked, + "errors": [{"file": f, "error": e} for f, e in errors], + "ok": not errors, "yaml_lib": HAVE_YAML, + }, indent=1)) + return 0 if not errors else 1 + + if total == 0: + print(f"Tidak ada SKILL.md ditemukan di {root}", file=sys.stderr) + return 0 + + if errors: + print("Skill validation FAILED:", file=sys.stderr) + for f, e in errors: + print(f"- {f}: {e}", file=sys.stderr) + return 1 + + if not args.quiet: + note = "" if HAVE_YAML else " (PyYAML tidak ada — pakai parser fallback)" + print(f"Validated {checked} skill(s) — OK{note}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())