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.
- package/README.md +1 -1
- package/bin/skilldrop.js +469 -41
- package/catalogue.json +1 -1
- 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/api-builder/pack.json +48 -0
- package/packs/api-builder/skills/eval-harness-generator/evals/eval_queries.json +30 -0
- package/packs/api-builder/skills/eval-harness-generator/evals/evals.json +19 -0
- package/packs/api-builder/skills/prompt-caching-advisor/evals/eval_queries.json +30 -0
- package/packs/api-builder/skills/prompt-caching-advisor/evals/evals.json +19 -0
- package/packs/api-builder/skills/token-budget-estimator/evals/eval_queries.json +30 -0
- package/packs/api-builder/skills/token-budget-estimator/evals/evals.json +18 -0
- package/packs/api-builder/skills/tool-use-schema-writer/evals/eval_queries.json +30 -0
- package/packs/api-builder/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/SKILL.md +1 -1
- 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/contribution-wizard/manifest.json +1 -1
- 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/profiles.json +1 -1
- package/packs/claude-api/pack.json +0 -21
- /package/packs/{claude-api → api-builder}/skills/eval-harness-generator/SKILL.md +0 -0
- /package/packs/{claude-api → api-builder}/skills/eval-harness-generator/manifest.json +0 -0
- /package/packs/{claude-api → api-builder}/skills/prompt-caching-advisor/SKILL.md +0 -0
- /package/packs/{claude-api → api-builder}/skills/prompt-caching-advisor/manifest.json +0 -0
- /package/packs/{claude-api → api-builder}/skills/token-budget-estimator/SKILL.md +0 -0
- /package/packs/{claude-api → api-builder}/skills/token-budget-estimator/manifest.json +0 -0
- /package/packs/{claude-api → api-builder}/skills/tool-use-schema-writer/SKILL.md +0 -0
- /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
|
-
--
|
|
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
|
}
|
|
@@ -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).
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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`);
|