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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: process.env.SKILLREPO_ACCESS_KEY || null,
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 ? "merged" : "created";
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/hooks/hooks.json
3
- * Writes hook scripts and merges hook entries into hooks.json.
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 hooks. Idempotent — skips if already installed.
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 { claudeHooksJson, claudeSyncHook, claudePromptHook } from "../paths.mjs";
12
+ import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudeHooksDir } from "../paths.mjs";
13
+ import { join } from "node:path";
11
14
 
12
- const SYNC_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
13
- const PROMPT_COMMAND = "node .claude/hooks/skillrepo-prompt-match.mjs";
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 hooks.json and write hook scripts.
32
- * @param {string} hooksJsonContent - The canonical hooks.json content from the API
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(hooksJsonContent, syncHookContent, promptHookContent) {
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
- // Merge hooks.json
56
- const filePath = claudeHooksJson();
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 };
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
- let config;
87
+ // Clean up stale .claude/hooks/hooks.json if it exists
88
+ const staleHooksJson = join(claudeHooksDir(), "hooks.json");
66
89
  try {
67
- config = JSON.parse(existing);
90
+ unlinkSync(staleHooksJson);
91
+ results.push({ path: ".claude/hooks/hooks.json", action: "removed" });
68
92
  } catch {
69
- throw new Error(
70
- "Cannot parse .claude/hooks/hooks.json — invalid JSON. Fix or delete it, then run setup again."
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, SYNC_COMMAND)) {
116
+ if (!hasCommand(config.hooks.SessionStart, syncCommand)) {
80
117
  config.hooks.SessionStart.push({
81
118
  matcher: "startup|resume",
82
- hooks: [{ type: "command", command: SYNC_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, PROMPT_COMMAND)) {
126
+ if (!hasCommand(config.hooks.UserPromptSubmit, promptCommand)) {
90
127
  config.hooks.UserPromptSubmit.push({
91
- hooks: [{ type: "command", command: PROMPT_COMMAND }],
128
+ hooks: [{ type: "command", command: promptCommand }],
92
129
  });
93
130
  changed = true;
94
131
  }
95
132
 
96
- if (changed) {
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/hooks/hooks.json", action: "merged" });
149
+ results.push({ path: ".claude/settings.local.json", action: "merged" });
99
150
  } else {
100
- results.push({ path: ".claude/hooks/hooks.json", action: "skipped" });
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 claudeHooksJson = () => join(cwd(), ".claude", "hooks", "hooks.json");
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
- // Hooks (SessionStart sync + UserPromptSubmit prompt-match)
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.hooksJson.content,
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, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
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, "created"); // new entry, not merge
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 (merged)", async () => {
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, "merged");
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 HOOKS_JSON = JSON.stringify({
24
- hooks: {
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
- }, null, 2) + "\n";
29
-
30
- describe("Hooks JSON merger", () => {
31
- it("creates all files when nothing exists", async () => {
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(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
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 hooks.json created
46
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
47
- assert.equal(jsonResult.action, "created");
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 hooks.json without destroying other hooks", async () => {
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 hooks.json with a different hook
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/hooks"), { recursive: true });
60
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
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(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
68
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
63
69
 
64
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
65
- assert.equal(jsonResult.action, "merged");
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/hooks/hooks.json"), "utf-8"));
68
- // Original hook preserved
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/hooks"), { recursive: true });
86
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
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(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
96
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
89
97
 
90
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
91
- assert.equal(jsonResult.action, "skipped");
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 (pre-existing v1.1 setup)
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/hooks"), { recursive: true });
104
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
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(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
114
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
107
115
 
108
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
109
- assert.equal(jsonResult.action, "merged");
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/hooks/hooks.json"), "utf-8"));
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 hooks.json is skipped", async () => {
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
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), HOOKS_JSON);
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(HOOKS_JSON, "// new sync", "// new prompt");
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("throws on invalid JSON in hooks.json", async () => {
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/hooks.json"), "not json{");
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(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT),
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
+ });