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
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -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
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
27
|
-
|
|
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
|
|
36
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
493
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
27
|
-
UserPromptSubmit: [{ hooks: [{ type: "command", command: "
|
|
28
|
-
PreToolUse: [{ hooks: [{ type: "command", command: "
|
|
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
|
|
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: "
|
|
90
|
-
UserPromptSubmit: [{ hooks: [{ type: "command", command: "
|
|
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: "
|
|
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.
|
|
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: "
|
|
136
|
-
UserPromptSubmit: [{ hooks: [{ type: "command", command: "
|
|
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.
|
|
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.
|
|
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
|
});
|