skillrepo 1.3.1 → 1.5.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/package.json +1 -1
- package/src/commands/init.mjs +3 -1
- package/src/lib/mergers/claude-mcp.mjs +1 -1
- package/src/lib/mergers/hooks-json.mjs +80 -29
- package/src/lib/paths.mjs +3 -1
- package/src/lib/resolve-key.mjs +36 -0
- package/src/lib/write-configs.mjs +12 -4
- package/src/test/mergers/claude-mcp.test.mjs +4 -4
- package/src/test/mergers/hooks-json.test.mjs +165 -43
- package/src/test/resolve-key.test.mjs +85 -0
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
confirm,
|
|
25
25
|
} from "../lib/prompt.mjs";
|
|
26
26
|
|
|
27
|
+
import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
|
|
28
|
+
|
|
27
29
|
const DEFAULT_URL = "https://skillrepo.dev";
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -32,7 +34,7 @@ const DEFAULT_URL = "https://skillrepo.dev";
|
|
|
32
34
|
*/
|
|
33
35
|
function parseFlags(argv) {
|
|
34
36
|
const flags = {
|
|
35
|
-
key:
|
|
37
|
+
key: resolveKeyFromEnvFiles(),
|
|
36
38
|
url: process.env.SKILLREPO_URL || DEFAULT_URL,
|
|
37
39
|
yes: false,
|
|
38
40
|
};
|
|
@@ -39,7 +39,7 @@ export function mergeClaudeMcpConfig(mcpUrl) {
|
|
|
39
39
|
config.mcpServers = {};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const action = config.mcpServers.skillrepo ? "
|
|
42
|
+
const action = config.mcpServers.skillrepo ? "updated" : "merged";
|
|
43
43
|
config.mcpServers.skillrepo = serverEntry;
|
|
44
44
|
|
|
45
45
|
writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Merger for Claude Code .claude/
|
|
3
|
-
* Writes hook scripts and merges hook entries into
|
|
2
|
+
* Merger for Claude Code hooks into .claude/settings.local.json
|
|
3
|
+
* Writes hook scripts and merges hook entries into settings.local.json.
|
|
4
4
|
*
|
|
5
5
|
* Merge strategy: add SessionStart and UserPromptSubmit entries
|
|
6
|
-
* without destroying existing
|
|
6
|
+
* without destroying existing settings. Idempotent — skips if already installed.
|
|
7
|
+
* Cleans up stale .claude/hooks/hooks.json if it exists.
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
import { unlinkSync } from "node:fs";
|
|
9
11
|
import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
|
|
10
|
-
import {
|
|
12
|
+
import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudeHooksDir } from "../paths.mjs";
|
|
13
|
+
import { join } from "node:path";
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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";
|
|
18
|
+
|
|
19
|
+
/**
|
|
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}
|
|
25
|
+
*/
|
|
26
|
+
function extractCommand(hooksConfig, eventName, fallback) {
|
|
27
|
+
try {
|
|
28
|
+
return hooksConfig[eventName][0].hooks[0].command || fallback;
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
14
33
|
|
|
15
34
|
/**
|
|
16
35
|
* Check if a command is already installed in a hook group array.
|
|
@@ -28,13 +47,17 @@ function hasCommand(groups, command) {
|
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
/**
|
|
31
|
-
* Merge SkillRepo hooks into
|
|
32
|
-
* @param {
|
|
50
|
+
* Merge SkillRepo hooks into .claude/settings.local.json and write hook scripts.
|
|
51
|
+
* @param {object} hooksConfig - The hooks config object from the server payload (settingsHooks.hooks)
|
|
33
52
|
* @param {string} syncHookContent - The sync hook script content
|
|
34
53
|
* @param {string} promptHookContent - The prompt-match hook script content
|
|
54
|
+
* @param {string} [preToolHookContent] - The pretool-match hook script content (optional for backward compat)
|
|
35
55
|
* @returns {{ results: { path: string; action: string }[] }}
|
|
36
56
|
*/
|
|
37
|
-
export function mergeHooksConfig(
|
|
57
|
+
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);
|
|
38
61
|
const results = [];
|
|
39
62
|
|
|
40
63
|
// Always write hook scripts (latest version from server)
|
|
@@ -52,23 +75,37 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookCo
|
|
|
52
75
|
action: promptExisted ? "updated" : "created",
|
|
53
76
|
});
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return { results };
|
|
78
|
+
if (preToolHookContent) {
|
|
79
|
+
const preToolExisted = readFileSafe(claudePreToolHook()) !== null;
|
|
80
|
+
writeFileSafe(claudePreToolHook(), preToolHookContent);
|
|
81
|
+
results.push({
|
|
82
|
+
path: ".claude/hooks/skillrepo-pretool-match.mjs",
|
|
83
|
+
action: preToolExisted ? "updated" : "created",
|
|
84
|
+
});
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
|
|
87
|
+
// Clean up stale .claude/hooks/hooks.json if it exists
|
|
88
|
+
const staleHooksJson = join(claudeHooksDir(), "hooks.json");
|
|
66
89
|
try {
|
|
67
|
-
|
|
90
|
+
unlinkSync(staleHooksJson);
|
|
91
|
+
results.push({ path: ".claude/hooks/hooks.json", action: "removed" });
|
|
68
92
|
} catch {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
93
|
+
// File doesn't exist — nothing to clean up
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Merge hooks into .claude/settings.local.json
|
|
97
|
+
const filePath = claudeSettingsLocal();
|
|
98
|
+
const existing = readFileSafe(filePath);
|
|
99
|
+
|
|
100
|
+
let config = {};
|
|
101
|
+
if (existing !== null) {
|
|
102
|
+
try {
|
|
103
|
+
config = JSON.parse(existing);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Cannot parse .claude/settings.local.json — invalid JSON. Fix or delete it, then run setup again."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
if (!config.hooks) config.hooks = {};
|
|
@@ -76,28 +113,42 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookCo
|
|
|
76
113
|
|
|
77
114
|
// Merge SessionStart
|
|
78
115
|
if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
|
|
79
|
-
if (!hasCommand(config.hooks.SessionStart,
|
|
116
|
+
if (!hasCommand(config.hooks.SessionStart, syncCommand)) {
|
|
80
117
|
config.hooks.SessionStart.push({
|
|
81
118
|
matcher: "startup|resume",
|
|
82
|
-
hooks: [{ type: "command", command:
|
|
119
|
+
hooks: [{ type: "command", command: syncCommand }],
|
|
83
120
|
});
|
|
84
121
|
changed = true;
|
|
85
122
|
}
|
|
86
123
|
|
|
87
124
|
// Merge UserPromptSubmit
|
|
88
125
|
if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = [];
|
|
89
|
-
if (!hasCommand(config.hooks.UserPromptSubmit,
|
|
126
|
+
if (!hasCommand(config.hooks.UserPromptSubmit, promptCommand)) {
|
|
90
127
|
config.hooks.UserPromptSubmit.push({
|
|
91
|
-
hooks: [{ type: "command", command:
|
|
128
|
+
hooks: [{ type: "command", command: promptCommand }],
|
|
92
129
|
});
|
|
93
130
|
changed = true;
|
|
94
131
|
}
|
|
95
132
|
|
|
96
|
-
|
|
133
|
+
// Merge PreToolUse
|
|
134
|
+
if (preToolHookContent) {
|
|
135
|
+
if (!Array.isArray(config.hooks.PreToolUse)) config.hooks.PreToolUse = [];
|
|
136
|
+
if (!hasCommand(config.hooks.PreToolUse, preToolCommand)) {
|
|
137
|
+
config.hooks.PreToolUse.push({
|
|
138
|
+
hooks: [{ type: "command", command: preToolCommand }],
|
|
139
|
+
});
|
|
140
|
+
changed = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (existing === null) {
|
|
145
|
+
writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
146
|
+
results.push({ path: ".claude/settings.local.json", action: "created" });
|
|
147
|
+
} else if (changed) {
|
|
97
148
|
writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
98
|
-
results.push({ path: ".claude/
|
|
149
|
+
results.push({ path: ".claude/settings.local.json", action: "merged" });
|
|
99
150
|
} else {
|
|
100
|
-
results.push({ path: ".claude/
|
|
151
|
+
results.push({ path: ".claude/settings.local.json", action: "skipped" });
|
|
101
152
|
}
|
|
102
153
|
|
|
103
154
|
return { results };
|
package/src/lib/paths.mjs
CHANGED
|
@@ -12,11 +12,13 @@ const cwd = () => process.cwd();
|
|
|
12
12
|
export const claudeMcpJson = () => join(cwd(), ".mcp.json");
|
|
13
13
|
export const claudeDir = () => join(cwd(), ".claude");
|
|
14
14
|
export const claudeHooksDir = () => join(cwd(), ".claude", "hooks");
|
|
15
|
-
export const
|
|
15
|
+
export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.json");
|
|
16
16
|
export const claudeSyncHook = () => join(cwd(), ".claude", "hooks", "skillrepo-sync.mjs");
|
|
17
17
|
export const claudePromptHook = () => join(cwd(), ".claude", "hooks", "skillrepo-prompt-match.mjs");
|
|
18
|
+
export const claudePreToolHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-match.mjs");
|
|
18
19
|
export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
|
|
19
20
|
export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
|
|
21
|
+
export const claudeSkillrepoConfig = () => join(cwd(), ".claude", "skillrepo-config.json");
|
|
20
22
|
|
|
21
23
|
// Cursor
|
|
22
24
|
export const cursorDir = () => join(cwd(), ".cursor");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve SKILLREPO_ACCESS_KEY from process.env → .env.local → .env.
|
|
3
|
+
* Extracted as a shared module so it can be tested independently.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve SKILLREPO_ACCESS_KEY from .env.local → .env → process.env.
|
|
11
|
+
* @returns {string|null}
|
|
12
|
+
*/
|
|
13
|
+
export function resolveKeyFromEnvFiles() {
|
|
14
|
+
if (process.env.SKILLREPO_ACCESS_KEY) return process.env.SKILLREPO_ACCESS_KEY;
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
for (const file of [".env.local", ".env"]) {
|
|
17
|
+
try {
|
|
18
|
+
const lines = readFileSync(join(cwd, file), "utf-8").split("\n");
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
22
|
+
const eqIdx = trimmed.indexOf("=");
|
|
23
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
24
|
+
if (key === "SKILLREPO_ACCESS_KEY") {
|
|
25
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
26
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
27
|
+
val = val.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
val = val.replace(/\s+#.*$/, "");
|
|
30
|
+
if (val) return val;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch { /* file not found, continue */ }
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
|
|
7
|
-
import { claudeSkillrepoMd, claudeSkillrepoIndex, cursorSkillrepoMdc } from "./paths.mjs";
|
|
7
|
+
import { claudeSkillrepoMd, claudeSkillrepoIndex, claudeSkillrepoConfig, cursorSkillrepoMdc } from "./paths.mjs";
|
|
8
8
|
import { mergeClaudeMcpConfig } from "./mergers/claude-mcp.mjs";
|
|
9
9
|
import { mergeCursorMcpConfig } from "./mergers/cursor-mcp.mjs";
|
|
10
10
|
import { mergeWindsurfMcpConfig } from "./mergers/windsurf-mcp.mjs";
|
|
@@ -38,11 +38,19 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, payload }) {
|
|
|
38
38
|
writeFileSafe(claudeSkillrepoIndex(), payload.claudeCode.skillIndex.content);
|
|
39
39
|
results.push({ path: ".claude/skillrepo-index.json", action: indexExisted ? "updated" : "created" });
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// Hook config (injection limits + companion map)
|
|
42
|
+
if (payload.claudeCode.skillrepoConfig?.content) {
|
|
43
|
+
const configExisted = readFileSafe(claudeSkillrepoConfig()) !== null;
|
|
44
|
+
writeFileSafe(claudeSkillrepoConfig(), payload.claudeCode.skillrepoConfig.content);
|
|
45
|
+
results.push({ path: ".claude/skillrepo-config.json", action: configExisted ? "updated" : "created" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Hooks (SessionStart sync + UserPromptSubmit prompt-match + PreToolUse pretool-match)
|
|
42
49
|
const hookResults = mergeHooksConfig(
|
|
43
|
-
payload.claudeCode.
|
|
50
|
+
payload.claudeCode.settingsHooks.hooks,
|
|
44
51
|
payload.claudeCode.syncHook.content,
|
|
45
|
-
payload.claudeCode.promptHook.content
|
|
52
|
+
payload.claudeCode.promptHook.content,
|
|
53
|
+
payload.claudeCode.preToolHook?.content
|
|
46
54
|
);
|
|
47
55
|
results.push(...hookResults.results);
|
|
48
56
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync,
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
|
|
@@ -46,13 +46,13 @@ describe("Claude Code MCP config merger", () => {
|
|
|
46
46
|
|
|
47
47
|
const result = mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp");
|
|
48
48
|
|
|
49
|
-
assert.equal(result.action, "
|
|
49
|
+
assert.equal(result.action, "merged"); // adding to existing file
|
|
50
50
|
const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
|
|
51
51
|
assert.ok(content.mcpServers.other, "other server preserved");
|
|
52
52
|
assert.ok(content.mcpServers.skillrepo, "skillrepo added");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it("updates existing skillrepo entry
|
|
55
|
+
it("updates existing skillrepo entry", async () => {
|
|
56
56
|
const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
|
|
57
57
|
|
|
58
58
|
const existing = {
|
|
@@ -64,7 +64,7 @@ describe("Claude Code MCP config merger", () => {
|
|
|
64
64
|
|
|
65
65
|
const result = mergeClaudeMcpConfig("https://new-url.com/api/mcp");
|
|
66
66
|
|
|
67
|
-
assert.equal(result.action, "
|
|
67
|
+
assert.equal(result.action, "updated");
|
|
68
68
|
const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
|
|
69
69
|
assert.equal(content.mcpServers.skillrepo.url, "https://new-url.com/api/mcp");
|
|
70
70
|
});
|
|
@@ -20,17 +20,18 @@ afterEach(() => {
|
|
|
20
20
|
|
|
21
21
|
const SYNC_CONTENT = "// sync hook";
|
|
22
22
|
const PROMPT_CONTENT = "// prompt hook";
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
const PRETOOL_CONTENT = "// pretool hook";
|
|
24
|
+
|
|
25
|
+
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" }] }],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("Hooks settings.local.json merger", () => {
|
|
32
|
+
it("creates settings.local.json and scripts when nothing exists", async () => {
|
|
32
33
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
33
|
-
const { results } = mergeHooksConfig(
|
|
34
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
34
35
|
|
|
35
36
|
// Check sync hook written
|
|
36
37
|
const syncResult = results.find((r) => r.path.includes("sync"));
|
|
@@ -42,30 +43,37 @@ describe("Hooks JSON merger", () => {
|
|
|
42
43
|
assert.equal(promptResult.action, "created");
|
|
43
44
|
assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), PROMPT_CONTENT);
|
|
44
45
|
|
|
45
|
-
// Check
|
|
46
|
-
const
|
|
47
|
-
assert.equal(
|
|
46
|
+
// Check settings.local.json created
|
|
47
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
48
|
+
assert.equal(settingsResult.action, "created");
|
|
49
|
+
|
|
50
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
51
|
+
assert.equal(config.hooks.SessionStart.length, 1);
|
|
52
|
+
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
48
53
|
});
|
|
49
54
|
|
|
50
|
-
it("merges into existing
|
|
55
|
+
it("merges into existing settings without destroying other keys", async () => {
|
|
51
56
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
52
57
|
|
|
53
|
-
// Pre-existing
|
|
58
|
+
// Pre-existing settings.local.json with permissions and other hooks
|
|
54
59
|
const existing = {
|
|
60
|
+
permissions: { allow: ["Bash(*)", "Read(*)"] },
|
|
55
61
|
hooks: {
|
|
56
62
|
PreToolUse: [{ hooks: [{ type: "command", command: "node other-hook.mjs" }] }],
|
|
57
63
|
},
|
|
58
64
|
};
|
|
59
|
-
mkdirSync(join(tempDir, ".claude
|
|
60
|
-
writeFileSync(join(tempDir, ".claude/
|
|
65
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
66
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
61
67
|
|
|
62
|
-
const { results } = mergeHooksConfig(
|
|
68
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
63
69
|
|
|
64
|
-
const
|
|
65
|
-
assert.equal(
|
|
70
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
71
|
+
assert.equal(settingsResult.action, "merged");
|
|
66
72
|
|
|
67
|
-
const config = JSON.parse(readFileSync(join(tempDir, ".claude/
|
|
68
|
-
// Original
|
|
73
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
74
|
+
// Original permissions preserved
|
|
75
|
+
assert.deepEqual(config.permissions, { allow: ["Bash(*)", "Read(*)"] });
|
|
76
|
+
// Original hooks preserved
|
|
69
77
|
assert.equal(config.hooks.PreToolUse.length, 1);
|
|
70
78
|
// New hooks added
|
|
71
79
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
@@ -82,50 +90,55 @@ describe("Hooks JSON merger", () => {
|
|
|
82
90
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
|
|
83
91
|
},
|
|
84
92
|
};
|
|
85
|
-
mkdirSync(join(tempDir, ".claude
|
|
86
|
-
writeFileSync(join(tempDir, ".claude/
|
|
93
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
94
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
87
95
|
|
|
88
|
-
const { results } = mergeHooksConfig(
|
|
96
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
89
97
|
|
|
90
|
-
const
|
|
91
|
-
assert.equal(
|
|
98
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
99
|
+
assert.equal(settingsResult.action, "skipped");
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
it("adds missing UserPromptSubmit when only SessionStart exists", async () => {
|
|
95
103
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
96
104
|
|
|
97
|
-
// Only SessionStart installed
|
|
105
|
+
// Only SessionStart installed
|
|
98
106
|
const existing = {
|
|
99
107
|
hooks: {
|
|
100
108
|
SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
|
|
101
109
|
},
|
|
102
110
|
};
|
|
103
|
-
mkdirSync(join(tempDir, ".claude
|
|
104
|
-
writeFileSync(join(tempDir, ".claude/
|
|
111
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
112
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
105
113
|
|
|
106
|
-
const { results } = mergeHooksConfig(
|
|
114
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
107
115
|
|
|
108
|
-
const
|
|
109
|
-
assert.equal(
|
|
116
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
117
|
+
assert.equal(settingsResult.action, "merged");
|
|
110
118
|
|
|
111
|
-
const config = JSON.parse(readFileSync(join(tempDir, ".claude/
|
|
112
|
-
// SessionStart unchanged
|
|
119
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
113
120
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
114
|
-
// UserPromptSubmit added
|
|
115
121
|
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
116
122
|
assert.equal(config.hooks.UserPromptSubmit[0].hooks[0].command, "node .claude/hooks/skillrepo-prompt-match.mjs");
|
|
117
123
|
});
|
|
118
124
|
|
|
119
|
-
it("always updates hook scripts even when
|
|
125
|
+
it("always updates hook scripts even when settings merge is skipped", async () => {
|
|
120
126
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
121
127
|
|
|
122
|
-
// Install once
|
|
128
|
+
// Install once with old scripts
|
|
123
129
|
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
124
130
|
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old sync");
|
|
125
131
|
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "// old prompt");
|
|
126
|
-
|
|
132
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
133
|
+
const existing = {
|
|
134
|
+
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" }] }],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
127
140
|
|
|
128
|
-
const { results } = mergeHooksConfig(
|
|
141
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, "// new sync", "// new prompt");
|
|
129
142
|
|
|
130
143
|
// Scripts updated
|
|
131
144
|
const syncResult = results.find((r) => r.path.includes("sync.mjs"));
|
|
@@ -135,17 +148,126 @@ describe("Hooks JSON merger", () => {
|
|
|
135
148
|
const promptResult = results.find((r) => r.path.includes("prompt-match"));
|
|
136
149
|
assert.equal(promptResult.action, "updated");
|
|
137
150
|
assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), "// new prompt");
|
|
151
|
+
|
|
152
|
+
// Settings skipped
|
|
153
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
154
|
+
assert.equal(settingsResult.action, "skipped");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("cleans up stale .claude/hooks/hooks.json", async () => {
|
|
158
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
159
|
+
|
|
160
|
+
// Create stale hooks.json
|
|
161
|
+
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
162
|
+
writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), '{"hooks":{}}');
|
|
163
|
+
|
|
164
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
165
|
+
|
|
166
|
+
// Stale file removed
|
|
167
|
+
assert.equal(existsSync(join(tempDir, ".claude/hooks/hooks.json")), false);
|
|
168
|
+
const removedResult = results.find((r) => r.path === ".claude/hooks/hooks.json");
|
|
169
|
+
assert.equal(removedResult.action, "removed");
|
|
170
|
+
|
|
171
|
+
// settings.local.json created
|
|
172
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
173
|
+
assert.equal(settingsResult.action, "created");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("throws on invalid JSON in settings.local.json", async () => {
|
|
177
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
178
|
+
|
|
179
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
180
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), "not json{");
|
|
181
|
+
|
|
182
|
+
assert.throws(
|
|
183
|
+
() => mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT),
|
|
184
|
+
/Cannot parse/
|
|
185
|
+
);
|
|
138
186
|
});
|
|
139
187
|
|
|
140
|
-
it("
|
|
188
|
+
it("merges SessionStart when existing value is null", async () => {
|
|
141
189
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
142
190
|
|
|
191
|
+
// Pre-populate settings.local.json with SessionStart: null
|
|
192
|
+
const existing = {
|
|
193
|
+
hooks: { SessionStart: null },
|
|
194
|
+
};
|
|
195
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
196
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
197
|
+
|
|
198
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
199
|
+
|
|
200
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
201
|
+
assert.equal(settingsResult.action, "merged");
|
|
202
|
+
|
|
203
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
204
|
+
assert.ok(Array.isArray(config.hooks.SessionStart), "SessionStart should be an array");
|
|
205
|
+
assert.equal(config.hooks.SessionStart.length, 1);
|
|
206
|
+
assert.equal(config.hooks.SessionStart[0].hooks[0].command, "node .claude/hooks/skillrepo-sync.mjs");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("documents partial write when JSON parse fails (hook scripts written before throw)", async () => {
|
|
210
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
211
|
+
|
|
212
|
+
// Create old sync script on disk
|
|
143
213
|
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
144
|
-
writeFileSync(join(tempDir, ".claude/hooks/
|
|
214
|
+
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old content");
|
|
145
215
|
|
|
216
|
+
// Create invalid settings.local.json
|
|
217
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
218
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), "not valid json{{{");
|
|
219
|
+
|
|
220
|
+
// Should throw on invalid JSON
|
|
146
221
|
assert.throws(
|
|
147
|
-
() => mergeHooksConfig(
|
|
222
|
+
() => mergeHooksConfig(HOOKS_CONFIG, "// new sync content", PROMPT_CONTENT),
|
|
148
223
|
/Cannot parse/
|
|
149
224
|
);
|
|
225
|
+
|
|
226
|
+
// Document the partial-write behavior: sync script was updated before the throw
|
|
227
|
+
const syncOnDisk = readFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "utf-8");
|
|
228
|
+
assert.equal(syncOnDisk, "// new sync content", "sync script should have new content (partial write)");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("writes pretool hook script when preToolHookContent is provided", async () => {
|
|
232
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
233
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT, PRETOOL_CONTENT);
|
|
234
|
+
|
|
235
|
+
const preToolResult = results.find((r) => r.path.includes("pretool"));
|
|
236
|
+
assert.equal(preToolResult.action, "created");
|
|
237
|
+
assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-pretool-match.mjs"), "utf-8"), PRETOOL_CONTENT);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("merges PreToolUse hook into settings.local.json", async () => {
|
|
241
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
242
|
+
mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT, PRETOOL_CONTENT);
|
|
243
|
+
|
|
244
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
245
|
+
assert.ok(Array.isArray(config.hooks.PreToolUse), "PreToolUse should be an array");
|
|
246
|
+
assert.equal(config.hooks.PreToolUse.length, 1);
|
|
247
|
+
assert.equal(config.hooks.PreToolUse[0].hooks[0].command, "node .claude/hooks/skillrepo-pretool-match.mjs");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("skips pretool hook when preToolHookContent is not provided", async () => {
|
|
251
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
252
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
253
|
+
|
|
254
|
+
const preToolResult = results.find((r) => r.path.includes("pretool"));
|
|
255
|
+
assert.equal(preToolResult, undefined, "no pretool result when content not provided");
|
|
256
|
+
assert.equal(existsSync(join(tempDir, ".claude/hooks/skillrepo-pretool-match.mjs")), false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("does not duplicate PreToolUse when already installed", async () => {
|
|
260
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
261
|
+
|
|
262
|
+
// First install
|
|
263
|
+
mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT, PRETOOL_CONTENT);
|
|
264
|
+
// Second install
|
|
265
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT, PRETOOL_CONTENT);
|
|
266
|
+
|
|
267
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
268
|
+
assert.equal(settingsResult.action, "skipped");
|
|
269
|
+
|
|
270
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
271
|
+
assert.equal(config.hooks.PreToolUse.length, 1, "should not duplicate PreToolUse entry");
|
|
150
272
|
});
|
|
151
273
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
let originalCwd;
|
|
8
|
+
let tempDir;
|
|
9
|
+
let originalEnv;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
originalCwd = process.cwd;
|
|
13
|
+
originalEnv = process.env.SKILLREPO_ACCESS_KEY;
|
|
14
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
15
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-resolve-key-test-"));
|
|
16
|
+
process.cwd = () => tempDir;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.cwd = originalCwd;
|
|
21
|
+
if (originalEnv !== undefined) {
|
|
22
|
+
process.env.SKILLREPO_ACCESS_KEY = originalEnv;
|
|
23
|
+
} else {
|
|
24
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
25
|
+
}
|
|
26
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("resolveKeyFromEnvFiles", () => {
|
|
30
|
+
it("short-circuits when key is in process.env", async () => {
|
|
31
|
+
process.env.SKILLREPO_ACCESS_KEY = "sk_live_from_env";
|
|
32
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
33
|
+
const result = resolveKeyFromEnvFiles();
|
|
34
|
+
assert.equal(result, "sk_live_from_env");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads plain value from .env.local", async () => {
|
|
38
|
+
writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_ACCESS_KEY=sk_live_abc123\n");
|
|
39
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
40
|
+
const result = resolveKeyFromEnvFiles();
|
|
41
|
+
assert.equal(result, "sk_live_abc123");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reads double-quoted value from .env.local", async () => {
|
|
45
|
+
writeFileSync(join(tempDir, ".env.local"), 'SKILLREPO_ACCESS_KEY="sk_live_quoted"\n');
|
|
46
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
47
|
+
const result = resolveKeyFromEnvFiles();
|
|
48
|
+
assert.equal(result, "sk_live_quoted");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("strips inline comment from value", async () => {
|
|
52
|
+
writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_ACCESS_KEY=sk_live_abc123 # my key\n");
|
|
53
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
54
|
+
const result = resolveKeyFromEnvFiles();
|
|
55
|
+
assert.equal(result, "sk_live_abc123");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls back to .env when .env.local is absent", async () => {
|
|
59
|
+
writeFileSync(join(tempDir, ".env"), "SKILLREPO_ACCESS_KEY=sk_live_fallback\n");
|
|
60
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
61
|
+
const result = resolveKeyFromEnvFiles();
|
|
62
|
+
assert.equal(result, "sk_live_fallback");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null when key is absent in both files", async () => {
|
|
66
|
+
writeFileSync(join(tempDir, ".env.local"), "OTHER_VAR=hello\n");
|
|
67
|
+
writeFileSync(join(tempDir, ".env"), "ANOTHER_VAR=world\n");
|
|
68
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
69
|
+
const result = resolveKeyFromEnvFiles();
|
|
70
|
+
assert.equal(result, null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null when both files are missing", async () => {
|
|
74
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
75
|
+
const result = resolveKeyFromEnvFiles();
|
|
76
|
+
assert.equal(result, null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores commented-out key line", async () => {
|
|
80
|
+
writeFileSync(join(tempDir, ".env.local"), "# SKILLREPO_ACCESS_KEY=sk_live_old\n");
|
|
81
|
+
const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
|
|
82
|
+
const result = resolveKeyFromEnvFiles();
|
|
83
|
+
assert.equal(result, null);
|
|
84
|
+
});
|
|
85
|
+
});
|