skillrepo 1.3.1 → 1.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/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 +59 -31
- package/src/lib/paths.mjs +1 -1
- package/src/lib/resolve-key.mjs +36 -0
- package/src/lib/write-configs.mjs +1 -1
- package/src/test/mergers/claude-mcp.test.mjs +4 -4
- package/src/test/mergers/hooks-json.test.mjs +120 -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,34 @@
|
|
|
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, 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
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract a hook command from a hooks config group, falling back to a default.
|
|
20
|
+
* @param {object} hooksConfig - The hooks config object from the server payload
|
|
21
|
+
* @param {string} eventName - e.g. "SessionStart" or "UserPromptSubmit"
|
|
22
|
+
* @param {string} fallback - default command if extraction fails
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function extractCommand(hooksConfig, eventName, fallback) {
|
|
26
|
+
try {
|
|
27
|
+
return hooksConfig[eventName][0].hooks[0].command || fallback;
|
|
28
|
+
} catch {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
14
32
|
|
|
15
33
|
/**
|
|
16
34
|
* Check if a command is already installed in a hook group array.
|
|
@@ -28,13 +46,15 @@ function hasCommand(groups, command) {
|
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
/**
|
|
31
|
-
* Merge SkillRepo hooks into
|
|
32
|
-
* @param {
|
|
49
|
+
* Merge SkillRepo hooks into .claude/settings.local.json and write hook scripts.
|
|
50
|
+
* @param {object} hooksConfig - The hooks config object from the server payload (settingsHooks.hooks)
|
|
33
51
|
* @param {string} syncHookContent - The sync hook script content
|
|
34
52
|
* @param {string} promptHookContent - The prompt-match hook script content
|
|
35
53
|
* @returns {{ results: { path: string; action: string }[] }}
|
|
36
54
|
*/
|
|
37
|
-
export function mergeHooksConfig(
|
|
55
|
+
export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent) {
|
|
56
|
+
const syncCommand = extractCommand(hooksConfig, "SessionStart", DEFAULT_SYNC_COMMAND);
|
|
57
|
+
const promptCommand = extractCommand(hooksConfig, "UserPromptSubmit", DEFAULT_PROMPT_COMMAND);
|
|
38
58
|
const results = [];
|
|
39
59
|
|
|
40
60
|
// Always write hook scripts (latest version from server)
|
|
@@ -52,23 +72,28 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookCo
|
|
|
52
72
|
action: promptExisted ? "updated" : "created",
|
|
53
73
|
});
|
|
54
74
|
|
|
55
|
-
//
|
|
56
|
-
const
|
|
57
|
-
const existing = readFileSafe(filePath);
|
|
58
|
-
|
|
59
|
-
if (existing === null) {
|
|
60
|
-
writeFileSafe(filePath, hooksJsonContent);
|
|
61
|
-
results.push({ path: ".claude/hooks/hooks.json", action: "created" });
|
|
62
|
-
return { results };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
let config;
|
|
75
|
+
// Clean up stale .claude/hooks/hooks.json if it exists
|
|
76
|
+
const staleHooksJson = join(claudeHooksDir(), "hooks.json");
|
|
66
77
|
try {
|
|
67
|
-
|
|
78
|
+
unlinkSync(staleHooksJson);
|
|
79
|
+
results.push({ path: ".claude/hooks/hooks.json", action: "removed" });
|
|
68
80
|
} catch {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
// File doesn't exist — nothing to clean up
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merge hooks into .claude/settings.local.json
|
|
85
|
+
const filePath = claudeSettingsLocal();
|
|
86
|
+
const existing = readFileSafe(filePath);
|
|
87
|
+
|
|
88
|
+
let config = {};
|
|
89
|
+
if (existing !== null) {
|
|
90
|
+
try {
|
|
91
|
+
config = JSON.parse(existing);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"Cannot parse .claude/settings.local.json — invalid JSON. Fix or delete it, then run setup again."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
if (!config.hooks) config.hooks = {};
|
|
@@ -76,28 +101,31 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookCo
|
|
|
76
101
|
|
|
77
102
|
// Merge SessionStart
|
|
78
103
|
if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
|
|
79
|
-
if (!hasCommand(config.hooks.SessionStart,
|
|
104
|
+
if (!hasCommand(config.hooks.SessionStart, syncCommand)) {
|
|
80
105
|
config.hooks.SessionStart.push({
|
|
81
106
|
matcher: "startup|resume",
|
|
82
|
-
hooks: [{ type: "command", command:
|
|
107
|
+
hooks: [{ type: "command", command: syncCommand }],
|
|
83
108
|
});
|
|
84
109
|
changed = true;
|
|
85
110
|
}
|
|
86
111
|
|
|
87
112
|
// Merge UserPromptSubmit
|
|
88
113
|
if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = [];
|
|
89
|
-
if (!hasCommand(config.hooks.UserPromptSubmit,
|
|
114
|
+
if (!hasCommand(config.hooks.UserPromptSubmit, promptCommand)) {
|
|
90
115
|
config.hooks.UserPromptSubmit.push({
|
|
91
|
-
hooks: [{ type: "command", command:
|
|
116
|
+
hooks: [{ type: "command", command: promptCommand }],
|
|
92
117
|
});
|
|
93
118
|
changed = true;
|
|
94
119
|
}
|
|
95
120
|
|
|
96
|
-
if (
|
|
121
|
+
if (existing === null) {
|
|
122
|
+
writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
123
|
+
results.push({ path: ".claude/settings.local.json", action: "created" });
|
|
124
|
+
} else if (changed) {
|
|
97
125
|
writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
98
|
-
results.push({ path: ".claude/
|
|
126
|
+
results.push({ path: ".claude/settings.local.json", action: "merged" });
|
|
99
127
|
} else {
|
|
100
|
-
results.push({ path: ".claude/
|
|
128
|
+
results.push({ path: ".claude/settings.local.json", action: "skipped" });
|
|
101
129
|
}
|
|
102
130
|
|
|
103
131
|
return { results };
|
package/src/lib/paths.mjs
CHANGED
|
@@ -12,7 +12,7 @@ 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
18
|
export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
|
|
@@ -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
|
+
}
|
|
@@ -40,7 +40,7 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, payload }) {
|
|
|
40
40
|
|
|
41
41
|
// Hooks (SessionStart sync + UserPromptSubmit prompt-match)
|
|
42
42
|
const hookResults = mergeHooksConfig(
|
|
43
|
-
payload.claudeCode.
|
|
43
|
+
payload.claudeCode.settingsHooks.hooks,
|
|
44
44
|
payload.claudeCode.syncHook.content,
|
|
45
45
|
payload.claudeCode.promptHook.content
|
|
46
46
|
);
|
|
@@ -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,16 @@ afterEach(() => {
|
|
|
20
20
|
|
|
21
21
|
const SYNC_CONTENT = "// sync hook";
|
|
22
22
|
const PROMPT_CONTENT = "// prompt hook";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
it("creates all files when nothing exists", async () => {
|
|
23
|
+
|
|
24
|
+
const HOOKS_CONFIG = {
|
|
25
|
+
SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
|
|
26
|
+
UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("Hooks settings.local.json merger", () => {
|
|
30
|
+
it("creates settings.local.json and scripts when nothing exists", async () => {
|
|
32
31
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
33
|
-
const { results } = mergeHooksConfig(
|
|
32
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
34
33
|
|
|
35
34
|
// Check sync hook written
|
|
36
35
|
const syncResult = results.find((r) => r.path.includes("sync"));
|
|
@@ -42,30 +41,37 @@ describe("Hooks JSON merger", () => {
|
|
|
42
41
|
assert.equal(promptResult.action, "created");
|
|
43
42
|
assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), PROMPT_CONTENT);
|
|
44
43
|
|
|
45
|
-
// Check
|
|
46
|
-
const
|
|
47
|
-
assert.equal(
|
|
44
|
+
// Check settings.local.json created
|
|
45
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
46
|
+
assert.equal(settingsResult.action, "created");
|
|
47
|
+
|
|
48
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
49
|
+
assert.equal(config.hooks.SessionStart.length, 1);
|
|
50
|
+
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
|
-
it("merges into existing
|
|
53
|
+
it("merges into existing settings without destroying other keys", async () => {
|
|
51
54
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
52
55
|
|
|
53
|
-
// Pre-existing
|
|
56
|
+
// Pre-existing settings.local.json with permissions and other hooks
|
|
54
57
|
const existing = {
|
|
58
|
+
permissions: { allow: ["Bash(*)", "Read(*)"] },
|
|
55
59
|
hooks: {
|
|
56
60
|
PreToolUse: [{ hooks: [{ type: "command", command: "node other-hook.mjs" }] }],
|
|
57
61
|
},
|
|
58
62
|
};
|
|
59
|
-
mkdirSync(join(tempDir, ".claude
|
|
60
|
-
writeFileSync(join(tempDir, ".claude/
|
|
63
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
64
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
61
65
|
|
|
62
|
-
const { results } = mergeHooksConfig(
|
|
66
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
63
67
|
|
|
64
|
-
const
|
|
65
|
-
assert.equal(
|
|
68
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
69
|
+
assert.equal(settingsResult.action, "merged");
|
|
66
70
|
|
|
67
|
-
const config = JSON.parse(readFileSync(join(tempDir, ".claude/
|
|
68
|
-
// Original
|
|
71
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
72
|
+
// Original permissions preserved
|
|
73
|
+
assert.deepEqual(config.permissions, { allow: ["Bash(*)", "Read(*)"] });
|
|
74
|
+
// Original hooks preserved
|
|
69
75
|
assert.equal(config.hooks.PreToolUse.length, 1);
|
|
70
76
|
// New hooks added
|
|
71
77
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
@@ -82,50 +88,55 @@ describe("Hooks JSON merger", () => {
|
|
|
82
88
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
|
|
83
89
|
},
|
|
84
90
|
};
|
|
85
|
-
mkdirSync(join(tempDir, ".claude
|
|
86
|
-
writeFileSync(join(tempDir, ".claude/
|
|
91
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
92
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
87
93
|
|
|
88
|
-
const { results } = mergeHooksConfig(
|
|
94
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
89
95
|
|
|
90
|
-
const
|
|
91
|
-
assert.equal(
|
|
96
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
97
|
+
assert.equal(settingsResult.action, "skipped");
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
it("adds missing UserPromptSubmit when only SessionStart exists", async () => {
|
|
95
101
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
96
102
|
|
|
97
|
-
// Only SessionStart installed
|
|
103
|
+
// Only SessionStart installed
|
|
98
104
|
const existing = {
|
|
99
105
|
hooks: {
|
|
100
106
|
SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
|
|
101
107
|
},
|
|
102
108
|
};
|
|
103
|
-
mkdirSync(join(tempDir, ".claude
|
|
104
|
-
writeFileSync(join(tempDir, ".claude/
|
|
109
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
110
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
105
111
|
|
|
106
|
-
const { results } = mergeHooksConfig(
|
|
112
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
107
113
|
|
|
108
|
-
const
|
|
109
|
-
assert.equal(
|
|
114
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
115
|
+
assert.equal(settingsResult.action, "merged");
|
|
110
116
|
|
|
111
|
-
const config = JSON.parse(readFileSync(join(tempDir, ".claude/
|
|
112
|
-
// SessionStart unchanged
|
|
117
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
113
118
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
114
|
-
// UserPromptSubmit added
|
|
115
119
|
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
116
120
|
assert.equal(config.hooks.UserPromptSubmit[0].hooks[0].command, "node .claude/hooks/skillrepo-prompt-match.mjs");
|
|
117
121
|
});
|
|
118
122
|
|
|
119
|
-
it("always updates hook scripts even when
|
|
123
|
+
it("always updates hook scripts even when settings merge is skipped", async () => {
|
|
120
124
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
121
125
|
|
|
122
|
-
// Install once
|
|
126
|
+
// Install once with old scripts
|
|
123
127
|
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
124
128
|
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old sync");
|
|
125
129
|
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "// old prompt");
|
|
126
|
-
|
|
130
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
131
|
+
const existing = {
|
|
132
|
+
hooks: {
|
|
133
|
+
SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
|
|
134
|
+
UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
127
138
|
|
|
128
|
-
const { results } = mergeHooksConfig(
|
|
139
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, "// new sync", "// new prompt");
|
|
129
140
|
|
|
130
141
|
// Scripts updated
|
|
131
142
|
const syncResult = results.find((r) => r.path.includes("sync.mjs"));
|
|
@@ -135,17 +146,83 @@ describe("Hooks JSON merger", () => {
|
|
|
135
146
|
const promptResult = results.find((r) => r.path.includes("prompt-match"));
|
|
136
147
|
assert.equal(promptResult.action, "updated");
|
|
137
148
|
assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), "// new prompt");
|
|
149
|
+
|
|
150
|
+
// Settings skipped
|
|
151
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
152
|
+
assert.equal(settingsResult.action, "skipped");
|
|
138
153
|
});
|
|
139
154
|
|
|
140
|
-
it("
|
|
155
|
+
it("cleans up stale .claude/hooks/hooks.json", async () => {
|
|
141
156
|
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
142
157
|
|
|
158
|
+
// Create stale hooks.json
|
|
143
159
|
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
144
|
-
writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), "
|
|
160
|
+
writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), '{"hooks":{}}');
|
|
161
|
+
|
|
162
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
163
|
+
|
|
164
|
+
// Stale file removed
|
|
165
|
+
assert.equal(existsSync(join(tempDir, ".claude/hooks/hooks.json")), false);
|
|
166
|
+
const removedResult = results.find((r) => r.path === ".claude/hooks/hooks.json");
|
|
167
|
+
assert.equal(removedResult.action, "removed");
|
|
168
|
+
|
|
169
|
+
// settings.local.json created
|
|
170
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
171
|
+
assert.equal(settingsResult.action, "created");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("throws on invalid JSON in settings.local.json", async () => {
|
|
175
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
176
|
+
|
|
177
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
178
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), "not json{");
|
|
145
179
|
|
|
146
180
|
assert.throws(
|
|
147
|
-
() => mergeHooksConfig(
|
|
181
|
+
() => mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT),
|
|
148
182
|
/Cannot parse/
|
|
149
183
|
);
|
|
150
184
|
});
|
|
185
|
+
|
|
186
|
+
it("merges SessionStart when existing value is null", async () => {
|
|
187
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
188
|
+
|
|
189
|
+
// Pre-populate settings.local.json with SessionStart: null
|
|
190
|
+
const existing = {
|
|
191
|
+
hooks: { SessionStart: null },
|
|
192
|
+
};
|
|
193
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
194
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
195
|
+
|
|
196
|
+
const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
|
|
197
|
+
|
|
198
|
+
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
199
|
+
assert.equal(settingsResult.action, "merged");
|
|
200
|
+
|
|
201
|
+
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
202
|
+
assert.ok(Array.isArray(config.hooks.SessionStart), "SessionStart should be an array");
|
|
203
|
+
assert.equal(config.hooks.SessionStart.length, 1);
|
|
204
|
+
assert.equal(config.hooks.SessionStart[0].hooks[0].command, "node .claude/hooks/skillrepo-sync.mjs");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("documents partial write when JSON parse fails (hook scripts written before throw)", async () => {
|
|
208
|
+
const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
|
|
209
|
+
|
|
210
|
+
// Create old sync script on disk
|
|
211
|
+
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
212
|
+
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old content");
|
|
213
|
+
|
|
214
|
+
// Create invalid settings.local.json
|
|
215
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
216
|
+
writeFileSync(join(tempDir, ".claude/settings.local.json"), "not valid json{{{");
|
|
217
|
+
|
|
218
|
+
// Should throw on invalid JSON
|
|
219
|
+
assert.throws(
|
|
220
|
+
() => mergeHooksConfig(HOOKS_CONFIG, "// new sync content", PROMPT_CONTENT),
|
|
221
|
+
/Cannot parse/
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Document the partial-write behavior: sync script was updated before the throw
|
|
225
|
+
const syncOnDisk = readFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "utf-8");
|
|
226
|
+
assert.equal(syncOnDisk, "// new sync content", "sync script should have new content (partial write)");
|
|
227
|
+
});
|
|
151
228
|
});
|
|
@@ -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
|
+
});
|