skilldrop-cli 0.13.6 → 0.13.8
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.
- package/bin/skilldrop.js +472 -46
- package/contracts/pack.schema.json +26 -0
- package/package.json +1 -1
- package/packs/ai-engineering/pack.json +36 -1
- package/packs/ai-engineering/skills/agent-threat-model/SKILL.md +5 -2
- package/packs/ai-engineering/skills/agent-threat-model/manifest.json +1 -1
- package/packs/ai-engineering/skills/agent-threat-model/reference.md +19 -0
- package/packs/ai-engineering/skills/agent-threat-model/templates/agent-threat-model.md +3 -3
- package/packs/ai-engineering/skills/ai-usage-report/evals/eval_queries.json +30 -0
- package/packs/ai-engineering/skills/ai-usage-report/evals/evals.json +19 -0
- package/packs/ai-engineering/skills/llm-eval-harness/evals/eval_queries.json +34 -0
- package/packs/ai-engineering/skills/llm-eval-harness/evals/evals.json +19 -0
- package/packs/claude-api/pack.json +27 -0
- package/packs/claude-api/skills/eval-harness-generator/evals/eval_queries.json +30 -0
- package/packs/claude-api/skills/eval-harness-generator/evals/evals.json +19 -0
- package/packs/claude-api/skills/prompt-caching-advisor/evals/eval_queries.json +30 -0
- package/packs/claude-api/skills/prompt-caching-advisor/evals/evals.json +19 -0
- package/packs/claude-api/skills/token-budget-estimator/evals/eval_queries.json +30 -0
- package/packs/claude-api/skills/token-budget-estimator/evals/evals.json +18 -0
- package/packs/claude-api/skills/tool-use-schema-writer/evals/eval_queries.json +30 -0
- package/packs/claude-api/skills/tool-use-schema-writer/evals/evals.json +18 -0
- package/packs/core/pack.json +29 -0
- package/packs/design/pack.json +39 -1
- package/packs/dev-team/pack.json +35 -1
- package/packs/dev-team/skills/accessibility-audit/evals/eval_queries.json +30 -0
- package/packs/dev-team/skills/accessibility-audit/evals/evals.json +19 -0
- package/packs/dev-team/skills/contribution-wizard/evals/eval_queries.json +30 -0
- package/packs/dev-team/skills/contribution-wizard/evals/evals.json +19 -0
- package/packs/dev-team/skills/feature-implement-loop/evals/eval_queries.json +30 -0
- package/packs/dev-team/skills/feature-implement-loop/evals/evals.json +19 -0
- package/packs/dev-team/skills/release-notes/evals/eval_queries.json +30 -0
- package/packs/dev-team/skills/release-notes/evals/evals.json +19 -0
- package/packs/product-manager/pack.json +40 -1
- package/packs/product-manager/skills/business-case/evals/eval_queries.json +30 -0
- package/packs/product-manager/skills/business-case/evals/evals.json +19 -0
- package/packs/solution-architect/pack.json +44 -1
- package/packs/solution-architect/skills/tech-comparison-matrix/evals/eval_queries.json +30 -0
- package/packs/solution-architect/skills/tech-comparison-matrix/evals/evals.json +18 -0
- package/packs/sre-oncall/pack.json +33 -1
- package/packs/sre-oncall/skills/capacity-cost-model/evals/eval_queries.json +30 -0
- package/packs/sre-oncall/skills/capacity-cost-model/evals/evals.json +19 -0
- package/packs/stakeholder-comms/pack.json +39 -1
- package/packs/stakeholder-comms/skills/guide-builder/evals/eval_queries.json +34 -0
- package/packs/stakeholder-comms/skills/guide-builder/evals/evals.json +18 -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
|
-
--
|
|
80
|
-
|
|
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
|
}
|
|
@@ -252,7 +266,7 @@ function skillDir(cat, s) {
|
|
|
252
266
|
// vanish in between and the read throws. Handle the absence, do not predict it.
|
|
253
267
|
function readIfPresent(p, absent = "") {
|
|
254
268
|
try { return fs.readFileSync(p, "utf8"); }
|
|
255
|
-
catch (e) { if (e.code === "ENOENT") return absent; throw e; }
|
|
269
|
+
catch (e) { if (e.code === "ENOENT" || e.code === "ENOTDIR") return absent; throw e; }
|
|
256
270
|
}
|
|
257
271
|
function reEscape(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
258
272
|
function skillExists(cat, s) { const d = skillDir(cat, s); return !!d && fs.existsSync(d); }
|
|
@@ -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 =
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
|
|
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
|
-
|
|
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).
|
|
@@ -651,9 +771,21 @@ function gitRoot(startDir) {
|
|
|
651
771
|
}
|
|
652
772
|
}
|
|
653
773
|
|
|
654
|
-
|
|
774
|
+
/* Where git actually reads the pre-commit hook. In a worktree or a submodule .git is a file
|
|
775
|
+
("gitdir: ..."), not a folder, and core.hooksPath can move hooks anywhere — so ask git, and
|
|
776
|
+
only fall back to .git/hooks when git isn't on the PATH. */
|
|
777
|
+
function preCommitPath(root) {
|
|
778
|
+
try {
|
|
779
|
+
const out = execFileSync("git", ["rev-parse", "--git-path", "hooks/pre-commit"],
|
|
780
|
+
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
781
|
+
if (out) return path.resolve(root, out);
|
|
782
|
+
} catch (e) { /* no git binary, or not a repo after all */ }
|
|
783
|
+
return path.join(root, ".git", "hooks", "pre-commit");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Append an idempotent, marker-fenced reminder to the repo's pre-commit hook. IDE-agnostic.
|
|
655
787
|
function writeGitPreCommitHook(root, skill, hook) {
|
|
656
|
-
const p =
|
|
788
|
+
const p = preCommitPath(root);
|
|
657
789
|
const marker = `skilldrop-hook:${skill}:pre-commit-review`;
|
|
658
790
|
let body = readIfPresent(p); // read-and-handle, not check-then-read
|
|
659
791
|
if (!body.startsWith("#!")) body = "#!/bin/sh\n" + body;
|
|
@@ -688,19 +820,23 @@ function writeClaudeSessionHook(settingsPath, skill, hook) {
|
|
|
688
820
|
}
|
|
689
821
|
|
|
690
822
|
// Wire one skill's declared hooks; returns human-readable status lines.
|
|
691
|
-
|
|
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) {
|
|
692
828
|
let hooks = [];
|
|
693
829
|
try { hooks = manifestOf(cat, skill).hooks || []; } catch (e) { return []; }
|
|
694
830
|
const lines = [];
|
|
695
831
|
for (const h of hooks) {
|
|
696
832
|
if (h.event === "pre-commit-review") {
|
|
697
833
|
const root = gitRoot(process.cwd());
|
|
698
|
-
if (root) lines.push(` ${skill}: pre-commit reminder -> ${
|
|
834
|
+
if (root) lines.push(` ${skill}: pre-commit reminder -> ${preCommitPath(root)}`);
|
|
699
835
|
else lines.push(` ${skill}: pre-commit-review skipped — no git repo at ${process.cwd()}`);
|
|
700
836
|
if (root) writeGitPreCommitHook(root, skill, h);
|
|
701
837
|
} else if (h.event === "session-start") {
|
|
702
838
|
if (ide === "claude") {
|
|
703
|
-
const sp =
|
|
839
|
+
const sp = claudeSettings(dest, local);
|
|
704
840
|
const written = writeClaudeSessionHook(sp, skill, h);
|
|
705
841
|
lines.push(written ? ` ${skill}: session-start context -> ${sp}`
|
|
706
842
|
: ` ${skill}: session-start skipped — ${sp} is not valid JSON`);
|
|
@@ -717,7 +853,7 @@ function emitHooks(cat, skill, dest, ide) {
|
|
|
717
853
|
function removeHooksFor(skill, dest, ide) {
|
|
718
854
|
const root = gitRoot(process.cwd());
|
|
719
855
|
if (root) {
|
|
720
|
-
const p =
|
|
856
|
+
const p = preCommitPath(root);
|
|
721
857
|
const body = readIfPresent(p, null);
|
|
722
858
|
if (body !== null) {
|
|
723
859
|
const q = reEscape(skill);
|
|
@@ -726,8 +862,7 @@ function removeHooksFor(skill, dest, ide) {
|
|
|
726
862
|
if (next !== body) fs.writeFileSync(p, next);
|
|
727
863
|
}
|
|
728
864
|
}
|
|
729
|
-
if (ide === "claude") {
|
|
730
|
-
const sp = path.join(path.dirname(dest), "settings.json");
|
|
865
|
+
if (ide === "claude") for (const sp of [claudeSettings(dest, false), claudeSettings(dest, true)]) {
|
|
731
866
|
const raw = readIfPresent(sp, null);
|
|
732
867
|
if (raw !== null) {
|
|
733
868
|
try {
|
|
@@ -789,7 +924,7 @@ function fileHashes(dir) {
|
|
|
789
924
|
local edited, upstream moved -> keep the local file, write the new one as <file>.upstream
|
|
790
925
|
A file the catalog dropped is removed only if the user never touched it. The new baseline is
|
|
791
926
|
what the catalog ships now, so a kept edit stays "edited" until the user merges it. */
|
|
792
|
-
function mergeOne(cat, s, dest, ide, l, notes) {
|
|
927
|
+
function mergeOne(cat, s, dest, ide, l, notes, dry = false) {
|
|
793
928
|
const m = manifestOf(cat, s);
|
|
794
929
|
const src = skillDir(cat, s), dst = path.join(dest, s);
|
|
795
930
|
const base = l.data[s].files, next = fileHashes(src);
|
|
@@ -798,23 +933,27 @@ function mergeOne(cat, s, dest, ide, l, notes) {
|
|
|
798
933
|
const localHash = (rel) => { try { return sha256(fs.readFileSync(at(dst, rel))); } catch (e) { return null; } };
|
|
799
934
|
for (const [rel, h] of Object.entries(next)) {
|
|
800
935
|
const mine = localHash(rel), to = at(dst, rel);
|
|
801
|
-
if (mine === h) { fs.rmSync(to + ".upstream", { force: true }); continue; }
|
|
936
|
+
if (mine === h) { if (!dry) fs.rmSync(to + ".upstream", { force: true }); continue; }
|
|
802
937
|
if (h === base[rel]) continue;
|
|
803
|
-
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
804
938
|
if (mine === null ? !(rel in base) : mine === base[rel]) {
|
|
939
|
+
if (dry) continue;
|
|
940
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
805
941
|
fs.copyFileSync(at(src, rel), to);
|
|
806
942
|
fs.rmSync(to + ".upstream", { force: true });
|
|
807
943
|
} else {
|
|
808
|
-
fs.copyFileSync(at(src, rel), to + ".upstream");
|
|
809
944
|
kept.push(rel);
|
|
945
|
+
if (dry) continue;
|
|
946
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
947
|
+
fs.copyFileSync(at(src, rel), to + ".upstream");
|
|
810
948
|
}
|
|
811
949
|
}
|
|
812
950
|
for (const rel of Object.keys(base)) {
|
|
813
951
|
if (rel in next) continue;
|
|
814
952
|
const mine = localHash(rel);
|
|
815
|
-
if (mine === base[rel]) fs.rmSync(at(dst, rel), { force: true });
|
|
953
|
+
if (mine === base[rel]) { if (!dry) fs.rmSync(at(dst, rel), { force: true }); }
|
|
816
954
|
else if (mine !== null) orphaned.push(rel);
|
|
817
955
|
}
|
|
956
|
+
if (dry) return { m, kept, orphaned };
|
|
818
957
|
const note = writeWiring(ide, dest, s, m.description);
|
|
819
958
|
if (note && notes) notes.push(note);
|
|
820
959
|
l.data[s] = { version: m.version, source: cat.source, files: next };
|
|
@@ -825,14 +964,15 @@ function mergeOne(cat, s, dest, ide, l, notes) {
|
|
|
825
964
|
command (RFC-0020). Needs a target with BOTH a subagent and a skill format, so it accepts
|
|
826
965
|
the default (Claude Code), --project, --ide kiro, or --dest; other IDEs install the halves
|
|
827
966
|
separately. Delegates to installAgents + install so every existing safety/craft gate applies. */
|
|
967
|
+
const PANEL_IDES = ["kiro", "codex", "antigravity", "copilot"];
|
|
828
968
|
function installPanel(args) {
|
|
829
969
|
const name = args.flags.panel;
|
|
830
970
|
const p = PANELS[name];
|
|
831
971
|
if (!p) die(`unknown panel '${name}' — available: ${Object.keys(PANELS).join(", ")}`);
|
|
832
972
|
const ide = args.flags.ide;
|
|
833
|
-
if (ide && ide
|
|
973
|
+
if (ide && !PANEL_IDES.includes(ide) && !args.flags.dest)
|
|
834
974
|
die(`--panel installs subagents + the orchestrator skill together, so it needs a target with both formats:\n` +
|
|
835
|
-
` (default | --project) Claude Code, --ide
|
|
975
|
+
` (default | --project) Claude Code, --ide ${PANEL_IDES.join(" | ")}, or --dest <dir>.\n` +
|
|
836
976
|
` For ${ide}: install the agents (skilldrop install --agent ${p.agents.join(" ")} --ide ${ide}) ` +
|
|
837
977
|
`and the skill (skilldrop install ${p.skill} --ide ${ide}) separately.`);
|
|
838
978
|
const flags = Object.assign({}, args.flags);
|
|
@@ -895,6 +1035,13 @@ function installProfile(args) {
|
|
|
895
1035
|
}
|
|
896
1036
|
|
|
897
1037
|
function install(args) {
|
|
1038
|
+
if (args.flags["dry-run"] && (args.flags.panel || args.flags.profile || args.flags.loop || args.flags.agent))
|
|
1039
|
+
die("--dry-run covers skill installs, update and uninstall. To preview a panel, profile, loop or agent,\n" +
|
|
1040
|
+
" read what it contains first: skilldrop profiles | loops | agents --json");
|
|
1041
|
+
if (args.flags.local && !gitRoot(process.cwd()))
|
|
1042
|
+
die("--local hides the install from git, so it needs a git repo — run it from inside one");
|
|
1043
|
+
if (args.flags.local && (args.flags.panel || args.flags.profile || args.flags.agent))
|
|
1044
|
+
die("--local covers skills and loops. Agents have their own folders; install them with --dest into a path you exclude yourself");
|
|
898
1045
|
if (args.flags.panel) return installPanel(args);
|
|
899
1046
|
if (args.flags.profile) return installProfile(args);
|
|
900
1047
|
if (args.flags.loop) return installLoops(args);
|
|
@@ -911,6 +1058,7 @@ function install(args) {
|
|
|
911
1058
|
}
|
|
912
1059
|
gate(cat, names);
|
|
913
1060
|
const { dest, ide } = target(args.flags);
|
|
1061
|
+
if (args.flags["dry-run"]) return planInstall(cat, names, dest, ide, args.flags);
|
|
914
1062
|
fs.mkdirSync(dest, { recursive: true });
|
|
915
1063
|
const l = ledger(dest);
|
|
916
1064
|
const pipDeps = [], suggestions = new Set(), notes = [];
|
|
@@ -923,10 +1071,20 @@ function install(args) {
|
|
|
923
1071
|
saveLedger(l);
|
|
924
1072
|
console.log(`\n${names.length} skill(s) installed (${ide}).`);
|
|
925
1073
|
if (notes.length) console.log(`\nCleanup:\n${notes.join("\n")}`);
|
|
1074
|
+
const local = !!args.flags.local;
|
|
1075
|
+
if (local) {
|
|
1076
|
+
const ex = addLocal(dest, ide, names);
|
|
1077
|
+
console.log(`\n--local: listed in ${path.relative(process.cwd(), ex) || ex}, so git won't see these files.` +
|
|
1078
|
+
`\n Nothing in the repo's tracked files changed. Remove with: skilldrop uninstall <skill> --local`);
|
|
1079
|
+
}
|
|
926
1080
|
|
|
927
1081
|
const withHooks = names.filter((s) => { try { return (manifestOf(cat, s).hooks || []).length; } catch (e) { return false; } });
|
|
928
1082
|
if (withHooks.length && args.flags["with-hooks"]) {
|
|
929
|
-
const lines = withHooks.flatMap((s) => emitHooks(cat, s, dest, ide));
|
|
1083
|
+
const lines = withHooks.flatMap((s) => emitHooks(cat, s, dest, ide, local));
|
|
1084
|
+
if (local && ide === "claude") {
|
|
1085
|
+
const root = gitRoot(process.cwd());
|
|
1086
|
+
writeExclude(root, readExclude(root).entries.concat(excludeEntry(root, claudeSettings(dest, true), false)));
|
|
1087
|
+
}
|
|
930
1088
|
console.log(`\nHooks wired (RFC-0006):\n${lines.join("\n")}`);
|
|
931
1089
|
if (cat.source !== BUNDLED)
|
|
932
1090
|
console.log(` NOTE: these hooks run commands from third-party catalog '${cat.source}' — read them before trusting them.`);
|
|
@@ -945,7 +1103,7 @@ function install(args) {
|
|
|
945
1103
|
: "\nSupply-chain scan: no flagged patterns.");
|
|
946
1104
|
}
|
|
947
1105
|
if (ide === "generic")
|
|
948
|
-
console.log("wiring: attach each skill's SKILL.md to your agent (Continue/Cline: @file, Aider: /add
|
|
1106
|
+
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.");
|
|
949
1107
|
for (const s of pipDeps)
|
|
950
1108
|
console.log(`deps: ${s} needs Python packages — run: cd ${path.join(dest, s)} && python3 -m pip install -r requirements.txt`);
|
|
951
1109
|
if (suggestions.size)
|
|
@@ -954,6 +1112,31 @@ function install(args) {
|
|
|
954
1112
|
if (pk && pk["first-value"]) console.log("\n" + firstValueLines(pk).join("\n"));
|
|
955
1113
|
}
|
|
956
1114
|
|
|
1115
|
+
/* install --dry-run: everything install would write, and nothing written. */
|
|
1116
|
+
function planInstall(cat, names, dest, ide, flags) {
|
|
1117
|
+
const l = ledger(dest);
|
|
1118
|
+
console.log(`dry run — nothing will be written. Target: ${dest} (${ide})\n`);
|
|
1119
|
+
for (const s of names) {
|
|
1120
|
+
const m = manifestOf(cat, s);
|
|
1121
|
+
const have = l.data[s];
|
|
1122
|
+
const verb = !have ? "install" : lver(have) === m.version ? "reinstall" : `replace ${lver(have)} with`;
|
|
1123
|
+
const edits = editedFiles(dest, s, have);
|
|
1124
|
+
console.log(`would ${verb} ${s}@${m.version} -> ${path.join(dest, s)}`);
|
|
1125
|
+
if (edits.length) console.log(` overwrites your edits in: ${edits.join(", ")} (update keeps them; install does not)`);
|
|
1126
|
+
const w = wiringPath(ide, dest, s);
|
|
1127
|
+
if (w && ide === "cursor") console.log(` and write ${path.relative(process.cwd(), w)}`);
|
|
1128
|
+
}
|
|
1129
|
+
if (flags.local) {
|
|
1130
|
+
const root = gitRoot(process.cwd());
|
|
1131
|
+
console.log(`\n--local: would list ${names.length} folder(s) and the ledger in ${path.relative(process.cwd(), excludePath(root))}`);
|
|
1132
|
+
}
|
|
1133
|
+
if (flags["with-hooks"]) {
|
|
1134
|
+
const hooked = names.filter((s) => { try { return (manifestOf(cat, s).hooks || []).length; } catch (e) { return false; } });
|
|
1135
|
+
if (hooked.length) console.log(`\nwould wire hooks for: ${hooked.join(", ")}`);
|
|
1136
|
+
}
|
|
1137
|
+
console.log(`\n${names.length} skill(s). Rerun without --dry-run to install.`);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
957
1140
|
function installedRows(flags) {
|
|
958
1141
|
const { dest, ide } = target(flags);
|
|
959
1142
|
const l = ledger(dest);
|
|
@@ -972,6 +1155,16 @@ function installedRows(flags) {
|
|
|
972
1155
|
function update(args) {
|
|
973
1156
|
const { dest, ide, l, rows } = installedRows(args.flags);
|
|
974
1157
|
if (!rows.length) return console.log(`nothing installed at ${dest}`);
|
|
1158
|
+
const dry = !!args.flags["dry-run"];
|
|
1159
|
+
const due = rows.filter((r) => r.current && r.current !== r.installed);
|
|
1160
|
+
if (args.flags.force && !dry) {
|
|
1161
|
+
const lost = due.flatMap((r) => editedFiles(dest, r.s, l.data[r.s]).map((f) => `${r.s}/${f}`));
|
|
1162
|
+
if (lost.length) {
|
|
1163
|
+
console.log(`--force overwrites ${lost.length} file(s) you edited:\n ${lost.join("\n ")}`);
|
|
1164
|
+
if (!confirm("Overwrite them?", args.flags)) return console.log("cancelled — nothing changed. Drop --force to keep your edits as <file>.upstream.");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (dry) return planUpdate(dest, ide, l, rows, args.flags);
|
|
975
1168
|
let n = 0, conflicts = 0;
|
|
976
1169
|
for (const r of rows) {
|
|
977
1170
|
if (!r.current) { console.log(`skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
|
|
@@ -994,10 +1187,42 @@ function update(args) {
|
|
|
994
1187
|
}
|
|
995
1188
|
saveLedger(l);
|
|
996
1189
|
console.log(n ? `\n${n} skill(s) updated.` : "everything up to date.");
|
|
1190
|
+
// An update is a fresh act of trust in a third-party catalog (OWASP AST07): scan what changed.
|
|
1191
|
+
const thirdParty = {};
|
|
1192
|
+
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);
|
|
1193
|
+
for (const { cat, names } of Object.values(thirdParty)) {
|
|
1194
|
+
const { total } = printScan(cat, names, { compact: true });
|
|
1195
|
+
console.log(total ? `\n${total} pattern(s) in the updated skills from '${cat.source}' worth reading. Full detail: skilldrop scan --from ${cat.source}`
|
|
1196
|
+
: `\nSupply-chain scan of the updated skills from '${cat.source}': no flagged patterns.`);
|
|
1197
|
+
}
|
|
997
1198
|
if (conflicts)
|
|
998
1199
|
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.`);
|
|
999
1200
|
}
|
|
1000
1201
|
|
|
1202
|
+
/* update --dry-run: the same per-skill, per-file decisions update makes, reported, not applied. */
|
|
1203
|
+
function planUpdate(dest, ide, l, rows, flags) {
|
|
1204
|
+
console.log(`dry run — nothing will be written. Target: ${dest} (${ide})\n`);
|
|
1205
|
+
let n = 0;
|
|
1206
|
+
for (const r of rows) {
|
|
1207
|
+
if (!r.current) { console.log(`would skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
|
|
1208
|
+
if (r.current === r.installed) continue;
|
|
1209
|
+
const problems = checkSkill(r.cat, r.s);
|
|
1210
|
+
if (problems.length) { console.log(`would skip ${r.s}: fails structural check (${problems[0]})`); continue; }
|
|
1211
|
+
n++;
|
|
1212
|
+
const entry = l.data[r.s];
|
|
1213
|
+
console.log(`would update ${r.s} ${r.installed} -> ${r.current} (${r.src})`);
|
|
1214
|
+
if (flags.force || typeof entry !== "object" || !entry.files) {
|
|
1215
|
+
const lost = editedFiles(dest, r.s, entry);
|
|
1216
|
+
if (lost.length) console.log(` --force overwrites your edits in: ${lost.join(", ")}`);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
const { kept, orphaned } = mergeOne(r.cat, r.s, dest, ide, l, null, true);
|
|
1220
|
+
for (const f of kept) console.log(` would keep your edits in ${f} and write the new version to ${f}.upstream`);
|
|
1221
|
+
for (const f of orphaned) console.log(` would keep ${f} — dropped upstream, but you edited it`);
|
|
1222
|
+
}
|
|
1223
|
+
console.log(n ? `\n${n} skill(s) would update. Rerun without --dry-run to apply.` : "everything up to date.");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1001
1226
|
function outdated(args) {
|
|
1002
1227
|
const { dest, rows } = installedRows(args.flags);
|
|
1003
1228
|
const stale = rows.filter((r) => r.current && r.current !== r.installed);
|
|
@@ -1022,7 +1247,25 @@ function uninstall(args) {
|
|
|
1022
1247
|
if (!args._.length) die("pass skill names to uninstall");
|
|
1023
1248
|
const { dest, ide } = target(args.flags);
|
|
1024
1249
|
const l = ledger(dest);
|
|
1025
|
-
|
|
1250
|
+
const present = args._.filter((s) => l.data[s] || fs.existsSync(path.join(dest, s)));
|
|
1251
|
+
const missing = args._.filter((s) => !present.includes(s));
|
|
1252
|
+
for (const s of missing) console.log(`${s} is not installed at ${dest}`);
|
|
1253
|
+
if (!present.length) return;
|
|
1254
|
+
const edits = present.flatMap((s) => editedFiles(dest, s, l.data[s]).map((f) => `${s}/${f}`));
|
|
1255
|
+
if (args.flags["dry-run"]) {
|
|
1256
|
+
console.log(`dry run — nothing will be removed. Target: ${dest} (${ide})\n`);
|
|
1257
|
+
for (const s of present) {
|
|
1258
|
+
console.log(`would remove ${path.join(dest, s)}`);
|
|
1259
|
+
const w = wiringPath(ide, dest, s);
|
|
1260
|
+
if (w && fs.existsSync(w)) console.log(` and ${path.relative(process.cwd(), w)}`);
|
|
1261
|
+
}
|
|
1262
|
+
if (edits.length) console.log(`\nincluding ${edits.length} file(s) you edited: ${edits.join(", ")}`);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const what = `Remove ${present.length} skill(s) from ${dest}: ${present.join(", ")}` +
|
|
1266
|
+
(edits.length ? `\n (${edits.length} of the files have your edits: ${edits.join(", ")})` : "") + "?";
|
|
1267
|
+
if (!confirm(what, args.flags)) return console.log("cancelled — nothing removed.");
|
|
1268
|
+
for (const s of present) {
|
|
1026
1269
|
fs.rmSync(path.join(dest, s), { recursive: true, force: true });
|
|
1027
1270
|
const w = wiringPath(ide, dest, s);
|
|
1028
1271
|
if (w) fs.rmSync(w, { force: true });
|
|
@@ -1031,6 +1274,176 @@ function uninstall(args) {
|
|
|
1031
1274
|
console.log(`removed ${s} from ${dest}`);
|
|
1032
1275
|
}
|
|
1033
1276
|
saveLedger(l);
|
|
1277
|
+
const ex = removeLocal(dest, ide, present);
|
|
1278
|
+
if (ex) console.log(`dropped the --local entries from ${path.relative(process.cwd(), ex) || ex}`);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/* diff <skill>: what differs between the installed copy and the catalog's current one — your
|
|
1282
|
+
edits, an upstream change, or both. File-level always; line-level through git when it is on
|
|
1283
|
+
the PATH (git diff --no-index works outside any repo). */
|
|
1284
|
+
function diff(args) {
|
|
1285
|
+
const s = args._[0] || die("pass an installed skill name: skilldrop diff <skill>");
|
|
1286
|
+
const { dest } = target(args.flags);
|
|
1287
|
+
const l = ledger(dest);
|
|
1288
|
+
const entry = l.data[s];
|
|
1289
|
+
if (!entry && !fs.existsSync(path.join(dest, s))) die(`'${s}' is not installed at ${dest}`);
|
|
1290
|
+
if (entry && entry.loop) die(`'${s}' is a loop — diff compares skills`);
|
|
1291
|
+
const src = args.flags.from || (entry ? lsrc(entry) : BUNDLED);
|
|
1292
|
+
const cat = resolveCatalog(src === BUNDLED ? undefined : src);
|
|
1293
|
+
if (!skillExists(cat, s)) die(`'${s}' is no longer in catalog '${cat.source}'`);
|
|
1294
|
+
const theirs = fileHashes(skillDir(cat, s)), mine = fileHashes(path.join(dest, s));
|
|
1295
|
+
const base = (entry && entry.files) || {};
|
|
1296
|
+
const changed = Object.keys(theirs).filter((f) => f in mine && mine[f] !== theirs[f]).sort();
|
|
1297
|
+
const onlyUp = Object.keys(theirs).filter((f) => !(f in mine)).sort();
|
|
1298
|
+
const onlyMine = Object.keys(mine).filter((f) => !(f in theirs)).sort();
|
|
1299
|
+
const why = (f) => !base[f] ? "" : mine[f] !== base[f] && theirs[f] !== base[f] ? " (you edited it, and so did the catalog)"
|
|
1300
|
+
: mine[f] !== base[f] ? " (your edit)" : " (catalog changed it)";
|
|
1301
|
+
const iv = entry ? lver(entry) : "?", cv = manifestOf(cat, s).version;
|
|
1302
|
+
console.log(`${s}: installed ${iv} at ${path.join(dest, s)} vs catalog '${cat.source}' ${cv}`);
|
|
1303
|
+
if (!changed.length && !onlyUp.length && !onlyMine.length) return console.log("identical.");
|
|
1304
|
+
for (const f of changed) console.log(` M ${f}${why(f)}`);
|
|
1305
|
+
for (const f of onlyUp) console.log(` + ${f} (in the catalog, not installed)`);
|
|
1306
|
+
for (const f of onlyMine) console.log(` - ${f} (installed only)`);
|
|
1307
|
+
if (args.flags.stat) return;
|
|
1308
|
+
let git = true;
|
|
1309
|
+
for (const f of changed) {
|
|
1310
|
+
const a = path.join(skillDir(cat, s), ...f.split("/")), b = path.join(dest, s, ...f.split("/"));
|
|
1311
|
+
try {
|
|
1312
|
+
execFileSync("git", ["--no-pager", "diff", "--no-index", "--color=auto", "--src-prefix=catalog/", "--dst-prefix=installed/", a, b],
|
|
1313
|
+
{ stdio: ["ignore", "inherit", "inherit"] });
|
|
1314
|
+
} catch (e) {
|
|
1315
|
+
if (e.code === "ENOENT") { git = false; break; } // no git binary
|
|
1316
|
+
// exit status 1 is git diff's "files differ" — the expected case
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (!git) console.log("\n(git not found — line-level diff needs git on the PATH; file list above)");
|
|
1320
|
+
if (changed.length) console.log(`\nTake the catalog's version: skilldrop update --force, or install ${s} again.`);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/* doctor: check an install target against its ledger and report what's out of step — skills
|
|
1324
|
+
recorded but gone, skills on disk nobody recorded, leftover .upstream files, wiring and hooks
|
|
1325
|
+
for skills no longer installed. Report-only: it changes nothing, and each finding says the fix. */
|
|
1326
|
+
function doctor(args) {
|
|
1327
|
+
const { dest, ide } = target(args.flags);
|
|
1328
|
+
const findings = [];
|
|
1329
|
+
const add = (level, msg, fix) => findings.push({ level, msg, fix });
|
|
1330
|
+
const lp = path.join(dest, LEDGER);
|
|
1331
|
+
let data = {};
|
|
1332
|
+
const raw = readIfPresent(lp, null);
|
|
1333
|
+
if (raw === null) add("info", `no ledger at ${lp}`, "nothing installed here by skilldrop — or a different target; try --project or --ide");
|
|
1334
|
+
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"); }
|
|
1335
|
+
const names = Object.keys(data);
|
|
1336
|
+
for (const n of names) {
|
|
1337
|
+
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)`);
|
|
1338
|
+
else if (!data[n].loop && !data[n].file) {
|
|
1339
|
+
const e = editedFiles(dest, n, data[n]);
|
|
1340
|
+
if (e.length) add("info", `${n}: you edited ${e.join(", ")}`, `skilldrop diff ${n}`);
|
|
1341
|
+
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`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (fs.existsSync(dest)) {
|
|
1345
|
+
for (const d of fs.readdirSync(dest, { withFileTypes: true })) {
|
|
1346
|
+
if (!d.isDirectory()) continue;
|
|
1347
|
+
const dir = path.join(dest, d.name);
|
|
1348
|
+
if (fs.existsSync(path.join(dir, "SKILL.md")) && !data[d.name])
|
|
1349
|
+
add("info", `${d.name} is on disk but not in the ledger`, "installed by hand or another tool — skilldrop won't update it");
|
|
1350
|
+
for (const f of walkFiles(dir)) if (f.endsWith(".upstream"))
|
|
1351
|
+
add("warn", `${path.relative(dest, f)} is waiting to be merged`, `merge it into ${path.relative(dest, f.slice(0, -9))}, then delete it`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (ide === "cursor") {
|
|
1355
|
+
const rules = path.join(path.dirname(dest), "rules");
|
|
1356
|
+
for (const f of fs.existsSync(rules) ? fs.readdirSync(rules) : []) {
|
|
1357
|
+
const body = readIfPresent(path.join(rules, f));
|
|
1358
|
+
const m = body.match(/\.cursor\/skills\/([^/\s]+)\/SKILL\.md/);
|
|
1359
|
+
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}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (ide === "kiro") for (const n of names) {
|
|
1363
|
+
const w = wiringPath("kiro", dest, n);
|
|
1364
|
+
if (readIfPresent(w).startsWith(KIRO_SHIM_PREFIX)) add("warn", `stale steering shim ${path.relative(process.cwd(), w)}`, `skilldrop install ${n} --ide kiro removes it`);
|
|
1365
|
+
}
|
|
1366
|
+
const root = gitRoot(process.cwd());
|
|
1367
|
+
if (root) {
|
|
1368
|
+
const hook = readIfPresent(preCommitPath(root));
|
|
1369
|
+
for (const m of hook.matchAll(/# >>> skilldrop-hook:([^:\n]+):/g))
|
|
1370
|
+
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)`);
|
|
1371
|
+
for (const e of readExclude(root).entries)
|
|
1372
|
+
if (!fs.existsSync(path.join(root, e.replace(/^\//, "")))) add("info", `--local entry ${e} matches nothing`, "harmless; skilldrop uninstall --local tidies it");
|
|
1373
|
+
}
|
|
1374
|
+
if (ide === "claude") for (const local of [false, true]) {
|
|
1375
|
+
const sp = claudeSettings(dest, local);
|
|
1376
|
+
const body = readIfPresent(sp, null);
|
|
1377
|
+
if (body === null) continue;
|
|
1378
|
+
for (const m of body.matchAll(/skilldrop-hook:([^:"\s]+):session-start/g))
|
|
1379
|
+
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`);
|
|
1380
|
+
}
|
|
1381
|
+
if (args.flags.json) return emitJSON({ dest, ide, installed: names.length, findings });
|
|
1382
|
+
console.log(`skilldrop doctor — ${dest} (${ide}), ${names.length} recorded\n`);
|
|
1383
|
+
const icon = { error: "✗", warn: "!", info: "·" };
|
|
1384
|
+
for (const f of findings) console.log(`${icon[f.level]} ${f.msg}\n fix: ${f.fix}`);
|
|
1385
|
+
const bad = findings.filter((f) => f.level !== "info").length;
|
|
1386
|
+
console.log(findings.length ? `\n${bad} to fix, ${findings.length - bad} for information. Nothing was changed.` : "all good.");
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/* new-skill <name> --pack <pack>: scaffold a skill in the catalog you're standing in, with every
|
|
1390
|
+
file validate.py checks for, so the first run fails only on the parts that need a human. */
|
|
1391
|
+
function newSkill(args) {
|
|
1392
|
+
const name = args._[0] || die("usage: skilldrop new-skill <name> --pack <pack> [--tier light|standard|heavy]");
|
|
1393
|
+
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) die(`'${name}' isn't kebab-case — use lowercase words joined by hyphens`);
|
|
1394
|
+
const pack = args.flags.pack || die("pass --pack <pack> — the one pack it belongs in (core only if every role needs it)");
|
|
1395
|
+
const tier = args.flags.tier || "standard";
|
|
1396
|
+
if (!["light", "standard", "heavy"].includes(tier)) die("--tier is light, standard or heavy");
|
|
1397
|
+
const root = process.cwd();
|
|
1398
|
+
const pdir = path.join(root, "packs", pack);
|
|
1399
|
+
if (!fs.existsSync(path.join(pdir, "pack.json")))
|
|
1400
|
+
die(`no packs/${pack}/pack.json here — run new-skill from the root of a skilldrop catalog`);
|
|
1401
|
+
for (const p of fs.readdirSync(path.join(root, "packs")))
|
|
1402
|
+
if (fs.existsSync(path.join(root, "packs", p, "skills", name))) die(`packs/${p}/skills/${name} already exists`);
|
|
1403
|
+
const dir = path.join(pdir, "skills", name);
|
|
1404
|
+
const desc = `TODO: one sentence, use-case-first — what it produces. Use when the user asks for …`;
|
|
1405
|
+
const files = {
|
|
1406
|
+
"SKILL.md": `---\nname: ${name}\ndescription: ${desc}\n---\n\n# ${name}\n\nTODO: one paragraph — what you produce and for whom.\n\n` +
|
|
1407
|
+
`## 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` +
|
|
1408
|
+
`**Non-interactive runs** (subagent, CI, headless): TODO — what to assume, and when to emit \`BLOCKED: <what is missing>\`.\n\n` +
|
|
1409
|
+
`## Quality bar\n\n- TODO: a checkable property of a good output\n\n## When to use this skill\n\n- ✅ TODO\n\n` +
|
|
1410
|
+
`## 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`,
|
|
1411
|
+
"manifest.json": JSON.stringify({
|
|
1412
|
+
name, version: "0.1.0", description: desc, entrypoint: "SKILL.md",
|
|
1413
|
+
deps: { npm: [], pip: [] }, env: { required: [], optional: [] }, related: [], tags: ["todo"],
|
|
1414
|
+
model: { tier, rationale: "TODO: why this tier — how much reasoning the task needs" },
|
|
1415
|
+
}, null, 2) + "\n",
|
|
1416
|
+
"evals/evals.json": JSON.stringify({ skill_name: name, evals: [{ id: 1,
|
|
1417
|
+
prompt: "TODO: a realistic request with concrete details",
|
|
1418
|
+
assertions: ["TODO: a property the output must have, checkable by reading it"] }] }, null, 2) + "\n",
|
|
1419
|
+
"evals/eval_queries.json": "[\n" + [
|
|
1420
|
+
{ query: "TODO: a phrasing that should trigger this skill", should_trigger: true },
|
|
1421
|
+
{ query: "TODO: a near-miss that belongs to a sibling skill", should_trigger: false },
|
|
1422
|
+
].map((r) => " " + JSON.stringify(r)).join(",\n") + "\n]\n",
|
|
1423
|
+
};
|
|
1424
|
+
if (args.flags["dry-run"]) {
|
|
1425
|
+
for (const f of Object.keys(files)) console.log(`would write ${path.relative(root, path.join(dir, f))}`);
|
|
1426
|
+
return console.log(`would add ${name} (tier ${tier}) to model-routing.json`);
|
|
1427
|
+
}
|
|
1428
|
+
for (const [f, body] of Object.entries(files)) {
|
|
1429
|
+
fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true });
|
|
1430
|
+
fs.writeFileSync(path.join(dir, f), body);
|
|
1431
|
+
console.log(`wrote ${path.relative(root, path.join(dir, f))}`);
|
|
1432
|
+
}
|
|
1433
|
+
const rp = path.join(root, "model-routing.json");
|
|
1434
|
+
const routingRaw = readIfPresent(rp, null); // read-and-handle, not check-then-read
|
|
1435
|
+
if (routingRaw !== null) {
|
|
1436
|
+
const routing = JSON.parse(routingRaw);
|
|
1437
|
+
routing.skills = routing.skills || {};
|
|
1438
|
+
routing.skills[name] = { tier, rationale: "TODO: why this tier" };
|
|
1439
|
+
routing.skills = Object.fromEntries(Object.entries(routing.skills).sort(([a], [b]) => a.localeCompare(b)));
|
|
1440
|
+
fs.writeFileSync(rp, JSON.stringify(routing, null, 2) + "\n");
|
|
1441
|
+
console.log(`added ${name} to model-routing.json (tier ${tier})`);
|
|
1442
|
+
}
|
|
1443
|
+
console.log(`\nNext:\n 1. Replace every TODO — the description in SKILL.md and manifest.json must match.` +
|
|
1444
|
+
`\n 2. Add ${name} to an outcome in catalogue.json, and a row in guides/reference/skill-catalogue.md.` +
|
|
1445
|
+
`\n 3. python3 build_marketplace.py && python3 build_llms.py, then python3 validate.py — it lists what's left.` +
|
|
1446
|
+
`\n 4. Try it from another repo: skilldrop install ${name} --from ${root} --local`);
|
|
1034
1447
|
}
|
|
1035
1448
|
|
|
1036
1449
|
function listAgents(args) {
|
|
@@ -1155,6 +1568,7 @@ function installLoops(args) {
|
|
|
1155
1568
|
console.log(`installed loop ${n} -> ${out}`);
|
|
1156
1569
|
}
|
|
1157
1570
|
saveLedger(l);
|
|
1571
|
+
if (args.flags.local) addLocal(dest, ide, names);
|
|
1158
1572
|
|
|
1159
1573
|
const wanted = [];
|
|
1160
1574
|
for (const n of names) for (const sk of loopSkills(cat, n)) if (!wanted.includes(sk)) wanted.push(sk);
|
|
@@ -1175,6 +1589,11 @@ function uninstallLoops(args) {
|
|
|
1175
1589
|
if (!args._.length) die("pass loop names to uninstall");
|
|
1176
1590
|
const { dest, ide } = target(args.flags);
|
|
1177
1591
|
const l = ledger(dest);
|
|
1592
|
+
if (args.flags["dry-run"]) {
|
|
1593
|
+
for (const n of args._) console.log(`would remove loop ${path.join(dest, n)} (stage skills left in place)`);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (!confirm(`Remove ${args._.length} loop(s) from ${dest}: ${args._.join(", ")}?`, args.flags)) return console.log("cancelled — nothing removed.");
|
|
1178
1597
|
for (const n of args._) {
|
|
1179
1598
|
fs.rmSync(path.join(dest, n), { recursive: true, force: true });
|
|
1180
1599
|
const w = wiringPath(ide, dest, n);
|
|
@@ -1183,6 +1602,7 @@ function uninstallLoops(args) {
|
|
|
1183
1602
|
console.log(`removed loop ${n} from ${dest} (stage skills left in place)`);
|
|
1184
1603
|
}
|
|
1185
1604
|
saveLedger(l);
|
|
1605
|
+
removeLocal(dest, ide, args._);
|
|
1186
1606
|
}
|
|
1187
1607
|
|
|
1188
1608
|
function installAgents(args) {
|
|
@@ -1227,6 +1647,11 @@ function uninstallAgents(args) {
|
|
|
1227
1647
|
if (!args._.length) die("pass agent names to uninstall");
|
|
1228
1648
|
const { dest } = agentTarget(args.flags);
|
|
1229
1649
|
const l = ledger(dest);
|
|
1650
|
+
if (args.flags["dry-run"]) {
|
|
1651
|
+
for (const a of args._) console.log(`would remove agent ${a} from ${dest}`);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (!confirm(`Remove ${args._.length} agent(s) from ${dest}: ${args._.join(", ")}?`, args.flags)) return console.log("cancelled — nothing removed.");
|
|
1230
1655
|
for (const a of args._) {
|
|
1231
1656
|
const rec = l.data[a];
|
|
1232
1657
|
for (const f of new Set([rec && rec.file, `${a}.md`, `${a}.json`, `${a}.agent.md`, `${a}.toml`].filter(Boolean)))
|
|
@@ -1386,7 +1811,8 @@ function bootstrap() {
|
|
|
1386
1811
|
|
|
1387
1812
|
const args = parseArgs(process.argv.slice(2));
|
|
1388
1813
|
const cmd = args._.shift();
|
|
1389
|
-
const commands = { list, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, validate: validateCmd, scan, bootstrap };
|
|
1390
|
-
if (
|
|
1814
|
+
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 };
|
|
1815
|
+
if (args.flags.version || cmd === "version") console.log(readJSON(path.join(ROOT, "package.json")).version);
|
|
1816
|
+
else if (!cmd || cmd === "help" || args.flags.help) console.log(HELP);
|
|
1391
1817
|
else if (commands[cmd]) commands[cmd](args);
|
|
1392
1818
|
else die(`unknown command '${cmd}' — run: skilldrop help`);
|