skilldrop-cli 0.13.7 → 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 +455 -41
- 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
|
}
|
|
@@ -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 {
|
|
@@ -801,7 +924,7 @@ function fileHashes(dir) {
|
|
|
801
924
|
local edited, upstream moved -> keep the local file, write the new one as <file>.upstream
|
|
802
925
|
A file the catalog dropped is removed only if the user never touched it. The new baseline is
|
|
803
926
|
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) {
|
|
927
|
+
function mergeOne(cat, s, dest, ide, l, notes, dry = false) {
|
|
805
928
|
const m = manifestOf(cat, s);
|
|
806
929
|
const src = skillDir(cat, s), dst = path.join(dest, s);
|
|
807
930
|
const base = l.data[s].files, next = fileHashes(src);
|
|
@@ -810,23 +933,27 @@ function mergeOne(cat, s, dest, ide, l, notes) {
|
|
|
810
933
|
const localHash = (rel) => { try { return sha256(fs.readFileSync(at(dst, rel))); } catch (e) { return null; } };
|
|
811
934
|
for (const [rel, h] of Object.entries(next)) {
|
|
812
935
|
const mine = localHash(rel), to = at(dst, rel);
|
|
813
|
-
if (mine === h) { fs.rmSync(to + ".upstream", { force: true }); continue; }
|
|
936
|
+
if (mine === h) { if (!dry) fs.rmSync(to + ".upstream", { force: true }); continue; }
|
|
814
937
|
if (h === base[rel]) continue;
|
|
815
|
-
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
816
938
|
if (mine === null ? !(rel in base) : mine === base[rel]) {
|
|
939
|
+
if (dry) continue;
|
|
940
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
817
941
|
fs.copyFileSync(at(src, rel), to);
|
|
818
942
|
fs.rmSync(to + ".upstream", { force: true });
|
|
819
943
|
} else {
|
|
820
|
-
fs.copyFileSync(at(src, rel), to + ".upstream");
|
|
821
944
|
kept.push(rel);
|
|
945
|
+
if (dry) continue;
|
|
946
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
947
|
+
fs.copyFileSync(at(src, rel), to + ".upstream");
|
|
822
948
|
}
|
|
823
949
|
}
|
|
824
950
|
for (const rel of Object.keys(base)) {
|
|
825
951
|
if (rel in next) continue;
|
|
826
952
|
const mine = localHash(rel);
|
|
827
|
-
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 }); }
|
|
828
954
|
else if (mine !== null) orphaned.push(rel);
|
|
829
955
|
}
|
|
956
|
+
if (dry) return { m, kept, orphaned };
|
|
830
957
|
const note = writeWiring(ide, dest, s, m.description);
|
|
831
958
|
if (note && notes) notes.push(note);
|
|
832
959
|
l.data[s] = { version: m.version, source: cat.source, files: next };
|
|
@@ -837,14 +964,15 @@ function mergeOne(cat, s, dest, ide, l, notes) {
|
|
|
837
964
|
command (RFC-0020). Needs a target with BOTH a subagent and a skill format, so it accepts
|
|
838
965
|
the default (Claude Code), --project, --ide kiro, or --dest; other IDEs install the halves
|
|
839
966
|
separately. Delegates to installAgents + install so every existing safety/craft gate applies. */
|
|
967
|
+
const PANEL_IDES = ["kiro", "codex", "antigravity", "copilot"];
|
|
840
968
|
function installPanel(args) {
|
|
841
969
|
const name = args.flags.panel;
|
|
842
970
|
const p = PANELS[name];
|
|
843
971
|
if (!p) die(`unknown panel '${name}' — available: ${Object.keys(PANELS).join(", ")}`);
|
|
844
972
|
const ide = args.flags.ide;
|
|
845
|
-
if (ide && ide
|
|
973
|
+
if (ide && !PANEL_IDES.includes(ide) && !args.flags.dest)
|
|
846
974
|
die(`--panel installs subagents + the orchestrator skill together, so it needs a target with both formats:\n` +
|
|
847
|
-
` (default | --project) Claude Code, --ide
|
|
975
|
+
` (default | --project) Claude Code, --ide ${PANEL_IDES.join(" | ")}, or --dest <dir>.\n` +
|
|
848
976
|
` For ${ide}: install the agents (skilldrop install --agent ${p.agents.join(" ")} --ide ${ide}) ` +
|
|
849
977
|
`and the skill (skilldrop install ${p.skill} --ide ${ide}) separately.`);
|
|
850
978
|
const flags = Object.assign({}, args.flags);
|
|
@@ -907,6 +1035,13 @@ function installProfile(args) {
|
|
|
907
1035
|
}
|
|
908
1036
|
|
|
909
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");
|
|
910
1045
|
if (args.flags.panel) return installPanel(args);
|
|
911
1046
|
if (args.flags.profile) return installProfile(args);
|
|
912
1047
|
if (args.flags.loop) return installLoops(args);
|
|
@@ -923,6 +1058,7 @@ function install(args) {
|
|
|
923
1058
|
}
|
|
924
1059
|
gate(cat, names);
|
|
925
1060
|
const { dest, ide } = target(args.flags);
|
|
1061
|
+
if (args.flags["dry-run"]) return planInstall(cat, names, dest, ide, args.flags);
|
|
926
1062
|
fs.mkdirSync(dest, { recursive: true });
|
|
927
1063
|
const l = ledger(dest);
|
|
928
1064
|
const pipDeps = [], suggestions = new Set(), notes = [];
|
|
@@ -935,10 +1071,20 @@ function install(args) {
|
|
|
935
1071
|
saveLedger(l);
|
|
936
1072
|
console.log(`\n${names.length} skill(s) installed (${ide}).`);
|
|
937
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
|
+
}
|
|
938
1080
|
|
|
939
1081
|
const withHooks = names.filter((s) => { try { return (manifestOf(cat, s).hooks || []).length; } catch (e) { return false; } });
|
|
940
1082
|
if (withHooks.length && args.flags["with-hooks"]) {
|
|
941
|
-
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
|
+
}
|
|
942
1088
|
console.log(`\nHooks wired (RFC-0006):\n${lines.join("\n")}`);
|
|
943
1089
|
if (cat.source !== BUNDLED)
|
|
944
1090
|
console.log(` NOTE: these hooks run commands from third-party catalog '${cat.source}' — read them before trusting them.`);
|
|
@@ -957,7 +1103,7 @@ function install(args) {
|
|
|
957
1103
|
: "\nSupply-chain scan: no flagged patterns.");
|
|
958
1104
|
}
|
|
959
1105
|
if (ide === "generic")
|
|
960
|
-
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.");
|
|
961
1107
|
for (const s of pipDeps)
|
|
962
1108
|
console.log(`deps: ${s} needs Python packages — run: cd ${path.join(dest, s)} && python3 -m pip install -r requirements.txt`);
|
|
963
1109
|
if (suggestions.size)
|
|
@@ -966,6 +1112,31 @@ function install(args) {
|
|
|
966
1112
|
if (pk && pk["first-value"]) console.log("\n" + firstValueLines(pk).join("\n"));
|
|
967
1113
|
}
|
|
968
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
|
+
|
|
969
1140
|
function installedRows(flags) {
|
|
970
1141
|
const { dest, ide } = target(flags);
|
|
971
1142
|
const l = ledger(dest);
|
|
@@ -984,6 +1155,16 @@ function installedRows(flags) {
|
|
|
984
1155
|
function update(args) {
|
|
985
1156
|
const { dest, ide, l, rows } = installedRows(args.flags);
|
|
986
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);
|
|
987
1168
|
let n = 0, conflicts = 0;
|
|
988
1169
|
for (const r of rows) {
|
|
989
1170
|
if (!r.current) { console.log(`skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
|
|
@@ -1006,10 +1187,42 @@ function update(args) {
|
|
|
1006
1187
|
}
|
|
1007
1188
|
saveLedger(l);
|
|
1008
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
|
+
}
|
|
1009
1198
|
if (conflicts)
|
|
1010
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.`);
|
|
1011
1200
|
}
|
|
1012
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
|
+
|
|
1013
1226
|
function outdated(args) {
|
|
1014
1227
|
const { dest, rows } = installedRows(args.flags);
|
|
1015
1228
|
const stale = rows.filter((r) => r.current && r.current !== r.installed);
|
|
@@ -1034,7 +1247,25 @@ function uninstall(args) {
|
|
|
1034
1247
|
if (!args._.length) die("pass skill names to uninstall");
|
|
1035
1248
|
const { dest, ide } = target(args.flags);
|
|
1036
1249
|
const l = ledger(dest);
|
|
1037
|
-
|
|
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) {
|
|
1038
1269
|
fs.rmSync(path.join(dest, s), { recursive: true, force: true });
|
|
1039
1270
|
const w = wiringPath(ide, dest, s);
|
|
1040
1271
|
if (w) fs.rmSync(w, { force: true });
|
|
@@ -1043,6 +1274,176 @@ function uninstall(args) {
|
|
|
1043
1274
|
console.log(`removed ${s} from ${dest}`);
|
|
1044
1275
|
}
|
|
1045
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`);
|
|
1046
1447
|
}
|
|
1047
1448
|
|
|
1048
1449
|
function listAgents(args) {
|
|
@@ -1167,6 +1568,7 @@ function installLoops(args) {
|
|
|
1167
1568
|
console.log(`installed loop ${n} -> ${out}`);
|
|
1168
1569
|
}
|
|
1169
1570
|
saveLedger(l);
|
|
1571
|
+
if (args.flags.local) addLocal(dest, ide, names);
|
|
1170
1572
|
|
|
1171
1573
|
const wanted = [];
|
|
1172
1574
|
for (const n of names) for (const sk of loopSkills(cat, n)) if (!wanted.includes(sk)) wanted.push(sk);
|
|
@@ -1187,6 +1589,11 @@ function uninstallLoops(args) {
|
|
|
1187
1589
|
if (!args._.length) die("pass loop names to uninstall");
|
|
1188
1590
|
const { dest, ide } = target(args.flags);
|
|
1189
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.");
|
|
1190
1597
|
for (const n of args._) {
|
|
1191
1598
|
fs.rmSync(path.join(dest, n), { recursive: true, force: true });
|
|
1192
1599
|
const w = wiringPath(ide, dest, n);
|
|
@@ -1195,6 +1602,7 @@ function uninstallLoops(args) {
|
|
|
1195
1602
|
console.log(`removed loop ${n} from ${dest} (stage skills left in place)`);
|
|
1196
1603
|
}
|
|
1197
1604
|
saveLedger(l);
|
|
1605
|
+
removeLocal(dest, ide, args._);
|
|
1198
1606
|
}
|
|
1199
1607
|
|
|
1200
1608
|
function installAgents(args) {
|
|
@@ -1239,6 +1647,11 @@ function uninstallAgents(args) {
|
|
|
1239
1647
|
if (!args._.length) die("pass agent names to uninstall");
|
|
1240
1648
|
const { dest } = agentTarget(args.flags);
|
|
1241
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.");
|
|
1242
1655
|
for (const a of args._) {
|
|
1243
1656
|
const rec = l.data[a];
|
|
1244
1657
|
for (const f of new Set([rec && rec.file, `${a}.md`, `${a}.json`, `${a}.agent.md`, `${a}.toml`].filter(Boolean)))
|
|
@@ -1398,7 +1811,8 @@ function bootstrap() {
|
|
|
1398
1811
|
|
|
1399
1812
|
const args = parseArgs(process.argv.slice(2));
|
|
1400
1813
|
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 (
|
|
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);
|
|
1403
1817
|
else if (commands[cmd]) commands[cmd](args);
|
|
1404
1818
|
else die(`unknown command '${cmd}' — run: skilldrop help`);
|