@khanhcan148/mk 0.1.16 → 0.1.18

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/src/lib/copy.js CHANGED
@@ -1,331 +1,335 @@
1
- import { join, relative } from 'node:path';
2
- import { statSync, lstatSync, existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
3
- import fsExtra from 'fs-extra';
4
- import { KIT_SUBDIRS, COPY_FILTER_PATTERNS, WINDOWS_PATH_WARN_LENGTH } from './constants.js';
5
-
6
- /**
7
- * Check if a path should be filtered out during copy.
8
- * @param {string} filePath
9
- * @returns {boolean} true if the file should be excluded
10
- */
11
- function shouldFilter(filePath) {
12
- const normalized = filePath.replace(/\\/g, '/');
13
- for (const pattern of COPY_FILTER_PATTERNS) {
14
- if (normalized.includes(pattern)) return true;
15
- }
16
- return false;
17
- }
18
-
19
- /**
20
- * Collect all file paths recursively in a directory.
21
- * @param {string} dir
22
- * @returns {string[]}
23
- */
24
- function collectFiles(dir) {
25
- const results = [];
26
- function walk(current) {
27
- let entries;
28
- try {
29
- entries = fsExtra.readdirSync(current);
30
- } catch {
31
- return;
32
- }
33
- for (const entry of entries) {
34
- const fullPath = join(current, entry);
35
- try {
36
- // Use lstatSync (does not follow symlinks) to detect and skip symlinks.
37
- // Symlinks inside the bundled .claude/ could otherwise redirect writes
38
- // to arbitrary locations on the user's filesystem.
39
- const lstat = lstatSync(fullPath);
40
- if (lstat.isSymbolicLink()) continue;
41
- if (lstat.isDirectory()) {
42
- walk(fullPath);
43
- } else {
44
- results.push(fullPath);
45
- }
46
- } catch {
47
- // skip inaccessible files
48
- }
49
- }
50
- }
51
- walk(dir);
52
- return results;
53
- }
54
-
55
- /**
56
- * Collect all kit-managed files currently on disk under targetDir (.claude/).
57
- * Only walks KIT_SUBDIRS (agents/, skills/, workflows/).
58
- * Returns relative paths in the form `.claude/agents/foo.md`.
59
- * Applies shouldFilter to exclude filtered patterns.
60
- *
61
- * @param {string} targetDir - Absolute path to target .claude/
62
- * @returns {string[]} relative paths (relative to project root, e.g. `.claude/agents/foo.md`)
63
- */
64
- export function collectDiskFiles(targetDir) {
65
- const results = [];
66
- for (const subdir of KIT_SUBDIRS) {
67
- const subdirAbs = join(targetDir, subdir);
68
- if (!existsSync(subdirAbs)) continue;
69
- const files = collectFiles(subdirAbs);
70
- for (const absPath of files) {
71
- if (shouldFilter(absPath)) continue;
72
- const relFromSubdir = relative(subdirAbs, absPath);
73
- const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
74
- results.push(relPath);
75
- }
76
- }
77
- return results;
78
- }
79
-
80
- /**
81
- * Copy kit files from sourceDir (.claude/) to targetDir (.claude/).
82
- * Only copies KIT_SUBDIRS (agents/, skills/, workflows/).
83
- *
84
- * @param {string} sourceDir - Absolute path to source .claude/
85
- * @param {string} targetDir - Absolute path to target .claude/
86
- * @param {{ dryRun: boolean }} options
87
- * @returns {Array<{ relativePath: string, absolutePath: string, sourceAbsPath: string, size: number }>}
88
- * @remarks Naming convention: `absolutePath` is the destination (under targetDir),
89
- * `sourceAbsPath` is the source (under sourceDir). The asymmetry is intentional —
90
- * renaming would break consumers (update.js). See DEBT-016.
91
- */
92
- export function copyKitFiles(sourceDir, targetDir, options = {}) {
93
- const { dryRun = false } = options;
94
- const fileList = [];
95
- const warnings = [];
96
-
97
- for (const subdir of KIT_SUBDIRS) {
98
- const srcSubdir = join(sourceDir, subdir);
99
- if (!existsSync(srcSubdir)) continue;
100
-
101
- // Collect files in this subdir
102
- const files = collectFiles(srcSubdir);
103
-
104
- for (const absPath of files) {
105
- if (shouldFilter(absPath)) continue;
106
-
107
- const relFromSubdir = relative(srcSubdir, absPath);
108
- const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
109
- const destAbs = join(targetDir, subdir, relFromSubdir);
110
-
111
- // Windows path length check
112
- if (destAbs.length > WINDOWS_PATH_WARN_LENGTH) {
113
- warnings.push(`Warning: Long path (${destAbs.length} chars): ${destAbs}`);
114
- }
115
-
116
- let size = 0;
117
- try {
118
- size = statSync(absPath).size;
119
- } catch {
120
- // ignore
121
- }
122
-
123
- fileList.push({ relativePath: relPath, absolutePath: destAbs, sourceAbsPath: absPath, size });
124
- }
125
- }
126
-
127
- // Print warnings
128
- for (const w of warnings) {
129
- process.stderr.write(w + '\n');
130
- }
131
-
132
- if (dryRun) {
133
- return fileList;
134
- }
135
-
136
- // Actually copy each subdir using fs-extra
137
- for (const subdir of KIT_SUBDIRS) {
138
- const srcSubdir = join(sourceDir, subdir);
139
- if (!existsSync(srcSubdir)) continue;
140
- const destSubdir = join(targetDir, subdir);
141
-
142
- fsExtra.copySync(srcSubdir, destSubdir, {
143
- overwrite: true,
144
- filter: (src) => {
145
- if (shouldFilter(src)) return false;
146
- try {
147
- // Skip symlinks — prevents traversal to paths outside destSubdir
148
- if (lstatSync(src).isSymbolicLink()) return false;
149
- } catch {
150
- return false;
151
- }
152
- return true;
153
- }
154
- });
155
- }
156
-
157
- return fileList;
158
- }
159
-
160
- /**
161
- * Create a timestamped backup of a file.
162
- * @param {string} filePath - Absolute path to the file to back up
163
- * @returns {string|null} Backup path, or null if source doesn't exist
164
- */
165
- function backupFile(filePath) {
166
- if (!existsSync(filePath)) return null;
167
- const now = new Date();
168
- const ts = [
169
- now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'),
170
- String(now.getDate()).padStart(2, '0'), '-',
171
- String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0')
172
- ].join('');
173
- const backupPath = `${filePath}.${ts}.bak`;
174
- copyFileSync(filePath, backupPath);
175
- return backupPath;
176
- }
177
-
178
- /**
179
- * Returns true if siblingHooks already has an entry for the given event+matcher.
180
- * @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
181
- * @param {string} event - Hook event name (e.g. 'PostToolUse')
182
- * @param {string} matcher - Hook matcher (e.g. '*', 'Task')
183
- */
184
- function isInSibling(siblingHooks, event, matcher) {
185
- return !!siblingHooks?.[event]?.some(s => (s.matcher || '*') === matcher);
186
- }
187
-
188
- /**
189
- * Filter kitEntries removing any whose event+matcher already exists in siblingHooks.
190
- * Emits a stderr info message per skipped entry.
191
- * @param {object[]} kitEntries - Hook entries from kit source
192
- * @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
193
- * @param {string} event - Hook event name
194
- * @returns {object[]} Entries not present in sibling
195
- */
196
- function dedupeAgainstSibling(kitEntries, siblingHooks, event) {
197
- if (!siblingHooks?.[event]) return kitEntries;
198
- return kitEntries.filter(ke => {
199
- const m = ke.matcher || '*';
200
- if (isInSibling(siblingHooks, event, m)) {
201
- process.stderr.write(`mk: hook ${event}[${m}] skipped (exists in settings.local.json)\n`);
202
- return false;
203
- }
204
- return true;
205
- });
206
- }
207
-
208
- /**
209
- * Merge kit's settings.json into user's existing settings.json.
210
- * Strategy: merge "hooks" key from kit source; preserve all other user keys.
211
- * When a matcher already exists, the kit's hooks REPLACE the user's hooks for
212
- * that matcher — this ensures updated hook commands propagate on update instead
213
- * of accumulating stale duplicates.
214
- * If user has no settings.json, create one with hooks only.
215
- * If kit source has no settings.json, do nothing.
216
- * A timestamped backup is created before any write to an existing file.
217
- *
218
- * @param {string} sourceDir - Absolute path to source .claude/
219
- * @param {string} targetDir - Absolute path to target .claude/
220
- * @param {{ dryRun: boolean }} options
221
- * @returns {{ action: 'created'|'merged'|'skipped', merged?: string[], backup?: string }}
222
- */
223
- export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
224
- const { dryRun = false } = options;
225
- const srcPath = join(sourceDir, 'settings.json');
226
- const destPath = join(targetDir, 'settings.json');
227
-
228
- if (!existsSync(srcPath)) return { action: 'skipped' };
229
-
230
- let kitSettings;
231
- try {
232
- kitSettings = JSON.parse(readFileSync(srcPath, 'utf-8'));
233
- } catch {
234
- return { action: 'skipped' };
235
- }
236
-
237
- // Read settings.local.json once for sibling dedup guard.
238
- // Applies to both the 'created' and 'merged' paths — Claude Code loads hooks from
239
- // all settings files simultaneously; if a user already has a hook in settings.local.json,
240
- // writing the same event+matcher to settings.json would cause both to fire.
241
- let siblingHooks = null;
242
- try {
243
- const siblingPath = join(targetDir, 'settings.local.json');
244
- siblingHooks = JSON.parse(readFileSync(siblingPath, 'utf-8')).hooks || null;
245
- } catch {
246
- // Missing or malformed sibling — proceed as if no sibling exists.
247
- }
248
-
249
- // No existing user settings — create with hooks only (not permissions or other keys).
250
- // Copying the full kit settings.json would duplicate permissions.deny entries when both
251
- // global (~/.claude/settings.json) and project (.claude/settings.json) are initialised.
252
- if (!existsSync(destPath)) {
253
- if (!dryRun) {
254
- const hooksOnly = {};
255
- if (kitSettings.hooks) {
256
- for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
257
- const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
258
- if (deduped.length > 0) hooksOnly[event] = deduped;
259
- }
260
- }
261
- mkdirSync(targetDir, { recursive: true });
262
- const payload = Object.keys(hooksOnly).length > 0 ? { hooks: hooksOnly } : {};
263
- writeFileSync(destPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
264
- }
265
- return { action: 'created' };
266
- }
267
-
268
- // Existing user settings — merge hooks only, preserve everything else
269
- let userSettings;
270
- try {
271
- userSettings = JSON.parse(readFileSync(destPath, 'utf-8'));
272
- } catch {
273
- // Malformed user settings — skip to avoid data loss
274
- return { action: 'skipped' };
275
- }
276
-
277
- const merged = [];
278
-
279
- // Merge hooks: kit entries are added or replaced by matcher; user-only matchers preserved.
280
- // Replace strategy: when the kit ships an updated hook command for an existing matcher,
281
- // the old command is replaced instead of appended — preventing stale duplicates.
282
- if (kitSettings.hooks) {
283
- if (!userSettings.hooks) userSettings.hooks = {};
284
- for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
285
- if (!userSettings.hooks[event]) {
286
- const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
287
- if (deduped.length > 0) {
288
- userSettings.hooks[event] = deduped;
289
- merged.push(event);
290
- }
291
- } else {
292
- for (const kitEntry of kitEntries) {
293
- const kitMatcher = kitEntry.matcher || '*';
294
- // Dedup guard: skip if sibling already has this event+matcher
295
- if (isInSibling(siblingHooks, event, kitMatcher)) {
296
- process.stderr.write(`mk: hook ${event}[${kitMatcher}] skipped (exists in settings.local.json)\n`);
297
- continue;
298
- }
299
- const idx = userSettings.hooks[event].findIndex(
300
- e => (e.matcher || '*') === kitMatcher
301
- );
302
- if (idx === -1) {
303
- // New matcher — add it
304
- userSettings.hooks[event].push(kitEntry);
305
- merged.push(`${event}[${kitMatcher}]:added`);
306
- } else {
307
- // Existing matcher — replace with kit version if hooks differ
308
- const userEntry = userSettings.hooks[event][idx];
309
- const userCmds = (userEntry.hooks || []).map(h => h.command).sort();
310
- const kitCmds = (kitEntry.hooks || []).map(h => h.command).sort();
311
- const same = userCmds.length === kitCmds.length &&
312
- userCmds.every((c, i) => c === kitCmds[i]);
313
- if (!same) {
314
- userSettings.hooks[event][idx] = kitEntry;
315
- merged.push(`${event}[${kitMatcher}]:replaced`);
316
- }
317
- }
318
- }
319
- }
320
- }
321
- }
322
-
323
- if (merged.length === 0) return { action: 'skipped' };
324
-
325
- if (!dryRun) {
326
- const backup = backupFile(destPath);
327
- writeFileSync(destPath, JSON.stringify(userSettings, null, 2) + '\n', 'utf-8');
328
- return { action: 'merged', merged, backup };
329
- }
330
- return { action: 'merged', merged };
331
- }
1
+ import { join, relative } from 'node:path';
2
+ import { statSync, lstatSync, existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
3
+ import fsExtra from 'fs-extra';
4
+ import { KIT_SUBDIRS, COPY_FILTER_PATTERNS, KIT_INTERNAL_SKILLS, WINDOWS_PATH_WARN_LENGTH } from './constants.js';
5
+
6
+ /**
7
+ * Check if a path should be filtered out during copy.
8
+ * @param {string} filePath
9
+ * @returns {boolean} true if the file should be excluded
10
+ */
11
+ function shouldFilter(filePath) {
12
+ const normalized = filePath.replace(/\\/g, '/');
13
+ for (const pattern of COPY_FILTER_PATTERNS) {
14
+ if (normalized.includes(pattern)) return true;
15
+ }
16
+ // Exclude internal skills — see KIT_INTERNAL_SKILLS JSDoc in constants.js.
17
+ for (const skillName of KIT_INTERNAL_SKILLS) {
18
+ if (normalized.includes(`/skills/${skillName}/`)) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ /**
24
+ * Collect all file paths recursively in a directory.
25
+ * @param {string} dir
26
+ * @returns {string[]}
27
+ */
28
+ function collectFiles(dir) {
29
+ const results = [];
30
+ function walk(current) {
31
+ let entries;
32
+ try {
33
+ entries = fsExtra.readdirSync(current);
34
+ } catch {
35
+ return;
36
+ }
37
+ for (const entry of entries) {
38
+ const fullPath = join(current, entry);
39
+ try {
40
+ // Use lstatSync (does not follow symlinks) to detect and skip symlinks.
41
+ // Symlinks inside the bundled .claude/ could otherwise redirect writes
42
+ // to arbitrary locations on the user's filesystem.
43
+ const lstat = lstatSync(fullPath);
44
+ if (lstat.isSymbolicLink()) continue;
45
+ if (lstat.isDirectory()) {
46
+ walk(fullPath);
47
+ } else {
48
+ results.push(fullPath);
49
+ }
50
+ } catch {
51
+ // skip inaccessible files
52
+ }
53
+ }
54
+ }
55
+ walk(dir);
56
+ return results;
57
+ }
58
+
59
+ /**
60
+ * Collect all kit-managed files currently on disk under targetDir (.claude/).
61
+ * Only walks KIT_SUBDIRS (agents/, skills/, workflows/).
62
+ * Returns relative paths in the form `.claude/agents/foo.md`.
63
+ * Applies shouldFilter to exclude filtered patterns.
64
+ *
65
+ * @param {string} targetDir - Absolute path to target .claude/
66
+ * @returns {string[]} relative paths (relative to project root, e.g. `.claude/agents/foo.md`)
67
+ */
68
+ export function collectDiskFiles(targetDir) {
69
+ const results = [];
70
+ for (const subdir of KIT_SUBDIRS) {
71
+ const subdirAbs = join(targetDir, subdir);
72
+ if (!existsSync(subdirAbs)) continue;
73
+ const files = collectFiles(subdirAbs);
74
+ for (const absPath of files) {
75
+ if (shouldFilter(absPath)) continue;
76
+ const relFromSubdir = relative(subdirAbs, absPath);
77
+ const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
78
+ results.push(relPath);
79
+ }
80
+ }
81
+ return results;
82
+ }
83
+
84
+ /**
85
+ * Copy kit files from sourceDir (.claude/) to targetDir (.claude/).
86
+ * Only copies KIT_SUBDIRS (agents/, skills/, workflows/).
87
+ *
88
+ * @param {string} sourceDir - Absolute path to source .claude/
89
+ * @param {string} targetDir - Absolute path to target .claude/
90
+ * @param {{ dryRun: boolean }} options
91
+ * @returns {Array<{ relativePath: string, absolutePath: string, sourceAbsPath: string, size: number }>}
92
+ * @remarks Naming convention: `absolutePath` is the destination (under targetDir),
93
+ * `sourceAbsPath` is the source (under sourceDir). The asymmetry is intentional —
94
+ * renaming would break consumers (update.js). See DEBT-016.
95
+ */
96
+ export function copyKitFiles(sourceDir, targetDir, options = {}) {
97
+ const { dryRun = false } = options;
98
+ const fileList = [];
99
+ const warnings = [];
100
+
101
+ for (const subdir of KIT_SUBDIRS) {
102
+ const srcSubdir = join(sourceDir, subdir);
103
+ if (!existsSync(srcSubdir)) continue;
104
+
105
+ // Collect files in this subdir
106
+ const files = collectFiles(srcSubdir);
107
+
108
+ for (const absPath of files) {
109
+ if (shouldFilter(absPath)) continue;
110
+
111
+ const relFromSubdir = relative(srcSubdir, absPath);
112
+ const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
113
+ const destAbs = join(targetDir, subdir, relFromSubdir);
114
+
115
+ // Windows path length check
116
+ if (destAbs.length > WINDOWS_PATH_WARN_LENGTH) {
117
+ warnings.push(`Warning: Long path (${destAbs.length} chars): ${destAbs}`);
118
+ }
119
+
120
+ let size = 0;
121
+ try {
122
+ size = statSync(absPath).size;
123
+ } catch {
124
+ // ignore
125
+ }
126
+
127
+ fileList.push({ relativePath: relPath, absolutePath: destAbs, sourceAbsPath: absPath, size });
128
+ }
129
+ }
130
+
131
+ // Print warnings
132
+ for (const w of warnings) {
133
+ process.stderr.write(w + '\n');
134
+ }
135
+
136
+ if (dryRun) {
137
+ return fileList;
138
+ }
139
+
140
+ // Actually copy each subdir using fs-extra
141
+ for (const subdir of KIT_SUBDIRS) {
142
+ const srcSubdir = join(sourceDir, subdir);
143
+ if (!existsSync(srcSubdir)) continue;
144
+ const destSubdir = join(targetDir, subdir);
145
+
146
+ fsExtra.copySync(srcSubdir, destSubdir, {
147
+ overwrite: true,
148
+ filter: (src) => {
149
+ if (shouldFilter(src)) return false;
150
+ try {
151
+ // Skip symlinks — prevents traversal to paths outside destSubdir
152
+ if (lstatSync(src).isSymbolicLink()) return false;
153
+ } catch {
154
+ return false;
155
+ }
156
+ return true;
157
+ }
158
+ });
159
+ }
160
+
161
+ return fileList;
162
+ }
163
+
164
+ /**
165
+ * Create a timestamped backup of a file.
166
+ * @param {string} filePath - Absolute path to the file to back up
167
+ * @returns {string|null} Backup path, or null if source doesn't exist
168
+ */
169
+ function backupFile(filePath) {
170
+ if (!existsSync(filePath)) return null;
171
+ const now = new Date();
172
+ const ts = [
173
+ now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'),
174
+ String(now.getDate()).padStart(2, '0'), '-',
175
+ String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0')
176
+ ].join('');
177
+ const backupPath = `${filePath}.${ts}.bak`;
178
+ copyFileSync(filePath, backupPath);
179
+ return backupPath;
180
+ }
181
+
182
+ /**
183
+ * Returns true if siblingHooks already has an entry for the given event+matcher.
184
+ * @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
185
+ * @param {string} event - Hook event name (e.g. 'PostToolUse')
186
+ * @param {string} matcher - Hook matcher (e.g. '*', 'Task')
187
+ */
188
+ function isInSibling(siblingHooks, event, matcher) {
189
+ return !!siblingHooks?.[event]?.some(s => (s.matcher || '*') === matcher);
190
+ }
191
+
192
+ /**
193
+ * Filter kitEntries removing any whose event+matcher already exists in siblingHooks.
194
+ * Emits a stderr info message per skipped entry.
195
+ * @param {object[]} kitEntries - Hook entries from kit source
196
+ * @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
197
+ * @param {string} event - Hook event name
198
+ * @returns {object[]} Entries not present in sibling
199
+ */
200
+ function dedupeAgainstSibling(kitEntries, siblingHooks, event) {
201
+ if (!siblingHooks?.[event]) return kitEntries;
202
+ return kitEntries.filter(ke => {
203
+ const m = ke.matcher || '*';
204
+ if (isInSibling(siblingHooks, event, m)) {
205
+ process.stderr.write(`mk: hook ${event}[${m}] skipped (exists in settings.local.json)\n`);
206
+ return false;
207
+ }
208
+ return true;
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Merge kit's settings.json into user's existing settings.json.
214
+ * Strategy: merge "hooks" key from kit source; preserve all other user keys.
215
+ * When a matcher already exists, the kit's hooks REPLACE the user's hooks for
216
+ * that matcher this ensures updated hook commands propagate on update instead
217
+ * of accumulating stale duplicates.
218
+ * If user has no settings.json, create one with hooks only.
219
+ * If kit source has no settings.json, do nothing.
220
+ * A timestamped backup is created before any write to an existing file.
221
+ *
222
+ * @param {string} sourceDir - Absolute path to source .claude/
223
+ * @param {string} targetDir - Absolute path to target .claude/
224
+ * @param {{ dryRun: boolean }} options
225
+ * @returns {{ action: 'created'|'merged'|'skipped', merged?: string[], backup?: string }}
226
+ */
227
+ export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
228
+ const { dryRun = false } = options;
229
+ const srcPath = join(sourceDir, 'settings.json');
230
+ const destPath = join(targetDir, 'settings.json');
231
+
232
+ if (!existsSync(srcPath)) return { action: 'skipped' };
233
+
234
+ let kitSettings;
235
+ try {
236
+ kitSettings = JSON.parse(readFileSync(srcPath, 'utf-8'));
237
+ } catch {
238
+ return { action: 'skipped' };
239
+ }
240
+
241
+ // Read settings.local.json once for sibling dedup guard.
242
+ // Applies to both the 'created' and 'merged' paths — Claude Code loads hooks from
243
+ // all settings files simultaneously; if a user already has a hook in settings.local.json,
244
+ // writing the same event+matcher to settings.json would cause both to fire.
245
+ let siblingHooks = null;
246
+ try {
247
+ const siblingPath = join(targetDir, 'settings.local.json');
248
+ siblingHooks = JSON.parse(readFileSync(siblingPath, 'utf-8')).hooks || null;
249
+ } catch {
250
+ // Missing or malformed sibling proceed as if no sibling exists.
251
+ }
252
+
253
+ // No existing user settings — create with hooks only (not permissions or other keys).
254
+ // Copying the full kit settings.json would duplicate permissions.deny entries when both
255
+ // global (~/.claude/settings.json) and project (.claude/settings.json) are initialised.
256
+ if (!existsSync(destPath)) {
257
+ if (!dryRun) {
258
+ const hooksOnly = {};
259
+ if (kitSettings.hooks) {
260
+ for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
261
+ const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
262
+ if (deduped.length > 0) hooksOnly[event] = deduped;
263
+ }
264
+ }
265
+ mkdirSync(targetDir, { recursive: true });
266
+ const payload = Object.keys(hooksOnly).length > 0 ? { hooks: hooksOnly } : {};
267
+ writeFileSync(destPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
268
+ }
269
+ return { action: 'created' };
270
+ }
271
+
272
+ // Existing user settings — merge hooks only, preserve everything else
273
+ let userSettings;
274
+ try {
275
+ userSettings = JSON.parse(readFileSync(destPath, 'utf-8'));
276
+ } catch {
277
+ // Malformed user settings — skip to avoid data loss
278
+ return { action: 'skipped' };
279
+ }
280
+
281
+ const merged = [];
282
+
283
+ // Merge hooks: kit entries are added or replaced by matcher; user-only matchers preserved.
284
+ // Replace strategy: when the kit ships an updated hook command for an existing matcher,
285
+ // the old command is replaced instead of appended — preventing stale duplicates.
286
+ if (kitSettings.hooks) {
287
+ if (!userSettings.hooks) userSettings.hooks = {};
288
+ for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
289
+ if (!userSettings.hooks[event]) {
290
+ const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
291
+ if (deduped.length > 0) {
292
+ userSettings.hooks[event] = deduped;
293
+ merged.push(event);
294
+ }
295
+ } else {
296
+ for (const kitEntry of kitEntries) {
297
+ const kitMatcher = kitEntry.matcher || '*';
298
+ // Dedup guard: skip if sibling already has this event+matcher
299
+ if (isInSibling(siblingHooks, event, kitMatcher)) {
300
+ process.stderr.write(`mk: hook ${event}[${kitMatcher}] skipped (exists in settings.local.json)\n`);
301
+ continue;
302
+ }
303
+ const idx = userSettings.hooks[event].findIndex(
304
+ e => (e.matcher || '*') === kitMatcher
305
+ );
306
+ if (idx === -1) {
307
+ // New matcher — add it
308
+ userSettings.hooks[event].push(kitEntry);
309
+ merged.push(`${event}[${kitMatcher}]:added`);
310
+ } else {
311
+ // Existing matcher replace with kit version if hooks differ
312
+ const userEntry = userSettings.hooks[event][idx];
313
+ const userCmds = (userEntry.hooks || []).map(h => h.command).sort();
314
+ const kitCmds = (kitEntry.hooks || []).map(h => h.command).sort();
315
+ const same = userCmds.length === kitCmds.length &&
316
+ userCmds.every((c, i) => c === kitCmds[i]);
317
+ if (!same) {
318
+ userSettings.hooks[event][idx] = kitEntry;
319
+ merged.push(`${event}[${kitMatcher}]:replaced`);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ if (merged.length === 0) return { action: 'skipped' };
328
+
329
+ if (!dryRun) {
330
+ const backup = backupFile(destPath);
331
+ writeFileSync(destPath, JSON.stringify(userSettings, null, 2) + '\n', 'utf-8');
332
+ return { action: 'merged', merged, backup };
333
+ }
334
+ return { action: 'merged', merged };
335
+ }