skilldrop-cli 0.3.0 → 0.4.0

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 CHANGED
@@ -256,6 +256,17 @@ npx skilldrop-cli list | skilldrop info <skill> | skilldrop packs | skilldrop un
256
256
 
257
257
  `--with-related` also pulls each skill's companions. From a clone (or before the package is published): `node bin/skilldrop.js <same args>`. Scope and design: [RFC-0002](docs/rfcs/0002-skilldrop-cli.md), full command surface in [`docs/designs/skilldrop-cli-design.md`](docs/designs/skilldrop-cli-design.md).
258
258
 
259
+ ### Hooks (opt-in) — wire a skill to an event
260
+
261
+ Some loop-shaped skills declare **hooks** — event-triggered nudges the CLI wires into your environment when you pass `--with-hooks` ([RFC-0006](docs/rfcs/0006-per-ide-hooks.md)). It's off by default, so a plain install never touches your git repo or editor settings.
262
+
263
+ ```bash
264
+ npx skilldrop-cli install devils-advocate --with-hooks --project
265
+ # → appends a marker-fenced reminder to .git/hooks/pre-commit: "run /devils-advocate on staged changes"
266
+ ```
267
+
268
+ The CLI emits per target and **degrades gracefully** — a `pre-commit-review` hook becomes an IDE-agnostic git hook (needs a git repo); a `session-start` hook becomes a Claude Code `settings.json` entry, and is cleanly skipped where the target has no equivalent (Cursor, Kiro, plain `--dest`), printing what it did and where. Hooks are reminders/context, not autonomous execution — skilldrop skills are agent instructions, so the hook prompts *you* to run the review, it doesn't silently run an AI pass. `skilldrop uninstall` removes any hook artifacts it wrote. Vocabulary and the per-target mapping are in the RFC.
269
+
259
270
  ### Third-party catalogs — publish your own skills through the same CLI
260
271
 
261
272
  Any git repo or directory shaped like this one is a **catalog**: `skills/<name>/` folders each holding `SKILL.md` + `manifest.json`, optionally a root `packs.json`. That's the whole contract ([RFC-0003](docs/rfcs/0003-third-party-catalogs.md)):
package/bin/skilldrop.js CHANGED
@@ -15,6 +15,8 @@ const { execFileSync } = require("child_process");
15
15
  const ROOT = path.resolve(__dirname, "..");
16
16
  const LEDGER = ".skilldrop.json";
17
17
  const BUNDLED = "bundled";
18
+ // Neutral hook vocabulary (RFC-0006). Kept in sync with validate.py's HOOK_EVENTS.
19
+ const HOOK_EVENTS = ["session-start", "pre-commit-review", "on-demand"];
18
20
 
19
21
  const HELP = `skilldrop — portable AI-agent skills for Claude Code, Cursor, Kiro, and more
20
22
 
@@ -44,6 +46,10 @@ Install/update/uninstall targets (pick one):
44
46
 
45
47
  Options:
46
48
  --with-related also install each skill's related companions (one level)
49
+ --with-hooks also wire any hooks a skill declares (RFC-0006) — git pre-commit
50
+ reminders and Claude Code session-start context; degrades where the
51
+ target has no hook mechanism. Off by default so installs never touch
52
+ your git repo or settings unasked.
47
53
  `;
48
54
 
49
55
  function die(msg) { console.error("error: " + msg); process.exit(1); }
@@ -163,6 +169,106 @@ function writeWiring(ide, dest, s, desc) {
163
169
  fs.writeFileSync(p, `When the user requests the following, defer to the instructions in .kiro/skills/${s}/SKILL.md:\n\n${desc}\n`);
164
170
  }
165
171
 
172
+ /* ---------- hooks (RFC-0006: emit per target, degrade where unsupported) ---------- */
173
+
174
+ function gitRoot(startDir) {
175
+ let d = path.resolve(startDir);
176
+ for (;;) {
177
+ if (fs.existsSync(path.join(d, ".git"))) return d;
178
+ const up = path.dirname(d);
179
+ if (up === d) return null;
180
+ d = up;
181
+ }
182
+ }
183
+
184
+ // Append an idempotent, marker-fenced reminder to .git/hooks/pre-commit. IDE-agnostic.
185
+ function writeGitPreCommitHook(root, skill, hook) {
186
+ const p = path.join(root, ".git", "hooks", "pre-commit");
187
+ const marker = `skilldrop-hook:${skill}:pre-commit-review`;
188
+ let body = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
189
+ if (!body.startsWith("#!")) body = "#!/bin/sh\n" + body;
190
+ if (body.includes(marker)) return p; // already wired
191
+ const line = `echo "skilldrop: run /${hook.action} on your staged changes before committing — ${hook.description}"`;
192
+ body += `\n# >>> ${marker} >>>\n${line}\n# <<< ${marker} <<<\n`;
193
+ fs.mkdirSync(path.dirname(p), { recursive: true });
194
+ fs.writeFileSync(p, body);
195
+ fs.chmodSync(p, 0o755);
196
+ return p;
197
+ }
198
+
199
+ // Merge a SessionStart command into Claude Code settings.json. Returns the path,
200
+ // or null if the file exists but is malformed (we never clobber unparseable JSON).
201
+ function writeClaudeSessionHook(settingsPath, skill, hook) {
202
+ let data = {};
203
+ if (fs.existsSync(settingsPath)) {
204
+ try { data = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
205
+ catch (e) { return null; }
206
+ }
207
+ data.hooks = data.hooks || {};
208
+ data.hooks.SessionStart = data.hooks.SessionStart || [];
209
+ const marker = `skilldrop-hook:${skill}:session-start`;
210
+ if (!JSON.stringify(data.hooks.SessionStart).includes(marker)) {
211
+ const cmd = `echo "skilldrop: /${hook.action} is available — ${hook.description}" # ${marker}`;
212
+ data.hooks.SessionStart.push({ hooks: [{ type: "command", command: cmd }] });
213
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
214
+ fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n");
215
+ }
216
+ return settingsPath;
217
+ }
218
+
219
+ // Wire one skill's declared hooks; returns human-readable status lines.
220
+ function emitHooks(cat, skill, dest, ide) {
221
+ let hooks = [];
222
+ try { hooks = manifestOf(cat, skill).hooks || []; } catch (e) { return []; }
223
+ const lines = [];
224
+ for (const h of hooks) {
225
+ if (h.event === "pre-commit-review") {
226
+ const root = gitRoot(process.cwd());
227
+ if (root) lines.push(` ${skill}: pre-commit reminder -> ${path.join(root, ".git/hooks/pre-commit")}`);
228
+ else lines.push(` ${skill}: pre-commit-review skipped — no git repo at ${process.cwd()}`);
229
+ if (root) writeGitPreCommitHook(root, skill, h);
230
+ } else if (h.event === "session-start") {
231
+ if (ide === "claude") {
232
+ const sp = path.join(path.dirname(dest), "settings.json");
233
+ const written = writeClaudeSessionHook(sp, skill, h);
234
+ lines.push(written ? ` ${skill}: session-start context -> ${sp}`
235
+ : ` ${skill}: session-start skipped — ${sp} is not valid JSON`);
236
+ } else {
237
+ lines.push(` ${skill}: session-start skipped — no hook mechanism for ${ide}`);
238
+ }
239
+ } else if (h.event === "on-demand") {
240
+ lines.push(` ${skill}: on-demand — invoke /${h.action} manually (no artifact needed)`);
241
+ }
242
+ }
243
+ return lines;
244
+ }
245
+
246
+ function removeHooksFor(skill, dest, ide) {
247
+ const root = gitRoot(process.cwd());
248
+ if (root) {
249
+ const p = path.join(root, ".git", "hooks", "pre-commit");
250
+ if (fs.existsSync(p)) {
251
+ const body = fs.readFileSync(p, "utf8");
252
+ const re = new RegExp(`\\n?# >>> skilldrop-hook:${skill}:[^\\n]* >>>[\\s\\S]*?# <<< skilldrop-hook:${skill}:[^\\n]* <<<\\n?`, "g");
253
+ const next = body.replace(re, "\n");
254
+ if (next !== body) fs.writeFileSync(p, next);
255
+ }
256
+ }
257
+ if (ide === "claude") {
258
+ const sp = path.join(path.dirname(dest), "settings.json");
259
+ if (fs.existsSync(sp)) {
260
+ try {
261
+ const data = JSON.parse(fs.readFileSync(sp, "utf8"));
262
+ const arr = data.hooks && data.hooks.SessionStart;
263
+ if (Array.isArray(arr)) {
264
+ const kept = arr.filter((e) => !JSON.stringify(e).includes(`skilldrop-hook:${skill}:`));
265
+ if (kept.length !== arr.length) { data.hooks.SessionStart = kept; fs.writeFileSync(sp, JSON.stringify(data, null, 2) + "\n"); }
266
+ }
267
+ } catch (e) { /* leave malformed settings untouched */ }
268
+ }
269
+ }
270
+ }
271
+
166
272
  /* ---------- commands ---------- */
167
273
 
168
274
  function expandNames(args, cat) {
@@ -211,6 +317,18 @@ function install(args) {
211
317
  }
212
318
  saveLedger(l);
213
319
  console.log(`\n${names.length} skill(s) installed (${ide}).`);
320
+
321
+ const withHooks = names.filter((s) => { try { return (manifestOf(cat, s).hooks || []).length; } catch (e) { return false; } });
322
+ if (withHooks.length && args.flags["with-hooks"]) {
323
+ const lines = withHooks.flatMap((s) => emitHooks(cat, s, dest, ide));
324
+ console.log(`\nHooks wired (RFC-0006):\n${lines.join("\n")}`);
325
+ if (cat.source !== BUNDLED)
326
+ console.log(` NOTE: these hooks run commands from third-party catalog '${cat.source}' — read them before trusting them.`);
327
+ console.log(` Undo any of these with: skilldrop uninstall <skill> ${ide === "claude" ? "" : "--ide " + ide}`.trimEnd());
328
+ } else if (withHooks.length) {
329
+ console.log(`\n${withHooks.join(", ")} declare hooks — re-run with --with-hooks to wire them (git pre-commit reminders / session-start context).`);
330
+ }
331
+
214
332
  if (cat.source !== BUNDLED)
215
333
  console.log(`\nWARNING: third-party catalog '${cat.source}'. Skills are instructions your AI agent will follow — review each installed SKILL.md under ${dest} before first use. Install copied files only; nothing was executed.`);
216
334
  if (ide === "generic")
@@ -269,6 +387,7 @@ function uninstall(args) {
269
387
  fs.rmSync(path.join(dest, s), { recursive: true, force: true });
270
388
  const w = wiringPath(ide, dest, s);
271
389
  if (w) fs.rmSync(w, { force: true });
390
+ removeHooksFor(s, dest, ide);
272
391
  delete l.data[s];
273
392
  console.log(`removed ${s} from ${dest}`);
274
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Portable AI-agent skills for the deliverables knowledge workers ship — diagrams, ADRs, PRDs, runbooks, decks, reviews — installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "bin": {
6
6
  "skilldrop": "bin/skilldrop.js"
@@ -6,6 +6,9 @@
6
6
  "deps": { "npm": [], "pip": [] },
7
7
  "env": { "required": [], "optional": [] },
8
8
  "related": ["design-doc", "doc-critique"],
9
+ "hooks": [
10
+ { "event": "pre-commit-review", "action": "devils-advocate", "description": "adversarially review staged changes before they land" }
11
+ ],
9
12
  "tags": ["review", "code-review", "adversarial", "edge-cases", "future-proofing", "test-coverage", "dev-workflow"],
10
13
  "model": { "tier": "heavy", "rationale": "Adversarial code review across four lenses. Cheaper models miss edge cases and under-call severity. Never downgrade." }
11
14
  }