skilldrop-cli 0.13.7 → 0.13.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/bin/skilldrop.js +469 -41
  3. package/catalogue.json +1 -1
  4. package/contracts/pack.schema.json +26 -0
  5. package/package.json +1 -1
  6. package/packs/ai-engineering/pack.json +36 -1
  7. package/packs/ai-engineering/skills/agent-threat-model/SKILL.md +5 -2
  8. package/packs/ai-engineering/skills/agent-threat-model/manifest.json +1 -1
  9. package/packs/ai-engineering/skills/agent-threat-model/reference.md +19 -0
  10. package/packs/ai-engineering/skills/agent-threat-model/templates/agent-threat-model.md +3 -3
  11. package/packs/ai-engineering/skills/ai-usage-report/evals/eval_queries.json +30 -0
  12. package/packs/ai-engineering/skills/ai-usage-report/evals/evals.json +19 -0
  13. package/packs/ai-engineering/skills/llm-eval-harness/evals/eval_queries.json +34 -0
  14. package/packs/ai-engineering/skills/llm-eval-harness/evals/evals.json +19 -0
  15. package/packs/api-builder/pack.json +48 -0
  16. package/packs/api-builder/skills/eval-harness-generator/evals/eval_queries.json +30 -0
  17. package/packs/api-builder/skills/eval-harness-generator/evals/evals.json +19 -0
  18. package/packs/api-builder/skills/prompt-caching-advisor/evals/eval_queries.json +30 -0
  19. package/packs/api-builder/skills/prompt-caching-advisor/evals/evals.json +19 -0
  20. package/packs/api-builder/skills/token-budget-estimator/evals/eval_queries.json +30 -0
  21. package/packs/api-builder/skills/token-budget-estimator/evals/evals.json +18 -0
  22. package/packs/api-builder/skills/tool-use-schema-writer/evals/eval_queries.json +30 -0
  23. package/packs/api-builder/skills/tool-use-schema-writer/evals/evals.json +18 -0
  24. package/packs/core/pack.json +29 -0
  25. package/packs/design/pack.json +39 -1
  26. package/packs/dev-team/pack.json +35 -1
  27. package/packs/dev-team/skills/accessibility-audit/evals/eval_queries.json +30 -0
  28. package/packs/dev-team/skills/accessibility-audit/evals/evals.json +19 -0
  29. package/packs/dev-team/skills/contribution-wizard/SKILL.md +1 -1
  30. package/packs/dev-team/skills/contribution-wizard/evals/eval_queries.json +30 -0
  31. package/packs/dev-team/skills/contribution-wizard/evals/evals.json +19 -0
  32. package/packs/dev-team/skills/contribution-wizard/manifest.json +1 -1
  33. package/packs/dev-team/skills/feature-implement-loop/evals/eval_queries.json +30 -0
  34. package/packs/dev-team/skills/feature-implement-loop/evals/evals.json +19 -0
  35. package/packs/dev-team/skills/release-notes/evals/eval_queries.json +30 -0
  36. package/packs/dev-team/skills/release-notes/evals/evals.json +19 -0
  37. package/packs/product-manager/pack.json +40 -1
  38. package/packs/product-manager/skills/business-case/evals/eval_queries.json +30 -0
  39. package/packs/product-manager/skills/business-case/evals/evals.json +19 -0
  40. package/packs/solution-architect/pack.json +44 -1
  41. package/packs/solution-architect/skills/tech-comparison-matrix/evals/eval_queries.json +30 -0
  42. package/packs/solution-architect/skills/tech-comparison-matrix/evals/evals.json +18 -0
  43. package/packs/sre-oncall/pack.json +33 -1
  44. package/packs/sre-oncall/skills/capacity-cost-model/evals/eval_queries.json +30 -0
  45. package/packs/sre-oncall/skills/capacity-cost-model/evals/evals.json +19 -0
  46. package/packs/stakeholder-comms/pack.json +39 -1
  47. package/packs/stakeholder-comms/skills/guide-builder/evals/eval_queries.json +34 -0
  48. package/packs/stakeholder-comms/skills/guide-builder/evals/evals.json +18 -0
  49. package/profiles.json +1 -1
  50. package/packs/claude-api/pack.json +0 -21
  51. /package/packs/{claude-api → api-builder}/skills/eval-harness-generator/SKILL.md +0 -0
  52. /package/packs/{claude-api → api-builder}/skills/eval-harness-generator/manifest.json +0 -0
  53. /package/packs/{claude-api → api-builder}/skills/prompt-caching-advisor/SKILL.md +0 -0
  54. /package/packs/{claude-api → api-builder}/skills/prompt-caching-advisor/manifest.json +0 -0
  55. /package/packs/{claude-api → api-builder}/skills/token-budget-estimator/SKILL.md +0 -0
  56. /package/packs/{claude-api → api-builder}/skills/token-budget-estimator/manifest.json +0 -0
  57. /package/packs/{claude-api → api-builder}/skills/tool-use-schema-writer/SKILL.md +0 -0
  58. /package/packs/{claude-api → api-builder}/skills/tool-use-schema-writer/manifest.json +0 -0
package/bin/skilldrop.js CHANGED
@@ -55,6 +55,10 @@ Usage:
55
55
  you edited are kept and the new copy lands beside them
56
56
  as <file>.upstream (--force overwrites) (RFC-0032)
57
57
  skilldrop outdated show installed vs current versions, change nothing
58
+ skilldrop diff <skill> [--stat] what differs between your installed copy and the catalog's —
59
+ your edits, upstream changes, or both
60
+ skilldrop doctor check a target against its ledger: missing folders, leftover
61
+ .upstream files, stale wiring and hooks. Changes nothing.
58
62
  skilldrop uninstall <skill...> remove skills (and wiring files this tool wrote)
59
63
  skilldrop uninstall --agent <name...> remove subagents
60
64
  skilldrop uninstall --loop <name...> remove loops (stage skills are left in place)
@@ -62,6 +66,9 @@ Usage:
62
66
  skilldrop scan [<skill...>] [--from <src>] supply-chain scan — flag network/exec/credential
63
67
  patterns in scripts and injection-shaped instructions
64
68
  in SKILL.md before you trust a catalog (RFC-0022)
69
+ skilldrop new-skill <name> --pack <p> scaffold a skill in the catalog you're in (for catalog authors;
70
+ --tier light|standard|heavy, default standard)
71
+ skilldrop --version print the CLI version
65
72
  skilldrop bootstrap add the skilldrop marketplace to ~/.claude/settings.json
66
73
  (idempotent — safe to run in onboarding scripts)
67
74
 
@@ -76,10 +83,17 @@ Install/update/uninstall targets (pick one):
76
83
  --project ./.claude/skills Claude Code + GitHub Copilot CLI, project scope
77
84
  --ide cursor ./.cursor/skills + writes .cursor/rules/<skill>.mdc
78
85
  --ide kiro ./.kiro/skills Kiro IDE + Kiro CLI (discovered natively)
79
- --dest <dir> any directory e.g. .agents/skills (Codex, Copilot CLI),
80
- .github/skills (Copilot), Continue / Cline / Aider
86
+ --ide codex ~/.codex/skills Codex (--project: ./.agents/skills)
87
+ --ide antigravity ~/.gemini/antigravity-cli/skills Antigravity CLI (--project: ./.agents/skills)
88
+ --ide copilot ~/.copilot/skills GitHub Copilot (--project: ./.github/skills)
89
+ --dest <dir> any directory e.g. Continue / Cline / Aider
90
+ --local project scope, hidden from git via .git/info/exclude — try skills in a
91
+ repo you don't own without touching its tracked files or .gitignore
81
92
 
82
93
  Options:
94
+ --dry-run install | update | uninstall | new-skill: print what would change, change nothing
95
+ --yes skip the confirmation uninstall and update --force ask for at a terminal
96
+ (scripts and CI are never asked)
83
97
  --agent operate on subagents instead of skills. Targets:
84
98
  (default) ~/.claude/agents Claude Code (--project for repo scope)
85
99
  --ide kiro ./.kiro/agents generated JSON, tools mapped
@@ -88,7 +102,7 @@ Options:
88
102
  --ide antigravity ~/.gemini/config/agents markdown, subagent: true
89
103
  --dest <dir> any directory copied as-is
90
104
  Cursor has no agent format — use a custom mode (agents/README.md).
91
- --json machine-readable output for list | info | packs | agents | outdated —
105
+ --json machine-readable output for list | info | packs | agents | outdated | doctor —
92
106
  one JSON object on stdout, nothing else (for agents, scripts, CI)
93
107
  --with-related also install each skill's related companions (one level)
94
108
  --with-hooks also wire any hooks a skill declares (RFC-0006) — git pre-commit
@@ -109,7 +123,7 @@ function parseArgs(argv) {
109
123
  const out = { _: [], flags: {} };
110
124
  for (let i = 0; i < argv.length; i++) {
111
125
  const a = argv[i];
112
- if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile") out.flags[a.slice(2)] = argv[++i];
126
+ if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile" || a === "--tier") out.flags[a.slice(2)] = argv[++i];
113
127
  else if (a.startsWith("--")) out.flags[a.slice(2)] = true;
114
128
  else out._.push(a);
115
129
  }
@@ -359,33 +373,36 @@ function gate(cat, names) {
359
373
 
360
374
  Two rule sets, deliberately different:
361
375
  - SCRIPT_RULES run over executable files (real code): network, exec, credential, obfuscation.
362
- - PROSE_RULES run over SKILL.md and only match *instructions to misbehave* — not security
376
+ - PROSE_RULES run over every markdown file (SKILL.md, reference.md, templates, examples) and only match *instructions to misbehave* — not security
363
377
  vocabulary, so a skill that legitimately discusses injection (threat-model) isn't flagged. */
364
378
 
365
379
  const SCRIPT_EXT = new Set([".py", ".js", ".mjs", ".cjs", ".sh", ".bash", ".zsh", ".rb", ".pl", ".ps1"]);
366
380
 
367
381
  const SCRIPT_RULES = [
368
- { id: "exec-remote", sev: "🟥", why: "downloads and executes remote content — the classic supply-chain payload",
382
+ { id: "exec-remote", sev: "🟥", owasp: ["AST01", "AST02"], why: "downloads and executes remote content — the classic supply-chain payload",
369
383
  re: /(curl|wget)[^\n|]*\|\s*(sudo\s+)?(ba|z|)sh|eval\s*\(\s*(requests|urllib|fetch)|base64\s+(-d|--decode)[^\n|]*\|\s*(ba|z|)sh/i },
370
- { id: "shell-exec", sev: "🟧", why: "executes shell commands",
384
+ { id: "shell-exec", sev: "🟧", owasp: ["AST03", "LLM06"], why: "executes shell commands",
371
385
  re: /\b(os\.system|subprocess\.(run|call|Popen|check_output)|child_process|execSync|spawnSync|shell_exec|`[^`\n]*\$\()/ },
372
- { id: "network", sev: "🟧", why: "makes outbound network calls",
386
+ { id: "network", sev: "🟧", owasp: ["AST03", "LLM02"], why: "makes outbound network calls",
373
387
  re: /\b(requests\.(get|post|put)|urllib\.request|httpx\.|axios\.|node-fetch|\bfetch\s*\(\s*["'`]https?:|curl\s+https?:|wget\s+https?:)/i },
374
- { id: "credentials", sev: "🟧", why: "reads credentials or secret material",
388
+ { id: "credentials", sev: "🟧", owasp: ["AST01", "LLM02"], why: "reads credentials or secret material",
375
389
  re: /\b(os\.environ|process\.env)\b[^\n]{0,40}(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)|~\/\.(aws|ssh|npmrc|netrc)|\.env\b|id_rsa/i },
376
- { id: "broad-fs", sev: "🟨", why: "writes outside the skill's own directory",
390
+ { id: "broad-fs", sev: "🟨", owasp: ["AST03", "LLM06"], why: "writes outside the skill's own directory",
377
391
  re: /\b(shutil\.rmtree|rm\s+-rf\s+[~/]|open\s*\(\s*["'`]\/(etc|usr|bin)|fs\.(unlink|rmSync)\s*\([^)]*\.\.\/)/ },
378
392
  ];
379
393
 
380
394
  const PROSE_RULES = [
381
- { id: "instruction-override", sev: "🟥", why: "tells the agent to ignore its prior instructions",
395
+ { id: "instruction-override", sev: "🟥", owasp: ["AST01", "LLM01"], why: "tells the agent to ignore its prior instructions",
382
396
  re: /ignore\s+(all\s+)?(previous|prior|earlier|above|preceding)\s+(instructions|prompts|rules)/i },
383
- { id: "conceal-from-user", sev: "🟥", why: "tells the agent to hide actions from the user",
397
+ { id: "conceal-from-user", sev: "🟥", owasp: ["AST01", "LLM01"], why: "tells the agent to hide actions from the user",
384
398
  re: /(do\s*n[o']?t|never|without)\s+(tell|telling|inform|informing|notify|notifying|mention|mentioning)\s+(the\s+)?(user|human)/i },
385
- { id: "memory-overwrite", sev: "🟥", why: "instructs the agent to rewrite its own persistent memory/identity",
399
+ { id: "memory-overwrite", sev: "🟥", owasp: ["AST01", "LLM01"], why: "instructs the agent to rewrite its own persistent memory/identity",
386
400
  re: /(update|overwrite|append\s+to|modify)\s+your\s+(own\s+)?(SOUL|MEMORY|CLAUDE|AGENTS)\.md/i },
387
- { id: "prose-exfil", sev: "🟧", why: "instructs the agent to send data to an external endpoint",
401
+ { id: "prose-exfil", sev: "🟧", owasp: ["AST01", "LLM02"], why: "instructs the agent to send data to an external endpoint",
388
402
  re: /\b(POST|send|upload|transmit)\b[^\n.]{0,60}\bto\s+https?:\/\//i },
403
+ { id: "remote-instructions", sev: "🟧", owasp: ["AST05", "LLM01"],
404
+ why: "tells the agent to fetch instructions from a URL at run time — what it obeys can change after you reviewed it",
405
+ re: /\b(fetch|download|load|read|follow|pull)\b[^\n.]{0,40}\b(instructions|rules|prompt|system prompt|skill|directives)\b[^\n.]{0,30}\bfrom\s+(https?:\/\/|<?url)/i },
389
406
  ];
390
407
 
391
408
  function walkFiles(dir, out = []) {
@@ -406,7 +423,7 @@ function scanSkill(cat, s) {
406
423
  const rel = path.relative(dir, file);
407
424
  const ext = path.extname(file).toLowerCase();
408
425
  const isScript = SCRIPT_EXT.has(ext);
409
- const isProse = path.basename(file) === "SKILL.md";
426
+ const isProse = ext === ".md";
410
427
  if (!isScript && !isProse) continue;
411
428
  let body = "";
412
429
  try { body = fs.readFileSync(file, "utf8"); } catch (e) { continue; }
@@ -415,7 +432,7 @@ function scanSkill(cat, s) {
415
432
  if (line.length > 400) return; // minified/data line — not reviewable prose or code
416
433
  for (const r of rules) {
417
434
  if (r.re.test(line))
418
- findings.push({ sev: r.sev, id: r.id, why: r.why, file: rel, line: i + 1, text: line.trim().slice(0, 120) });
435
+ findings.push({ sev: r.sev, id: r.id, why: r.why, owasp: r.owasp, file: rel, line: i + 1, text: line.trim().slice(0, 120) });
419
436
  }
420
437
  });
421
438
  }
@@ -432,7 +449,7 @@ function printScan(cat, names, { compact = false } = {}) {
432
449
  if (!worst || f[0].sev === "🟥") worst = worst === "🟥" ? worst : f[0].sev;
433
450
  console.log(`\n${s}`);
434
451
  for (const x of (compact ? f.slice(0, 3) : f))
435
- console.log(` ${x.sev} ${x.id} — ${x.why}\n ${x.file}:${x.line} ${x.text}`);
452
+ console.log(` ${x.sev} ${x.id} — ${x.why} [${x.owasp.join(", ")}]\n ${x.file}:${x.line} ${x.text}`);
436
453
  if (compact && f.length > 3) console.log(` … ${f.length - 3} more (skilldrop scan ${s} --from ${cat.source})`);
437
454
  }
438
455
  return { total, worst };
@@ -458,21 +475,34 @@ function scan(args) {
458
475
  console.log(`\n${total} flagged pattern(s). These are HEURISTICS, not verdicts — a match can be` +
459
476
  ` entirely legitimate (a skill that is *about* security, or a script that genuinely needs the network).`);
460
477
  console.log("Read the cited lines before installing. Nothing here is executed by skilldrop; installs copy files only.");
478
+ console.log("Tags are OWASP Agentic Skills Top 10 (AST) and LLM Top 10 2025 (LLM) IDs — guides/reference/owasp-mapping.md.");
461
479
  }
462
480
  }
463
481
 
464
482
  /* ---------- install targets & ledger ---------- */
465
483
 
484
+ /* Skill directories per tool: [project path, user path]. A null user path means the tool only
485
+ reads skills from the repo. Paths are the ones each tool's own docs name, recorded with sources
486
+ in docs/designs/ide-primitive-coverage.md. .agents/skills is shared: Codex, Antigravity and
487
+ Copilot CLI all read it, so one project install reaches all three. */
488
+ const SKILL_DIRS = {
489
+ claude: [[".claude", "skills"], [".claude", "skills"]],
490
+ cursor: [[".cursor", "skills"], null],
491
+ kiro: [[".kiro", "skills"], null],
492
+ codex: [[".agents", "skills"], [".codex", "skills"]],
493
+ antigravity: [[".agents", "skills"], [".gemini", "antigravity-cli", "skills"]],
494
+ copilot: [[".github", "skills"], [".copilot", "skills"]],
495
+ };
496
+
466
497
  function target(flags) {
467
498
  if (flags.dest) return { dest: path.resolve(flags.dest), ide: "generic" };
468
499
  const ide = flags.ide || "claude";
469
- if (ide === "claude")
470
- return flags.project
471
- ? { dest: path.resolve(".claude", "skills"), ide }
472
- : { dest: path.join(os.homedir(), ".claude", "skills"), ide };
473
- if (ide === "cursor") return { dest: path.resolve(".cursor", "skills"), ide };
474
- if (ide === "kiro") return { dest: path.resolve(".kiro", "skills"), ide };
475
- die(`unknown --ide '${ide}' (claude | cursor | kiro; use --dest for anything else)`);
500
+ const dirs = SKILL_DIRS[ide];
501
+ if (!dirs) die(`unknown --ide '${ide}' (${Object.keys(SKILL_DIRS).join(" | ")}; use --dest for anything else)`);
502
+ const [proj, user] = dirs;
503
+ // --local is a project install the repo never sees (see localExclude), so it implies --project.
504
+ if (flags.project || flags.local || !user) return { dest: path.resolve(...proj), ide };
505
+ return { dest: path.join(os.homedir(), ...user), ide };
476
506
  }
477
507
 
478
508
  /* Claude Code tool name -> Kiro built-in tool name. Confirmed against
@@ -597,11 +627,101 @@ function ledger(dest) {
597
627
  const p = path.join(dest, LEDGER);
598
628
  return { path: p, data: fs.existsSync(p) ? readJSON(p) : {} };
599
629
  }
600
- function saveLedger(l) { fs.writeFileSync(l.path, JSON.stringify(l.data, null, 2) + "\n"); }
630
+ // An empty ledger is removed rather than left behind, so uninstalling the last skill leaves no trace.
631
+ function saveLedger(l) {
632
+ if (!Object.keys(l.data).length) return fs.rmSync(l.path, { force: true });
633
+ fs.writeFileSync(l.path, JSON.stringify(l.data, null, 2) + "\n");
634
+ }
601
635
  /* Ledger values: {version, source}; legacy plain strings mean a bundled install. */
602
636
  function lver(v) { return typeof v === "string" ? v : v.version; }
603
637
  function lsrc(v) { return typeof v === "string" ? BUNDLED : v.source || BUNDLED; }
604
638
 
639
+ /* Ask before a destructive step. Only a person at a terminal is asked: scripts, CI and pipes
640
+ (stdin not a TTY) proceed as they always did, and --yes skips the question. */
641
+ function confirm(question, flags) {
642
+ if (flags.yes || flags["dry-run"] || !process.stdin.isTTY) return true;
643
+ process.stdout.write(`${question} [y/N] `);
644
+ const buf = Buffer.alloc(256);
645
+ let n = 0;
646
+ try { n = fs.readSync(0, buf, 0, buf.length, null); } catch (e) { return false; }
647
+ return /^y(es)?$/i.test(buf.toString("utf8", 0, n).trim());
648
+ }
649
+
650
+ /* Files in an installed skill that differ from the baseline the ledger recorded at copy time:
651
+ the user's edits. Empty when there is no baseline (installed before RFC-0032). */
652
+ function editedFiles(dest, s, entry) {
653
+ if (!entry || typeof entry !== "object" || !entry.files) return [];
654
+ const out = [];
655
+ for (const [rel, h] of Object.entries(entry.files)) {
656
+ let mine = null;
657
+ try { mine = sha256(fs.readFileSync(path.join(dest, s, ...rel.split("/")))); } catch (e) { /* deleted */ }
658
+ if (mine !== null && mine !== h) out.push(rel);
659
+ }
660
+ return out;
661
+ }
662
+
663
+ /* --local: try skills in a repo you don't own. The install is a normal project install, and
664
+ every path it writes is listed in the repo's own exclude file (.git/info/exclude, which git
665
+ reads like .gitignore but never commits), so `git status` stays clean and nothing can be
666
+ committed by accident. The entries live in one marker-fenced block this tool owns. */
667
+ const EXCLUDE_OPEN = "# >>> skilldrop --local >>>", EXCLUDE_CLOSE = "# <<< skilldrop --local <<<";
668
+ function excludePath(root) {
669
+ try {
670
+ const out = execFileSync("git", ["rev-parse", "--git-path", "info/exclude"],
671
+ { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
672
+ if (out) return path.resolve(root, out);
673
+ } catch (e) { /* no git binary */ }
674
+ return path.join(root, ".git", "info", "exclude");
675
+ }
676
+ function readExclude(root) {
677
+ const p = excludePath(root);
678
+ const body = readIfPresent(p);
679
+ const a = body.indexOf(EXCLUDE_OPEN), b = body.indexOf(EXCLUDE_CLOSE);
680
+ const entries = a >= 0 && b > a
681
+ ? body.slice(a + EXCLUDE_OPEN.length, b).split("\n").map((x) => x.trim()).filter(Boolean) : [];
682
+ const rest = a >= 0 && b > a ? body.slice(0, a) + body.slice(b + EXCLUDE_CLOSE.length) : body;
683
+ return { p, entries, rest: rest.replace(/\n{3,}/g, "\n\n") };
684
+ }
685
+ function writeExclude(root, entries) {
686
+ const { p, rest } = readExclude(root);
687
+ const sorted = [...new Set(entries)].sort();
688
+ let body = rest.replace(/\s+$/, "");
689
+ if (sorted.length) body += `${body ? "\n\n" : ""}${EXCLUDE_OPEN}\n${sorted.join("\n")}\n${EXCLUDE_CLOSE}`;
690
+ fs.mkdirSync(path.dirname(p), { recursive: true });
691
+ fs.writeFileSync(p, body ? body + "\n" : "");
692
+ return p;
693
+ }
694
+ // Exclude patterns are repo-root-relative and anchored with a leading slash.
695
+ function excludeEntry(root, abs, dir) { return "/" + path.relative(root, abs).split(path.sep).join("/") + (dir ? "/" : ""); }
696
+ function localPaths(root, dest, ide, s) {
697
+ const out = [excludeEntry(root, path.join(dest, s), true)];
698
+ const w = wiringPath(ide, dest, s);
699
+ if (w && ide === "cursor") out.push(excludeEntry(root, w, false));
700
+ return out;
701
+ }
702
+ function addLocal(dest, ide, names) {
703
+ const root = gitRoot(process.cwd());
704
+ const { entries } = readExclude(root);
705
+ const next = entries.concat(excludeEntry(root, path.join(dest, LEDGER), false));
706
+ for (const s of names) next.push(...localPaths(root, dest, ide, s));
707
+ return writeExclude(root, next);
708
+ }
709
+ /* Called on every uninstall, --local or not: dropping an entry that isn't there is a no-op, and
710
+ the ledger's own entry goes once no skill in that folder is excluded any more. */
711
+ function removeLocal(dest, ide, names) {
712
+ const root = gitRoot(process.cwd());
713
+ if (!root) return null;
714
+ const { entries } = readExclude(root);
715
+ if (!entries.length) return null;
716
+ const drop = new Set(names.flatMap((s) => localPaths(root, dest, ide, s)));
717
+ let next = entries.filter((e) => !drop.has(e));
718
+ const destPrefix = excludeEntry(root, dest, true);
719
+ const ledgerEntry = excludeEntry(root, path.join(dest, LEDGER), false);
720
+ if (!next.some((e) => e !== ledgerEntry && e.startsWith(destPrefix))) next = next.filter((e) => e !== ledgerEntry);
721
+ if (next.length === entries.length) return null;
722
+ return writeExclude(root, next);
723
+ }
724
+
605
725
  /* Wiring = the pointer file a target needs to *find* a skill.
606
726
  Cursor needs one: .cursor/skills/ is not a discovery path, so the .mdc rule is what
607
727
  makes the skill reachable (and it carries alwaysApply:false, so it stays inert until matched).
@@ -700,7 +820,11 @@ function writeClaudeSessionHook(settingsPath, skill, hook) {
700
820
  }
701
821
 
702
822
  // Wire one skill's declared hooks; returns human-readable status lines.
703
- function emitHooks(cat, skill, dest, ide) {
823
+ /* Claude Code reads settings.local.json beside settings.json and never expects it committed, so a
824
+ --local install puts its session-start hook there instead of in the repo's shared settings. */
825
+ function claudeSettings(dest, local) { return path.join(path.dirname(dest), local ? "settings.local.json" : "settings.json"); }
826
+
827
+ function emitHooks(cat, skill, dest, ide, local) {
704
828
  let hooks = [];
705
829
  try { hooks = manifestOf(cat, skill).hooks || []; } catch (e) { return []; }
706
830
  const lines = [];
@@ -712,7 +836,7 @@ function emitHooks(cat, skill, dest, ide) {
712
836
  if (root) writeGitPreCommitHook(root, skill, h);
713
837
  } else if (h.event === "session-start") {
714
838
  if (ide === "claude") {
715
- const sp = path.join(path.dirname(dest), "settings.json");
839
+ const sp = claudeSettings(dest, local);
716
840
  const written = writeClaudeSessionHook(sp, skill, h);
717
841
  lines.push(written ? ` ${skill}: session-start context -> ${sp}`
718
842
  : ` ${skill}: session-start skipped — ${sp} is not valid JSON`);
@@ -738,8 +862,7 @@ function removeHooksFor(skill, dest, ide) {
738
862
  if (next !== body) fs.writeFileSync(p, next);
739
863
  }
740
864
  }
741
- if (ide === "claude") {
742
- const sp = path.join(path.dirname(dest), "settings.json");
865
+ if (ide === "claude") for (const sp of [claudeSettings(dest, false), claudeSettings(dest, true)]) {
743
866
  const raw = readIfPresent(sp, null);
744
867
  if (raw !== null) {
745
868
  try {
@@ -756,11 +879,23 @@ function removeHooksFor(skill, dest, ide) {
756
879
 
757
880
  /* ---------- commands ---------- */
758
881
 
882
+ /* Packs that were renamed, so an old command or script keeps working. claude-api became
883
+ api-builder in 0.13.9: Claude Code reserves plugin names that start with "claude-". */
884
+ const PACK_ALIASES = { "claude-api": "api-builder" };
885
+ function packAlias(flags, ps) {
886
+ const n = flags.pack;
887
+ if (n && ps && !ps[n] && PACK_ALIASES[n] && ps[PACK_ALIASES[n]]) {
888
+ console.error(`note: pack '${n}' is now '${PACK_ALIASES[n]}' — use --pack ${PACK_ALIASES[n]}`);
889
+ flags.pack = PACK_ALIASES[n];
890
+ }
891
+ }
892
+
759
893
  function expandNames(args, cat) {
760
894
  if (args.flags.all) return skillsIn(cat);
761
895
  if (args.flags.pack) {
762
896
  const ps = packsOf(cat);
763
897
  if (!ps) die(`catalog '${cat.source}' defines no packs — install skills by name`);
898
+ packAlias(args.flags, ps);
764
899
  const p = ps[args.flags.pack];
765
900
  if (!p) die(`unknown pack '${args.flags.pack}' in catalog '${cat.source}'`);
766
901
  return packMembers(ps, args.flags.pack, "skills");
@@ -801,7 +936,7 @@ function fileHashes(dir) {
801
936
  local edited, upstream moved -> keep the local file, write the new one as <file>.upstream
802
937
  A file the catalog dropped is removed only if the user never touched it. The new baseline is
803
938
  what the catalog ships now, so a kept edit stays "edited" until the user merges it. */
804
- function mergeOne(cat, s, dest, ide, l, notes) {
939
+ function mergeOne(cat, s, dest, ide, l, notes, dry = false) {
805
940
  const m = manifestOf(cat, s);
806
941
  const src = skillDir(cat, s), dst = path.join(dest, s);
807
942
  const base = l.data[s].files, next = fileHashes(src);
@@ -810,23 +945,27 @@ function mergeOne(cat, s, dest, ide, l, notes) {
810
945
  const localHash = (rel) => { try { return sha256(fs.readFileSync(at(dst, rel))); } catch (e) { return null; } };
811
946
  for (const [rel, h] of Object.entries(next)) {
812
947
  const mine = localHash(rel), to = at(dst, rel);
813
- if (mine === h) { fs.rmSync(to + ".upstream", { force: true }); continue; }
948
+ if (mine === h) { if (!dry) fs.rmSync(to + ".upstream", { force: true }); continue; }
814
949
  if (h === base[rel]) continue;
815
- fs.mkdirSync(path.dirname(to), { recursive: true });
816
950
  if (mine === null ? !(rel in base) : mine === base[rel]) {
951
+ if (dry) continue;
952
+ fs.mkdirSync(path.dirname(to), { recursive: true });
817
953
  fs.copyFileSync(at(src, rel), to);
818
954
  fs.rmSync(to + ".upstream", { force: true });
819
955
  } else {
820
- fs.copyFileSync(at(src, rel), to + ".upstream");
821
956
  kept.push(rel);
957
+ if (dry) continue;
958
+ fs.mkdirSync(path.dirname(to), { recursive: true });
959
+ fs.copyFileSync(at(src, rel), to + ".upstream");
822
960
  }
823
961
  }
824
962
  for (const rel of Object.keys(base)) {
825
963
  if (rel in next) continue;
826
964
  const mine = localHash(rel);
827
- if (mine === base[rel]) fs.rmSync(at(dst, rel), { force: true });
965
+ if (mine === base[rel]) { if (!dry) fs.rmSync(at(dst, rel), { force: true }); }
828
966
  else if (mine !== null) orphaned.push(rel);
829
967
  }
968
+ if (dry) return { m, kept, orphaned };
830
969
  const note = writeWiring(ide, dest, s, m.description);
831
970
  if (note && notes) notes.push(note);
832
971
  l.data[s] = { version: m.version, source: cat.source, files: next };
@@ -837,14 +976,15 @@ function mergeOne(cat, s, dest, ide, l, notes) {
837
976
  command (RFC-0020). Needs a target with BOTH a subagent and a skill format, so it accepts
838
977
  the default (Claude Code), --project, --ide kiro, or --dest; other IDEs install the halves
839
978
  separately. Delegates to installAgents + install so every existing safety/craft gate applies. */
979
+ const PANEL_IDES = ["kiro", "codex", "antigravity", "copilot"];
840
980
  function installPanel(args) {
841
981
  const name = args.flags.panel;
842
982
  const p = PANELS[name];
843
983
  if (!p) die(`unknown panel '${name}' — available: ${Object.keys(PANELS).join(", ")}`);
844
984
  const ide = args.flags.ide;
845
- if (ide && ide !== "kiro" && !args.flags.dest)
985
+ if (ide && !PANEL_IDES.includes(ide) && !args.flags.dest)
846
986
  die(`--panel installs subagents + the orchestrator skill together, so it needs a target with both formats:\n` +
847
- ` (default | --project) Claude Code, --ide kiro, or --dest <dir>.\n` +
987
+ ` (default | --project) Claude Code, --ide ${PANEL_IDES.join(" | ")}, or --dest <dir>.\n` +
848
988
  ` For ${ide}: install the agents (skilldrop install --agent ${p.agents.join(" ")} --ide ${ide}) ` +
849
989
  `and the skill (skilldrop install ${p.skill} --ide ${ide}) separately.`);
850
990
  const flags = Object.assign({}, args.flags);
@@ -907,6 +1047,13 @@ function installProfile(args) {
907
1047
  }
908
1048
 
909
1049
  function install(args) {
1050
+ if (args.flags["dry-run"] && (args.flags.panel || args.flags.profile || args.flags.loop || args.flags.agent))
1051
+ die("--dry-run covers skill installs, update and uninstall. To preview a panel, profile, loop or agent,\n" +
1052
+ " read what it contains first: skilldrop profiles | loops | agents --json");
1053
+ if (args.flags.local && !gitRoot(process.cwd()))
1054
+ die("--local hides the install from git, so it needs a git repo — run it from inside one");
1055
+ if (args.flags.local && (args.flags.panel || args.flags.profile || args.flags.agent))
1056
+ die("--local covers skills and loops. Agents have their own folders; install them with --dest into a path you exclude yourself");
910
1057
  if (args.flags.panel) return installPanel(args);
911
1058
  if (args.flags.profile) return installProfile(args);
912
1059
  if (args.flags.loop) return installLoops(args);
@@ -923,6 +1070,7 @@ function install(args) {
923
1070
  }
924
1071
  gate(cat, names);
925
1072
  const { dest, ide } = target(args.flags);
1073
+ if (args.flags["dry-run"]) return planInstall(cat, names, dest, ide, args.flags);
926
1074
  fs.mkdirSync(dest, { recursive: true });
927
1075
  const l = ledger(dest);
928
1076
  const pipDeps = [], suggestions = new Set(), notes = [];
@@ -935,10 +1083,20 @@ function install(args) {
935
1083
  saveLedger(l);
936
1084
  console.log(`\n${names.length} skill(s) installed (${ide}).`);
937
1085
  if (notes.length) console.log(`\nCleanup:\n${notes.join("\n")}`);
1086
+ const local = !!args.flags.local;
1087
+ if (local) {
1088
+ const ex = addLocal(dest, ide, names);
1089
+ console.log(`\n--local: listed in ${path.relative(process.cwd(), ex) || ex}, so git won't see these files.` +
1090
+ `\n Nothing in the repo's tracked files changed. Remove with: skilldrop uninstall <skill> --local`);
1091
+ }
938
1092
 
939
1093
  const withHooks = names.filter((s) => { try { return (manifestOf(cat, s).hooks || []).length; } catch (e) { return false; } });
940
1094
  if (withHooks.length && args.flags["with-hooks"]) {
941
- const lines = withHooks.flatMap((s) => emitHooks(cat, s, dest, ide));
1095
+ const lines = withHooks.flatMap((s) => emitHooks(cat, s, dest, ide, local));
1096
+ if (local && ide === "claude") {
1097
+ const root = gitRoot(process.cwd());
1098
+ writeExclude(root, readExclude(root).entries.concat(excludeEntry(root, claudeSettings(dest, true), false)));
1099
+ }
942
1100
  console.log(`\nHooks wired (RFC-0006):\n${lines.join("\n")}`);
943
1101
  if (cat.source !== BUNDLED)
944
1102
  console.log(` NOTE: these hooks run commands from third-party catalog '${cat.source}' — read them before trusting them.`);
@@ -957,7 +1115,7 @@ function install(args) {
957
1115
  : "\nSupply-chain scan: no flagged patterns.");
958
1116
  }
959
1117
  if (ide === "generic")
960
- console.log("wiring: attach each skill's SKILL.md to your agent (Continue/Cline: @file, Aider: /add, Codex: reference it from AGENTS.md) — see the repo README's per-IDE steps.");
1118
+ console.log("wiring: attach each skill's SKILL.md to your agent (Continue/Cline: @file, Aider: /add) — see the repo README's per-IDE steps.");
961
1119
  for (const s of pipDeps)
962
1120
  console.log(`deps: ${s} needs Python packages — run: cd ${path.join(dest, s)} && python3 -m pip install -r requirements.txt`);
963
1121
  if (suggestions.size)
@@ -966,6 +1124,31 @@ function install(args) {
966
1124
  if (pk && pk["first-value"]) console.log("\n" + firstValueLines(pk).join("\n"));
967
1125
  }
968
1126
 
1127
+ /* install --dry-run: everything install would write, and nothing written. */
1128
+ function planInstall(cat, names, dest, ide, flags) {
1129
+ const l = ledger(dest);
1130
+ console.log(`dry run — nothing will be written. Target: ${dest} (${ide})\n`);
1131
+ for (const s of names) {
1132
+ const m = manifestOf(cat, s);
1133
+ const have = l.data[s];
1134
+ const verb = !have ? "install" : lver(have) === m.version ? "reinstall" : `replace ${lver(have)} with`;
1135
+ const edits = editedFiles(dest, s, have);
1136
+ console.log(`would ${verb} ${s}@${m.version} -> ${path.join(dest, s)}`);
1137
+ if (edits.length) console.log(` overwrites your edits in: ${edits.join(", ")} (update keeps them; install does not)`);
1138
+ const w = wiringPath(ide, dest, s);
1139
+ if (w && ide === "cursor") console.log(` and write ${path.relative(process.cwd(), w)}`);
1140
+ }
1141
+ if (flags.local) {
1142
+ const root = gitRoot(process.cwd());
1143
+ console.log(`\n--local: would list ${names.length} folder(s) and the ledger in ${path.relative(process.cwd(), excludePath(root))}`);
1144
+ }
1145
+ if (flags["with-hooks"]) {
1146
+ const hooked = names.filter((s) => { try { return (manifestOf(cat, s).hooks || []).length; } catch (e) { return false; } });
1147
+ if (hooked.length) console.log(`\nwould wire hooks for: ${hooked.join(", ")}`);
1148
+ }
1149
+ console.log(`\n${names.length} skill(s). Rerun without --dry-run to install.`);
1150
+ }
1151
+
969
1152
  function installedRows(flags) {
970
1153
  const { dest, ide } = target(flags);
971
1154
  const l = ledger(dest);
@@ -984,6 +1167,16 @@ function installedRows(flags) {
984
1167
  function update(args) {
985
1168
  const { dest, ide, l, rows } = installedRows(args.flags);
986
1169
  if (!rows.length) return console.log(`nothing installed at ${dest}`);
1170
+ const dry = !!args.flags["dry-run"];
1171
+ const due = rows.filter((r) => r.current && r.current !== r.installed);
1172
+ if (args.flags.force && !dry) {
1173
+ const lost = due.flatMap((r) => editedFiles(dest, r.s, l.data[r.s]).map((f) => `${r.s}/${f}`));
1174
+ if (lost.length) {
1175
+ console.log(`--force overwrites ${lost.length} file(s) you edited:\n ${lost.join("\n ")}`);
1176
+ if (!confirm("Overwrite them?", args.flags)) return console.log("cancelled — nothing changed. Drop --force to keep your edits as <file>.upstream.");
1177
+ }
1178
+ }
1179
+ if (dry) return planUpdate(dest, ide, l, rows, args.flags);
987
1180
  let n = 0, conflicts = 0;
988
1181
  for (const r of rows) {
989
1182
  if (!r.current) { console.log(`skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
@@ -1006,10 +1199,42 @@ function update(args) {
1006
1199
  }
1007
1200
  saveLedger(l);
1008
1201
  console.log(n ? `\n${n} skill(s) updated.` : "everything up to date.");
1202
+ // An update is a fresh act of trust in a third-party catalog (OWASP AST07): scan what changed.
1203
+ const thirdParty = {};
1204
+ for (const r of rows) if (r.current && r.current !== r.installed && r.src !== BUNDLED) (thirdParty[r.src] = thirdParty[r.src] || { cat: r.cat, names: [] }).names.push(r.s);
1205
+ for (const { cat, names } of Object.values(thirdParty)) {
1206
+ const { total } = printScan(cat, names, { compact: true });
1207
+ console.log(total ? `\n${total} pattern(s) in the updated skills from '${cat.source}' worth reading. Full detail: skilldrop scan --from ${cat.source}`
1208
+ : `\nSupply-chain scan of the updated skills from '${cat.source}': no flagged patterns.`);
1209
+ }
1009
1210
  if (conflicts)
1010
1211
  console.log(`${conflicts} file(s) kept your edits. Merge each <file>.upstream into its file, then delete it — or rerun with --force to take every new version.`);
1011
1212
  }
1012
1213
 
1214
+ /* update --dry-run: the same per-skill, per-file decisions update makes, reported, not applied. */
1215
+ function planUpdate(dest, ide, l, rows, flags) {
1216
+ console.log(`dry run — nothing will be written. Target: ${dest} (${ide})\n`);
1217
+ let n = 0;
1218
+ for (const r of rows) {
1219
+ if (!r.current) { console.log(`would skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
1220
+ if (r.current === r.installed) continue;
1221
+ const problems = checkSkill(r.cat, r.s);
1222
+ if (problems.length) { console.log(`would skip ${r.s}: fails structural check (${problems[0]})`); continue; }
1223
+ n++;
1224
+ const entry = l.data[r.s];
1225
+ console.log(`would update ${r.s} ${r.installed} -> ${r.current} (${r.src})`);
1226
+ if (flags.force || typeof entry !== "object" || !entry.files) {
1227
+ const lost = editedFiles(dest, r.s, entry);
1228
+ if (lost.length) console.log(` --force overwrites your edits in: ${lost.join(", ")}`);
1229
+ continue;
1230
+ }
1231
+ const { kept, orphaned } = mergeOne(r.cat, r.s, dest, ide, l, null, true);
1232
+ for (const f of kept) console.log(` would keep your edits in ${f} and write the new version to ${f}.upstream`);
1233
+ for (const f of orphaned) console.log(` would keep ${f} — dropped upstream, but you edited it`);
1234
+ }
1235
+ console.log(n ? `\n${n} skill(s) would update. Rerun without --dry-run to apply.` : "everything up to date.");
1236
+ }
1237
+
1013
1238
  function outdated(args) {
1014
1239
  const { dest, rows } = installedRows(args.flags);
1015
1240
  const stale = rows.filter((r) => r.current && r.current !== r.installed);
@@ -1034,7 +1259,25 @@ function uninstall(args) {
1034
1259
  if (!args._.length) die("pass skill names to uninstall");
1035
1260
  const { dest, ide } = target(args.flags);
1036
1261
  const l = ledger(dest);
1037
- for (const s of args._) {
1262
+ const present = args._.filter((s) => l.data[s] || fs.existsSync(path.join(dest, s)));
1263
+ const missing = args._.filter((s) => !present.includes(s));
1264
+ for (const s of missing) console.log(`${s} is not installed at ${dest}`);
1265
+ if (!present.length) return;
1266
+ const edits = present.flatMap((s) => editedFiles(dest, s, l.data[s]).map((f) => `${s}/${f}`));
1267
+ if (args.flags["dry-run"]) {
1268
+ console.log(`dry run — nothing will be removed. Target: ${dest} (${ide})\n`);
1269
+ for (const s of present) {
1270
+ console.log(`would remove ${path.join(dest, s)}`);
1271
+ const w = wiringPath(ide, dest, s);
1272
+ if (w && fs.existsSync(w)) console.log(` and ${path.relative(process.cwd(), w)}`);
1273
+ }
1274
+ if (edits.length) console.log(`\nincluding ${edits.length} file(s) you edited: ${edits.join(", ")}`);
1275
+ return;
1276
+ }
1277
+ const what = `Remove ${present.length} skill(s) from ${dest}: ${present.join(", ")}` +
1278
+ (edits.length ? `\n (${edits.length} of the files have your edits: ${edits.join(", ")})` : "") + "?";
1279
+ if (!confirm(what, args.flags)) return console.log("cancelled — nothing removed.");
1280
+ for (const s of present) {
1038
1281
  fs.rmSync(path.join(dest, s), { recursive: true, force: true });
1039
1282
  const w = wiringPath(ide, dest, s);
1040
1283
  if (w) fs.rmSync(w, { force: true });
@@ -1043,6 +1286,176 @@ function uninstall(args) {
1043
1286
  console.log(`removed ${s} from ${dest}`);
1044
1287
  }
1045
1288
  saveLedger(l);
1289
+ const ex = removeLocal(dest, ide, present);
1290
+ if (ex) console.log(`dropped the --local entries from ${path.relative(process.cwd(), ex) || ex}`);
1291
+ }
1292
+
1293
+ /* diff <skill>: what differs between the installed copy and the catalog's current one — your
1294
+ edits, an upstream change, or both. File-level always; line-level through git when it is on
1295
+ the PATH (git diff --no-index works outside any repo). */
1296
+ function diff(args) {
1297
+ const s = args._[0] || die("pass an installed skill name: skilldrop diff <skill>");
1298
+ const { dest } = target(args.flags);
1299
+ const l = ledger(dest);
1300
+ const entry = l.data[s];
1301
+ if (!entry && !fs.existsSync(path.join(dest, s))) die(`'${s}' is not installed at ${dest}`);
1302
+ if (entry && entry.loop) die(`'${s}' is a loop — diff compares skills`);
1303
+ const src = args.flags.from || (entry ? lsrc(entry) : BUNDLED);
1304
+ const cat = resolveCatalog(src === BUNDLED ? undefined : src);
1305
+ if (!skillExists(cat, s)) die(`'${s}' is no longer in catalog '${cat.source}'`);
1306
+ const theirs = fileHashes(skillDir(cat, s)), mine = fileHashes(path.join(dest, s));
1307
+ const base = (entry && entry.files) || {};
1308
+ const changed = Object.keys(theirs).filter((f) => f in mine && mine[f] !== theirs[f]).sort();
1309
+ const onlyUp = Object.keys(theirs).filter((f) => !(f in mine)).sort();
1310
+ const onlyMine = Object.keys(mine).filter((f) => !(f in theirs)).sort();
1311
+ const why = (f) => !base[f] ? "" : mine[f] !== base[f] && theirs[f] !== base[f] ? " (you edited it, and so did the catalog)"
1312
+ : mine[f] !== base[f] ? " (your edit)" : " (catalog changed it)";
1313
+ const iv = entry ? lver(entry) : "?", cv = manifestOf(cat, s).version;
1314
+ console.log(`${s}: installed ${iv} at ${path.join(dest, s)} vs catalog '${cat.source}' ${cv}`);
1315
+ if (!changed.length && !onlyUp.length && !onlyMine.length) return console.log("identical.");
1316
+ for (const f of changed) console.log(` M ${f}${why(f)}`);
1317
+ for (const f of onlyUp) console.log(` + ${f} (in the catalog, not installed)`);
1318
+ for (const f of onlyMine) console.log(` - ${f} (installed only)`);
1319
+ if (args.flags.stat) return;
1320
+ let git = true;
1321
+ for (const f of changed) {
1322
+ const a = path.join(skillDir(cat, s), ...f.split("/")), b = path.join(dest, s, ...f.split("/"));
1323
+ try {
1324
+ execFileSync("git", ["--no-pager", "diff", "--no-index", "--color=auto", "--src-prefix=catalog/", "--dst-prefix=installed/", a, b],
1325
+ { stdio: ["ignore", "inherit", "inherit"] });
1326
+ } catch (e) {
1327
+ if (e.code === "ENOENT") { git = false; break; } // no git binary
1328
+ // exit status 1 is git diff's "files differ" — the expected case
1329
+ }
1330
+ }
1331
+ if (!git) console.log("\n(git not found — line-level diff needs git on the PATH; file list above)");
1332
+ if (changed.length) console.log(`\nTake the catalog's version: skilldrop update --force, or install ${s} again.`);
1333
+ }
1334
+
1335
+ /* doctor: check an install target against its ledger and report what's out of step — skills
1336
+ recorded but gone, skills on disk nobody recorded, leftover .upstream files, wiring and hooks
1337
+ for skills no longer installed. Report-only: it changes nothing, and each finding says the fix. */
1338
+ function doctor(args) {
1339
+ const { dest, ide } = target(args.flags);
1340
+ const findings = [];
1341
+ const add = (level, msg, fix) => findings.push({ level, msg, fix });
1342
+ const lp = path.join(dest, LEDGER);
1343
+ let data = {};
1344
+ const raw = readIfPresent(lp, null);
1345
+ if (raw === null) add("info", `no ledger at ${lp}`, "nothing installed here by skilldrop — or a different target; try --project or --ide");
1346
+ else try { data = JSON.parse(raw); } catch (e) { add("error", `${lp} is not valid JSON`, "fix or delete it; skilldrop can't track updates until then"); }
1347
+ const names = Object.keys(data);
1348
+ for (const n of names) {
1349
+ if (!fs.existsSync(path.join(dest, n))) add("error", `${n} is in the ledger but its folder is gone`, `skilldrop install ${n} (or uninstall ${n} to drop the record)`);
1350
+ else if (!data[n].loop && !data[n].file) {
1351
+ const e = editedFiles(dest, n, data[n]);
1352
+ if (e.length) add("info", `${n}: you edited ${e.join(", ")}`, `skilldrop diff ${n}`);
1353
+ if (typeof data[n] === "string" || !data[n].files) add("warn", `${n} has no file baseline (installed before 0.13)`, `skilldrop install ${n} once; update will keep your edits after that`);
1354
+ }
1355
+ }
1356
+ if (fs.existsSync(dest)) {
1357
+ for (const d of fs.readdirSync(dest, { withFileTypes: true })) {
1358
+ if (!d.isDirectory()) continue;
1359
+ const dir = path.join(dest, d.name);
1360
+ if (fs.existsSync(path.join(dir, "SKILL.md")) && !data[d.name])
1361
+ add("info", `${d.name} is on disk but not in the ledger`, "installed by hand or another tool — skilldrop won't update it");
1362
+ for (const f of walkFiles(dir)) if (f.endsWith(".upstream"))
1363
+ add("warn", `${path.relative(dest, f)} is waiting to be merged`, `merge it into ${path.relative(dest, f.slice(0, -9))}, then delete it`);
1364
+ }
1365
+ }
1366
+ if (ide === "cursor") {
1367
+ const rules = path.join(path.dirname(dest), "rules");
1368
+ for (const f of fs.existsSync(rules) ? fs.readdirSync(rules) : []) {
1369
+ const body = readIfPresent(path.join(rules, f));
1370
+ const m = body.match(/\.cursor\/skills\/([^/\s]+)\/SKILL\.md/);
1371
+ if (m && !fs.existsSync(path.join(dest, m[1]))) add("warn", `.cursor/rules/${f} points at ${m[1]}, which isn't installed`, `delete .cursor/rules/${f}`);
1372
+ }
1373
+ }
1374
+ if (ide === "kiro") for (const n of names) {
1375
+ const w = wiringPath("kiro", dest, n);
1376
+ if (readIfPresent(w).startsWith(KIRO_SHIM_PREFIX)) add("warn", `stale steering shim ${path.relative(process.cwd(), w)}`, `skilldrop install ${n} --ide kiro removes it`);
1377
+ }
1378
+ const root = gitRoot(process.cwd());
1379
+ if (root) {
1380
+ const hook = readIfPresent(preCommitPath(root));
1381
+ for (const m of hook.matchAll(/# >>> skilldrop-hook:([^:\n]+):/g))
1382
+ if (!fs.existsSync(path.join(dest, m[1])) && !data[m[1]]) add("warn", `pre-commit reminder for ${m[1]}, which isn't installed here`, `skilldrop uninstall ${m[1]} removes it (or it belongs to another target)`);
1383
+ for (const e of readExclude(root).entries)
1384
+ if (!fs.existsSync(path.join(root, e.replace(/^\//, "")))) add("info", `--local entry ${e} matches nothing`, "harmless; skilldrop uninstall --local tidies it");
1385
+ }
1386
+ if (ide === "claude") for (const local of [false, true]) {
1387
+ const sp = claudeSettings(dest, local);
1388
+ const body = readIfPresent(sp, null);
1389
+ if (body === null) continue;
1390
+ for (const m of body.matchAll(/skilldrop-hook:([^:"\s]+):session-start/g))
1391
+ if (!fs.existsSync(path.join(dest, m[1]))) add("warn", `${path.basename(sp)} has a session-start hook for ${m[1]}, which isn't installed`, `skilldrop uninstall ${m[1]} removes it`);
1392
+ }
1393
+ if (args.flags.json) return emitJSON({ dest, ide, installed: names.length, findings });
1394
+ console.log(`skilldrop doctor — ${dest} (${ide}), ${names.length} recorded\n`);
1395
+ const icon = { error: "✗", warn: "!", info: "·" };
1396
+ for (const f of findings) console.log(`${icon[f.level]} ${f.msg}\n fix: ${f.fix}`);
1397
+ const bad = findings.filter((f) => f.level !== "info").length;
1398
+ console.log(findings.length ? `\n${bad} to fix, ${findings.length - bad} for information. Nothing was changed.` : "all good.");
1399
+ }
1400
+
1401
+ /* new-skill <name> --pack <pack>: scaffold a skill in the catalog you're standing in, with every
1402
+ file validate.py checks for, so the first run fails only on the parts that need a human. */
1403
+ function newSkill(args) {
1404
+ const name = args._[0] || die("usage: skilldrop new-skill <name> --pack <pack> [--tier light|standard|heavy]");
1405
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) die(`'${name}' isn't kebab-case — use lowercase words joined by hyphens`);
1406
+ const pack = args.flags.pack || die("pass --pack <pack> — the one pack it belongs in (core only if every role needs it)");
1407
+ const tier = args.flags.tier || "standard";
1408
+ if (!["light", "standard", "heavy"].includes(tier)) die("--tier is light, standard or heavy");
1409
+ const root = process.cwd();
1410
+ const pdir = path.join(root, "packs", pack);
1411
+ if (!fs.existsSync(path.join(pdir, "pack.json")))
1412
+ die(`no packs/${pack}/pack.json here — run new-skill from the root of a skilldrop catalog`);
1413
+ for (const p of fs.readdirSync(path.join(root, "packs")))
1414
+ if (fs.existsSync(path.join(root, "packs", p, "skills", name))) die(`packs/${p}/skills/${name} already exists`);
1415
+ const dir = path.join(pdir, "skills", name);
1416
+ const desc = `TODO: one sentence, use-case-first — what it produces. Use when the user asks for …`;
1417
+ const files = {
1418
+ "SKILL.md": `---\nname: ${name}\ndescription: ${desc}\n---\n\n# ${name}\n\nTODO: one paragraph — what you produce and for whom.\n\n` +
1419
+ `## How to respond\n\n1. **Ask for what's missing in one message.** TODO: the inputs, with defaults.\n2. TODO: the steps.\n3. TODO: the output, and how the user checks it.\n\n` +
1420
+ `**Non-interactive runs** (subagent, CI, headless): TODO — what to assume, and when to emit \`BLOCKED: <what is missing>\`.\n\n` +
1421
+ `## Quality bar\n\n- TODO: a checkable property of a good output\n\n## When to use this skill\n\n- ✅ TODO\n\n` +
1422
+ `## When NOT to use this skill\n\n- ❌ TODO; use \`<sibling-skill>\`\n\n## Anti-patterns to avoid\n\n- ❌ **TODO** — and why it fails\n`,
1423
+ "manifest.json": JSON.stringify({
1424
+ name, version: "0.1.0", description: desc, entrypoint: "SKILL.md",
1425
+ deps: { npm: [], pip: [] }, env: { required: [], optional: [] }, related: [], tags: ["todo"],
1426
+ model: { tier, rationale: "TODO: why this tier — how much reasoning the task needs" },
1427
+ }, null, 2) + "\n",
1428
+ "evals/evals.json": JSON.stringify({ skill_name: name, evals: [{ id: 1,
1429
+ prompt: "TODO: a realistic request with concrete details",
1430
+ assertions: ["TODO: a property the output must have, checkable by reading it"] }] }, null, 2) + "\n",
1431
+ "evals/eval_queries.json": "[\n" + [
1432
+ { query: "TODO: a phrasing that should trigger this skill", should_trigger: true },
1433
+ { query: "TODO: a near-miss that belongs to a sibling skill", should_trigger: false },
1434
+ ].map((r) => " " + JSON.stringify(r)).join(",\n") + "\n]\n",
1435
+ };
1436
+ if (args.flags["dry-run"]) {
1437
+ for (const f of Object.keys(files)) console.log(`would write ${path.relative(root, path.join(dir, f))}`);
1438
+ return console.log(`would add ${name} (tier ${tier}) to model-routing.json`);
1439
+ }
1440
+ for (const [f, body] of Object.entries(files)) {
1441
+ fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true });
1442
+ fs.writeFileSync(path.join(dir, f), body);
1443
+ console.log(`wrote ${path.relative(root, path.join(dir, f))}`);
1444
+ }
1445
+ const rp = path.join(root, "model-routing.json");
1446
+ const routingRaw = readIfPresent(rp, null); // read-and-handle, not check-then-read
1447
+ if (routingRaw !== null) {
1448
+ const routing = JSON.parse(routingRaw);
1449
+ routing.skills = routing.skills || {};
1450
+ routing.skills[name] = { tier, rationale: "TODO: why this tier" };
1451
+ routing.skills = Object.fromEntries(Object.entries(routing.skills).sort(([a], [b]) => a.localeCompare(b)));
1452
+ fs.writeFileSync(rp, JSON.stringify(routing, null, 2) + "\n");
1453
+ console.log(`added ${name} to model-routing.json (tier ${tier})`);
1454
+ }
1455
+ console.log(`\nNext:\n 1. Replace every TODO — the description in SKILL.md and manifest.json must match.` +
1456
+ `\n 2. Add ${name} to an outcome in catalogue.json, and a row in guides/reference/skill-catalogue.md.` +
1457
+ `\n 3. python3 build_marketplace.py && python3 build_llms.py, then python3 validate.py — it lists what's left.` +
1458
+ `\n 4. Try it from another repo: skilldrop install ${name} --from ${root} --local`);
1046
1459
  }
1047
1460
 
1048
1461
  function listAgents(args) {
@@ -1134,6 +1547,7 @@ function installLoops(args) {
1134
1547
  if (!names.length && args.flags.pack) {
1135
1548
  const ps = packsOf(cat);
1136
1549
  if (!ps) die(`catalog '${cat.source}' defines no packs`);
1550
+ packAlias(args.flags, ps);
1137
1551
  const pk = ps[args.flags.pack];
1138
1552
  if (!pk) die(`unknown pack '${args.flags.pack}' in catalog '${cat.source}'`);
1139
1553
  names = packMembers(ps, args.flags.pack, "loops");
@@ -1167,6 +1581,7 @@ function installLoops(args) {
1167
1581
  console.log(`installed loop ${n} -> ${out}`);
1168
1582
  }
1169
1583
  saveLedger(l);
1584
+ if (args.flags.local) addLocal(dest, ide, names);
1170
1585
 
1171
1586
  const wanted = [];
1172
1587
  for (const n of names) for (const sk of loopSkills(cat, n)) if (!wanted.includes(sk)) wanted.push(sk);
@@ -1187,6 +1602,11 @@ function uninstallLoops(args) {
1187
1602
  if (!args._.length) die("pass loop names to uninstall");
1188
1603
  const { dest, ide } = target(args.flags);
1189
1604
  const l = ledger(dest);
1605
+ if (args.flags["dry-run"]) {
1606
+ for (const n of args._) console.log(`would remove loop ${path.join(dest, n)} (stage skills left in place)`);
1607
+ return;
1608
+ }
1609
+ if (!confirm(`Remove ${args._.length} loop(s) from ${dest}: ${args._.join(", ")}?`, args.flags)) return console.log("cancelled — nothing removed.");
1190
1610
  for (const n of args._) {
1191
1611
  fs.rmSync(path.join(dest, n), { recursive: true, force: true });
1192
1612
  const w = wiringPath(ide, dest, n);
@@ -1195,6 +1615,7 @@ function uninstallLoops(args) {
1195
1615
  console.log(`removed loop ${n} from ${dest} (stage skills left in place)`);
1196
1616
  }
1197
1617
  saveLedger(l);
1618
+ removeLocal(dest, ide, args._);
1198
1619
  }
1199
1620
 
1200
1621
  function installAgents(args) {
@@ -1239,6 +1660,11 @@ function uninstallAgents(args) {
1239
1660
  if (!args._.length) die("pass agent names to uninstall");
1240
1661
  const { dest } = agentTarget(args.flags);
1241
1662
  const l = ledger(dest);
1663
+ if (args.flags["dry-run"]) {
1664
+ for (const a of args._) console.log(`would remove agent ${a} from ${dest}`);
1665
+ return;
1666
+ }
1667
+ if (!confirm(`Remove ${args._.length} agent(s) from ${dest}: ${args._.join(", ")}?`, args.flags)) return console.log("cancelled — nothing removed.");
1242
1668
  for (const a of args._) {
1243
1669
  const rec = l.data[a];
1244
1670
  for (const f of new Set([rec && rec.file, `${a}.md`, `${a}.json`, `${a}.agent.md`, `${a}.toml`].filter(Boolean)))
@@ -1280,6 +1706,7 @@ function firstValueLines(p) {
1280
1706
  function infoPack(args) {
1281
1707
  const cat = resolveCatalog(args.flags.from);
1282
1708
  const ps = packsOf(cat) || die(`catalog '${cat.source}' has no packs`);
1709
+ packAlias(args.flags, ps);
1283
1710
  const name = args.flags.pack;
1284
1711
  const p = ps[name] || die(`unknown pack '${name}' — available: ${Object.keys(ps).join(", ")}`);
1285
1712
  if (args.flags.json)
@@ -1398,7 +1825,8 @@ function bootstrap() {
1398
1825
 
1399
1826
  const args = parseArgs(process.argv.slice(2));
1400
1827
  const cmd = args._.shift();
1401
- const commands = { list, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, validate: validateCmd, scan, bootstrap };
1402
- if (!cmd || cmd === "help" || args.flags.help) console.log(HELP);
1828
+ const commands = { list, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, diff, doctor, "new-skill": newSkill, validate: validateCmd, scan, bootstrap };
1829
+ if (args.flags.version || cmd === "version") console.log(readJSON(path.join(ROOT, "package.json")).version);
1830
+ else if (!cmd || cmd === "help" || args.flags.help) console.log(HELP);
1403
1831
  else if (commands[cmd]) commands[cmd](args);
1404
1832
  else die(`unknown command '${cmd}' — run: skilldrop help`);