skillrepo 1.6.0 → 1.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  * Creates directories as needed, handles errors cleanly.
4
4
  */
5
5
 
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, chmodSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
 
9
9
  /**
@@ -35,6 +35,15 @@ export function writeFileSafe(filePath, content) {
35
35
  writeFileSync(filePath, content, "utf-8");
36
36
  }
37
37
 
38
+ /**
39
+ * Write a file and mark it executable (0o755).
40
+ * Used for the Cursor session hook which is invoked directly via shebang.
41
+ */
42
+ export function writeExecutable(filePath, content) {
43
+ writeFileSafe(filePath, content);
44
+ chmodSync(filePath, 0o755);
45
+ }
46
+
38
47
  /**
39
48
  * Check if a path exists (file or directory).
40
49
  */
@@ -12,40 +12,57 @@ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
12
12
  import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudeHooksDir } from "../paths.mjs";
13
13
  import { join } from "node:path";
14
14
 
15
- const DEFAULT_SYNC_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
16
- const DEFAULT_PROMPT_COMMAND = "node .claude/hooks/skillrepo-prompt-match.mjs";
17
- const DEFAULT_PRETOOL_COMMAND = "node .claude/hooks/skillrepo-pretool-match.mjs";
15
+ // The three hook script paths (relative to project root).
16
+ const HOOK_SCRIPTS = {
17
+ sync: ".claude/hooks/skillrepo-sync.mjs",
18
+ prompt: ".claude/hooks/skillrepo-prompt-match.mjs",
19
+ pretool: ".claude/hooks/skillrepo-pretool-match.mjs",
20
+ };
18
21
 
19
22
  /**
20
- * Extract a hook command from a hooks config group, falling back to a default.
21
- * @param {object} hooksConfig - The hooks config object from the server payload
22
- * @param {string} eventName - e.g. "SessionStart" or "UserPromptSubmit"
23
- * @param {string} fallback - default command if extraction fails
24
- * @returns {string}
23
+ * Build the full command string for a hook script.
24
+ * Uses process.execPath (the absolute path to the current Node binary)
25
+ * so hooks work on nvm/fnm/mise systems where `node` is not on /bin/sh's PATH.
25
26
  */
26
- function extractCommand(hooksConfig, eventName, fallback) {
27
- try {
28
- return hooksConfig[eventName][0].hooks[0].command || fallback;
29
- } catch {
30
- return fallback;
31
- }
27
+ function buildCommand(scriptPath) {
28
+ return `"${process.execPath}" ${scriptPath}`;
32
29
  }
33
30
 
34
31
  /**
35
- * Check if a command is already installed in a hook group array.
36
- * @param {Array} groups
37
- * @param {string} command
38
- * @returns {boolean}
32
+ * Check if a SkillRepo hook for the given script path is already installed,
33
+ * regardless of how node was referenced (bare `node`, shebang, or full path).
39
34
  */
40
- function hasCommand(groups, command) {
35
+ function hasSkillRepoHook(groups, scriptPath) {
41
36
  if (!Array.isArray(groups)) return false;
42
37
  return groups.some(
43
38
  (group) =>
44
39
  Array.isArray(group.hooks) &&
45
- group.hooks.some((h) => h.command === command)
40
+ group.hooks.some((h) => h.command?.includes(scriptPath))
46
41
  );
47
42
  }
48
43
 
44
+ /**
45
+ * Replace any existing SkillRepo hook command with the current node path.
46
+ * Handles all legacy formats: `node .claude/hooks/...`, `.claude/hooks/...`,
47
+ * or `/old/path/to/node .claude/hooks/...`.
48
+ * Returns true if any replacement was made.
49
+ */
50
+ function updateExistingCommands(groups, scriptPath) {
51
+ if (!Array.isArray(groups)) return false;
52
+ const newCommand = buildCommand(scriptPath);
53
+ let updated = false;
54
+ for (const group of groups) {
55
+ if (!Array.isArray(group.hooks)) continue;
56
+ for (const hook of group.hooks) {
57
+ if (hook.command?.includes(scriptPath) && hook.command !== newCommand) {
58
+ hook.command = newCommand;
59
+ updated = true;
60
+ }
61
+ }
62
+ }
63
+ return updated;
64
+ }
65
+
49
66
  /**
50
67
  * Merge SkillRepo hooks into .claude/settings.local.json and write hook scripts.
51
68
  * @param {object} hooksConfig - The hooks config object from the server payload (settingsHooks.hooks)
@@ -54,24 +71,24 @@ function hasCommand(groups, command) {
54
71
  * @param {string} [preToolHookContent] - The pretool-match hook script content (optional for backward compat)
55
72
  * @returns {{ results: { path: string; action: string }[] }}
56
73
  */
74
+ // NOTE: The hooksConfig parameter is accepted for backward compatibility but
75
+ // its command strings are not used. All commands are built locally using
76
+ // process.execPath to ensure hooks work on nvm/fnm/mise systems.
57
77
  export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent, preToolHookContent) {
58
- const syncCommand = extractCommand(hooksConfig, "SessionStart", DEFAULT_SYNC_COMMAND);
59
- const promptCommand = extractCommand(hooksConfig, "UserPromptSubmit", DEFAULT_PROMPT_COMMAND);
60
- const preToolCommand = extractCommand(hooksConfig, "PreToolUse", DEFAULT_PRETOOL_COMMAND);
61
78
  const results = [];
62
79
 
63
- // Always write hook scripts (latest version from server)
80
+ // Always write hook scripts (latest version from server).
64
81
  const syncExisted = readFileSafe(claudeSyncHook()) !== null;
65
82
  writeFileSafe(claudeSyncHook(), syncHookContent);
66
83
  results.push({
67
- path: ".claude/hooks/skillrepo-sync.mjs",
84
+ path: HOOK_SCRIPTS.sync,
68
85
  action: syncExisted ? "updated" : "created",
69
86
  });
70
87
 
71
88
  const promptExisted = readFileSafe(claudePromptHook()) !== null;
72
89
  writeFileSafe(claudePromptHook(), promptHookContent);
73
90
  results.push({
74
- path: ".claude/hooks/skillrepo-prompt-match.mjs",
91
+ path: HOOK_SCRIPTS.prompt,
75
92
  action: promptExisted ? "updated" : "created",
76
93
  });
77
94
 
@@ -79,7 +96,7 @@ export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent
79
96
  const preToolExisted = readFileSafe(claudePreToolHook()) !== null;
80
97
  writeFileSafe(claudePreToolHook(), preToolHookContent);
81
98
  results.push({
82
- path: ".claude/hooks/skillrepo-pretool-match.mjs",
99
+ path: HOOK_SCRIPTS.pretool,
83
100
  action: preToolExisted ? "updated" : "created",
84
101
  });
85
102
  }
@@ -111,9 +128,28 @@ export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent
111
128
  if (!config.hooks) config.hooks = {};
112
129
  let changed = false;
113
130
 
131
+ // Build commands using the absolute node path from this machine.
132
+ // This ensures hooks work on nvm/fnm/mise systems where `node` is
133
+ // not on /bin/sh's PATH.
134
+ const syncCommand = buildCommand(HOOK_SCRIPTS.sync);
135
+ const promptCommand = buildCommand(HOOK_SCRIPTS.prompt);
136
+ const preToolCommand = buildCommand(HOOK_SCRIPTS.pretool);
137
+
138
+ // Update existing SkillRepo hook commands to use the current node path
139
+ // (handles all legacy formats: bare `node`, shebang paths, old full paths)
140
+ for (const [event, scriptPath] of [
141
+ ["SessionStart", HOOK_SCRIPTS.sync],
142
+ ["UserPromptSubmit", HOOK_SCRIPTS.prompt],
143
+ ["PreToolUse", HOOK_SCRIPTS.pretool],
144
+ ]) {
145
+ if (Array.isArray(config.hooks[event]) && updateExistingCommands(config.hooks[event], scriptPath)) {
146
+ changed = true;
147
+ }
148
+ }
149
+
114
150
  // Merge SessionStart
115
151
  if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
116
- if (!hasCommand(config.hooks.SessionStart, syncCommand)) {
152
+ if (!hasSkillRepoHook(config.hooks.SessionStart, HOOK_SCRIPTS.sync)) {
117
153
  config.hooks.SessionStart.push({
118
154
  matcher: "startup|resume",
119
155
  hooks: [{ type: "command", command: syncCommand }],
@@ -123,7 +159,7 @@ export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent
123
159
 
124
160
  // Merge UserPromptSubmit
125
161
  if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = [];
126
- if (!hasCommand(config.hooks.UserPromptSubmit, promptCommand)) {
162
+ if (!hasSkillRepoHook(config.hooks.UserPromptSubmit, HOOK_SCRIPTS.prompt)) {
127
163
  config.hooks.UserPromptSubmit.push({
128
164
  hooks: [{ type: "command", command: promptCommand }],
129
165
  });
@@ -133,7 +169,7 @@ export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent
133
169
  // Merge PreToolUse
134
170
  if (preToolHookContent) {
135
171
  if (!Array.isArray(config.hooks.PreToolUse)) config.hooks.PreToolUse = [];
136
- if (!hasCommand(config.hooks.PreToolUse, preToolCommand)) {
172
+ if (!hasSkillRepoHook(config.hooks.PreToolUse, HOOK_SCRIPTS.pretool)) {
137
173
  config.hooks.PreToolUse.push({
138
174
  hooks: [{ type: "command", command: preToolCommand }],
139
175
  });
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { resolve } from "node:path";
7
- import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
7
+ import { readFileSafe, writeFileSafe, writeExecutable } from "./fs-utils.mjs";
8
8
  import { claudeSkillrepoMd, claudeSkillrepoIndex, claudeSkillrepoConfig } from "./paths.mjs";
9
9
 
10
10
  /**
@@ -95,7 +95,7 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, payload }) {
95
95
  }
96
96
  if (payload.cursor?.sessionHook) {
97
97
  const existed = readFileSafe(payload.cursor.sessionHook.path) !== null;
98
- writeFileSafe(payload.cursor.sessionHook.path, payload.cursor.sessionHook.content);
98
+ writeExecutable(payload.cursor.sessionHook.path, payload.cursor.sessionHook.content);
99
99
  results.push({ path: payload.cursor.sessionHook.path, action: existed ? "updated" : "created" });
100
100
  }
101
101
  if (payload.cursor?.skillIndex) {
@@ -473,12 +473,13 @@ import { tmpdir } from "os";
473
473
 
474
474
  const CONTENT_URL = "${contentUrl}";
475
475
 
476
- let hookConfig = { maxTotalBytes: 10000, reinjectionLineThreshold: 50 };
476
+ let hookConfig = { maxTotalBytes: 10000, reinjectionLineThreshold: 50, maxSkillsPerPrompt: 3 };
477
477
  try {
478
478
  const cfg = JSON.parse(readFileSync(join(process.cwd(), ".claude", "skillrepo-config.json"), "utf-8"));
479
479
  if (cfg.hookInjection) hookConfig = { ...hookConfig, ...cfg.hookInjection };
480
480
  } catch {}
481
481
  const MAX_BYTES = hookConfig.maxTotalBytes;
482
+ const MAX_SKILLS = hookConfig.maxSkillsPerPrompt;
482
483
  const REINJECT_THRESHOLD = hookConfig.reinjectionLineThreshold ?? 50;
483
484
 
484
485
  let dynamicSignals = [];
@@ -489,13 +490,8 @@ try {
489
490
  }
490
491
  } catch {}
491
492
 
492
- const FALLBACK_SIGNALS = [
493
- { toolName: "skill__affaan-m__frontend-patterns", files: ["**/*.tsx", "**/*.jsx", "**/*.css"], project: [], tasks: [] },
494
- { toolName: "skill__affaan-m__e2e-testing", files: ["**/*.test.*", "**/*.spec.*"], project: [], tasks: [] },
495
- { toolName: "skill__atxpace__gh-issue-workflow", files: [], project: [], tasks: ["github issue", "pull request"] },
496
- { toolName: "skill__affaan-m__api-routes", files: ["**/api/**/route.ts", "**/api/**/route.tsx"], project: [], tasks: [] },
497
- ];
498
- const SIGNALS = dynamicSignals.length > 0 ? dynamicSignals : FALLBACK_SIGNALS;
493
+ const SIGNALS = dynamicSignals;
494
+ if (SIGNALS.length === 0) { process.stdout.write("{}"); process.exit(0); }
499
495
 
500
496
  // Minimal glob matcher — self-contained, no npm deps.
501
497
  function matchGlob(filePath, pattern) {
@@ -621,7 +617,7 @@ if (matchedSkills.length === 0) { process.stdout.write("{}"); process.exit(0); }
621
617
 
622
618
  const { path: statePath, state } = readState(sessionId);
623
619
  const currentLines = countTranscriptLines(transcriptPath);
624
- const candidates = matchedSkills.filter((s) => shouldReinject(s.toolName, state, currentLines));
620
+ const candidates = matchedSkills.filter((s) => shouldReinject(s.toolName, state, currentLines)).slice(0, MAX_SKILLS);
625
621
  if (candidates.length === 0) { process.stdout.write("{}"); process.exit(0); }
626
622
 
627
623
  const contents = [];
@@ -675,21 +671,21 @@ function buildSettingsHooks() {
675
671
  {
676
672
  matcher: "startup|resume",
677
673
  hooks: [
678
- { type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" },
674
+ { type: "command", command: ".claude/hooks/skillrepo-sync.mjs" },
679
675
  ],
680
676
  },
681
677
  ],
682
678
  UserPromptSubmit: [
683
679
  {
684
680
  hooks: [
685
- { type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" },
681
+ { type: "command", command: ".claude/hooks/skillrepo-prompt-match.mjs" },
686
682
  ],
687
683
  },
688
684
  ],
689
685
  PreToolUse: [
690
686
  {
691
687
  hooks: [
692
- { type: "command", command: "node .claude/hooks/skillrepo-pretool-match.mjs" },
688
+ { type: "command", command: ".claude/hooks/skillrepo-pretool-match.mjs" },
693
689
  ],
694
690
  },
695
691
  ],
@@ -766,7 +762,7 @@ function buildCursorHooksJson() {
766
762
  hooks: [
767
763
  {
768
764
  event: "sessionStart",
769
- command: "node .cursor/hooks/skillrepo-session.mjs",
765
+ command: ".cursor/hooks/skillrepo-session.mjs",
770
766
  },
771
767
  ],
772
768
  }, null, 2) + "\n";
@@ -22,10 +22,11 @@ const SYNC_CONTENT = "// sync hook";
22
22
  const PROMPT_CONTENT = "// prompt hook";
23
23
  const PRETOOL_CONTENT = "// pretool hook";
24
24
 
25
+ // Server payload hook commands (bare script paths — the CLI builds full commands locally)
25
26
  const HOOKS_CONFIG = {
26
- SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
27
- UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
28
- PreToolUse: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-pretool-match.mjs" }] }],
27
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: ".claude/hooks/skillrepo-sync.mjs" }] }],
28
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: ".claude/hooks/skillrepo-prompt-match.mjs" }] }],
29
+ PreToolUse: [{ hooks: [{ type: "command", command: ".claude/hooks/skillrepo-pretool-match.mjs" }] }],
29
30
  };
30
31
 
31
32
  describe("Hooks settings.local.json merger", () => {
@@ -80,14 +81,15 @@ describe("Hooks settings.local.json merger", () => {
80
81
  assert.equal(config.hooks.UserPromptSubmit.length, 1);
81
82
  });
82
83
 
83
- it("skips merge when hooks are already installed", async () => {
84
+ it("skips merge when hooks are already installed with current node path", async () => {
84
85
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
85
86
 
86
- // Pre-existing with both hooks already
87
+ // Pre-existing with hooks using the CURRENT node path (already up to date)
88
+ const nodeCmd = (script) => `"${process.execPath}" ${script}`;
87
89
  const existing = {
88
90
  hooks: {
89
- SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
90
- UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
91
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-sync.mjs") }] }],
92
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-prompt-match.mjs") }] }],
91
93
  },
92
94
  };
93
95
  mkdirSync(join(tempDir, ".claude"), { recursive: true });
@@ -105,7 +107,7 @@ describe("Hooks settings.local.json merger", () => {
105
107
  // Only SessionStart installed
106
108
  const existing = {
107
109
  hooks: {
108
- SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
110
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: ".claude/hooks/skillrepo-sync.mjs" }] }],
109
111
  },
110
112
  };
111
113
  mkdirSync(join(tempDir, ".claude"), { recursive: true });
@@ -119,21 +121,22 @@ describe("Hooks settings.local.json merger", () => {
119
121
  const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
120
122
  assert.equal(config.hooks.SessionStart.length, 1);
121
123
  assert.equal(config.hooks.UserPromptSubmit.length, 1);
122
- assert.equal(config.hooks.UserPromptSubmit[0].hooks[0].command, "node .claude/hooks/skillrepo-prompt-match.mjs");
124
+ assert.ok(config.hooks.UserPromptSubmit[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-prompt-match.mjs"), "Command should end with script path");
123
125
  });
124
126
 
125
127
  it("always updates hook scripts even when settings merge is skipped", async () => {
126
128
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
127
129
 
128
- // Install once with old scripts
130
+ // Install once with old scripts but current-format commands
131
+ const nodeCmd = (script) => `"${process.execPath}" ${script}`;
129
132
  mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
130
133
  writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old sync");
131
134
  writeFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "// old prompt");
132
135
  mkdirSync(join(tempDir, ".claude"), { recursive: true });
133
136
  const existing = {
134
137
  hooks: {
135
- SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
136
- UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
138
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-sync.mjs") }] }],
139
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-prompt-match.mjs") }] }],
137
140
  },
138
141
  };
139
142
  writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
@@ -149,7 +152,7 @@ describe("Hooks settings.local.json merger", () => {
149
152
  assert.equal(promptResult.action, "updated");
150
153
  assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), "// new prompt");
151
154
 
152
- // Settings skipped
155
+ // Settings skipped (commands already use current node path)
153
156
  const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
154
157
  assert.equal(settingsResult.action, "skipped");
155
158
  });
@@ -203,7 +206,7 @@ describe("Hooks settings.local.json merger", () => {
203
206
  const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
204
207
  assert.ok(Array.isArray(config.hooks.SessionStart), "SessionStart should be an array");
205
208
  assert.equal(config.hooks.SessionStart.length, 1);
206
- assert.equal(config.hooks.SessionStart[0].hooks[0].command, "node .claude/hooks/skillrepo-sync.mjs");
209
+ assert.ok(config.hooks.SessionStart[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-sync.mjs"), "Command should end with script path");
207
210
  });
208
211
 
209
212
  it("documents partial write when JSON parse fails (hook scripts written before throw)", async () => {
@@ -244,7 +247,7 @@ describe("Hooks settings.local.json merger", () => {
244
247
  const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
245
248
  assert.ok(Array.isArray(config.hooks.PreToolUse), "PreToolUse should be an array");
246
249
  assert.equal(config.hooks.PreToolUse.length, 1);
247
- assert.equal(config.hooks.PreToolUse[0].hooks[0].command, "node .claude/hooks/skillrepo-pretool-match.mjs");
250
+ assert.ok(config.hooks.PreToolUse[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-pretool-match.mjs"), "Command should end with script path");
248
251
  });
249
252
 
250
253
  it("skips pretool hook when preToolHookContent is not provided", async () => {
@@ -270,4 +273,88 @@ describe("Hooks settings.local.json merger", () => {
270
273
  const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
271
274
  assert.equal(config.hooks.PreToolUse.length, 1, "should not duplicate PreToolUse entry");
272
275
  });
276
+
277
+ it("migrates any non-current-node-path commands to use process.execPath", async () => {
278
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
279
+
280
+ // Pre-existing settings with bare `node` command format
281
+ const oldSettings = {
282
+ hooks: {
283
+ SessionStart: [{
284
+ matcher: "startup|resume",
285
+ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }]
286
+ }],
287
+ UserPromptSubmit: [{
288
+ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }]
289
+ }],
290
+ PreToolUse: [{
291
+ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-pretool-match.mjs" }]
292
+ }]
293
+ }
294
+ };
295
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
296
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(oldSettings, null, 2));
297
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
298
+
299
+ // Run merger with NEW hook config (HOOKS_CONFIG uses .claude/hooks/... without "node" prefix)
300
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT, PRETOOL_CONTENT);
301
+
302
+ // Settings should show "merged" (not "created" or "skipped")
303
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
304
+ assert.equal(settingsResult.action, "merged");
305
+
306
+ // Read the result and verify: ONE entry per event, with NEW command format
307
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
308
+ assert.equal(config.hooks.SessionStart.length, 1);
309
+ assert.ok(config.hooks.SessionStart[0].hooks[0].command.includes(process.execPath), "Command should contain full node path");
310
+ assert.ok(config.hooks.SessionStart[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-sync.mjs"), "Command should end with script path");
311
+ assert.equal(config.hooks.UserPromptSubmit.length, 1);
312
+ assert.ok(config.hooks.UserPromptSubmit[0].hooks[0].command.includes(process.execPath), "Command should contain full node path");
313
+ assert.ok(config.hooks.UserPromptSubmit[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-prompt-match.mjs"), "Command should end with script path");
314
+ assert.equal(config.hooks.PreToolUse.length, 1);
315
+ assert.ok(config.hooks.PreToolUse[0].hooks[0].command.includes(process.execPath), "Command should contain full node path");
316
+ assert.ok(config.hooks.PreToolUse[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-pretool-match.mjs"), "Command should end with script path");
317
+ });
318
+
319
+ it("migrates bare shebang-style commands (v1.6.1 format)", async () => {
320
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
321
+
322
+ // v1.6.1 used bare script paths relying on shebang execution
323
+ const oldSettings = {
324
+ hooks: {
325
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: ".claude/hooks/skillrepo-sync.mjs" }] }],
326
+ }
327
+ };
328
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
329
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(oldSettings, null, 2));
330
+
331
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
332
+
333
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
334
+ assert.equal(config.hooks.SessionStart.length, 1, "Should not duplicate");
335
+ const cmd = config.hooks.SessionStart[0].hooks[0].command;
336
+ assert.ok(cmd.includes(process.execPath), "Should contain full node path");
337
+ assert.ok(cmd.endsWith(".claude/hooks/skillrepo-sync.mjs"), "Should end with script path");
338
+ });
339
+
340
+ it("migrates old full-path-to-node commands (nvm version change)", async () => {
341
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
342
+
343
+ // User ran init with an older nvm node version
344
+ const oldSettings = {
345
+ hooks: {
346
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "/usr/local/bin/node .claude/hooks/skillrepo-sync.mjs" }] }],
347
+ }
348
+ };
349
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
350
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(oldSettings, null, 2));
351
+
352
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
353
+
354
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
355
+ assert.equal(config.hooks.SessionStart.length, 1, "Should not duplicate");
356
+ const cmd = config.hooks.SessionStart[0].hooks[0].command;
357
+ assert.ok(cmd.includes(process.execPath), "Should contain CURRENT node path");
358
+ assert.ok(!cmd.includes("/usr/local/bin/node"), "Should NOT contain old node path");
359
+ });
273
360
  });