@khanhcan148/mk 0.1.14 → 0.1.16

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/auth.js CHANGED
@@ -127,7 +127,9 @@ export async function startDeviceFlow(opts = {}) {
127
127
  'Content-Type': 'application/x-www-form-urlencoded',
128
128
  Accept: 'application/json'
129
129
  },
130
- body: `client_id=${GITHUB_CLIENT_ID}&scope=repo`
130
+ // Empty scope sufficient for public repos (5000 req/hr). Set MK_OAUTH_SCOPE=repo for private forks.
131
+ // Use URLSearchParams to prevent parameter injection via env var containing '&' chars.
132
+ body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: process.env.MK_OAUTH_SCOPE || '' }).toString()
131
133
  });
132
134
 
133
135
  if (!codeRes.ok) {
package/src/lib/copy.js CHANGED
@@ -1,271 +1,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, 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
- */
89
- export function copyKitFiles(sourceDir, targetDir, options = {}) {
90
- const { dryRun = false } = options;
91
- const fileList = [];
92
- const warnings = [];
93
-
94
- for (const subdir of KIT_SUBDIRS) {
95
- const srcSubdir = join(sourceDir, subdir);
96
- if (!existsSync(srcSubdir)) continue;
97
-
98
- // Collect files in this subdir
99
- const files = collectFiles(srcSubdir);
100
-
101
- for (const absPath of files) {
102
- if (shouldFilter(absPath)) continue;
103
-
104
- const relFromSubdir = relative(srcSubdir, absPath);
105
- const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
106
- const destAbs = join(targetDir, subdir, relFromSubdir);
107
-
108
- // Windows path length check
109
- if (destAbs.length > WINDOWS_PATH_WARN_LENGTH) {
110
- warnings.push(`Warning: Long path (${destAbs.length} chars): ${destAbs}`);
111
- }
112
-
113
- let size = 0;
114
- try {
115
- size = statSync(absPath).size;
116
- } catch {
117
- // ignore
118
- }
119
-
120
- fileList.push({ relativePath: relPath, absolutePath: destAbs, sourceAbsPath: absPath, size });
121
- }
122
- }
123
-
124
- // Print warnings
125
- for (const w of warnings) {
126
- process.stderr.write(w + '\n');
127
- }
128
-
129
- if (dryRun) {
130
- return fileList;
131
- }
132
-
133
- // Actually copy each subdir using fs-extra
134
- for (const subdir of KIT_SUBDIRS) {
135
- const srcSubdir = join(sourceDir, subdir);
136
- if (!existsSync(srcSubdir)) continue;
137
- const destSubdir = join(targetDir, subdir);
138
-
139
- fsExtra.copySync(srcSubdir, destSubdir, {
140
- overwrite: true,
141
- filter: (src) => {
142
- if (shouldFilter(src)) return false;
143
- try {
144
- // Skip symlinks — prevents traversal to paths outside destSubdir
145
- if (lstatSync(src).isSymbolicLink()) return false;
146
- } catch {
147
- return false;
148
- }
149
- return true;
150
- }
151
- });
152
- }
153
-
154
- return fileList;
155
- }
156
-
157
- /**
158
- * Create a timestamped backup of a file.
159
- * @param {string} filePath - Absolute path to the file to back up
160
- * @returns {string|null} Backup path, or null if source doesn't exist
161
- */
162
- function backupFile(filePath) {
163
- if (!existsSync(filePath)) return null;
164
- const now = new Date();
165
- const ts = [
166
- now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'),
167
- String(now.getDate()).padStart(2, '0'), '-',
168
- String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0')
169
- ].join('');
170
- const backupPath = `${filePath}.${ts}.bak`;
171
- copyFileSync(filePath, backupPath);
172
- return backupPath;
173
- }
174
-
175
- /**
176
- * Merge kit's settings.json into user's existing settings.json.
177
- * Strategy: merge "hooks" key from kit source; preserve all other user keys.
178
- * When a matcher already exists, the kit's hooks REPLACE the user's hooks for
179
- * that matcher this ensures updated hook commands propagate on update instead
180
- * of accumulating stale duplicates.
181
- * If user has no settings.json, create one with hooks only.
182
- * If kit source has no settings.json, do nothing.
183
- * A timestamped backup is created before any write to an existing file.
184
- *
185
- * @param {string} sourceDir - Absolute path to source .claude/
186
- * @param {string} targetDir - Absolute path to target .claude/
187
- * @param {{ dryRun: boolean }} options
188
- * @returns {{ action: 'created'|'merged'|'skipped', merged?: string[], backup?: string }}
189
- */
190
- export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
191
- const { dryRun = false } = options;
192
- const srcPath = join(sourceDir, 'settings.json');
193
- const destPath = join(targetDir, 'settings.json');
194
-
195
- if (!existsSync(srcPath)) return { action: 'skipped' };
196
-
197
- let kitSettings;
198
- try {
199
- kitSettings = JSON.parse(readFileSync(srcPath, 'utf-8'));
200
- } catch {
201
- return { action: 'skipped' };
202
- }
203
-
204
- // No existing user settings — create with hooks only (not permissions or other keys).
205
- // Copying the full kit settings.json would duplicate permissions.deny entries when both
206
- // global (~/.claude/settings.json) and project (.claude/settings.json) are initialised.
207
- if (!existsSync(destPath)) {
208
- if (!dryRun) {
209
- const hooksOnly = kitSettings.hooks ? { hooks: kitSettings.hooks } : {};
210
- mkdirSync(targetDir, { recursive: true });
211
- writeFileSync(destPath, JSON.stringify(hooksOnly, null, 2) + '\n', 'utf-8');
212
- }
213
- return { action: 'created' };
214
- }
215
-
216
- // Existing user settings merge hooks only, preserve everything else
217
- let userSettings;
218
- try {
219
- userSettings = JSON.parse(readFileSync(destPath, 'utf-8'));
220
- } catch {
221
- // Malformed user settings skip to avoid data loss
222
- return { action: 'skipped' };
223
- }
224
-
225
- const merged = [];
226
-
227
- // Merge hooks: kit entries are added or replaced by matcher; user-only matchers preserved.
228
- // Replace strategy: when the kit ships an updated hook command for an existing matcher,
229
- // the old command is replaced instead of appended — preventing stale duplicates.
230
- if (kitSettings.hooks) {
231
- if (!userSettings.hooks) userSettings.hooks = {};
232
- for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
233
- if (!userSettings.hooks[event]) {
234
- userSettings.hooks[event] = kitEntries;
235
- merged.push(event);
236
- } else {
237
- for (const kitEntry of kitEntries) {
238
- const kitMatcher = kitEntry.matcher || '*';
239
- const idx = userSettings.hooks[event].findIndex(
240
- e => (e.matcher || '*') === kitMatcher
241
- );
242
- if (idx === -1) {
243
- // New matcher add it
244
- userSettings.hooks[event].push(kitEntry);
245
- merged.push(`${event}[${kitMatcher}]:added`);
246
- } else {
247
- // Existing matcher — replace with kit version if hooks differ
248
- const userEntry = userSettings.hooks[event][idx];
249
- const userCmds = (userEntry.hooks || []).map(h => h.command).sort();
250
- const kitCmds = (kitEntry.hooks || []).map(h => h.command).sort();
251
- const same = userCmds.length === kitCmds.length &&
252
- userCmds.every((c, i) => c === kitCmds[i]);
253
- if (!same) {
254
- userSettings.hooks[event][idx] = kitEntry;
255
- merged.push(`${event}[${kitMatcher}]:replaced`);
256
- }
257
- }
258
- }
259
- }
260
- }
261
- }
262
-
263
- if (merged.length === 0) return { action: 'skipped' };
264
-
265
- if (!dryRun) {
266
- const backup = backupFile(destPath);
267
- writeFileSync(destPath, JSON.stringify(userSettings, null, 2) + '\n', 'utf-8');
268
- return { action: 'merged', merged, backup };
269
- }
270
- return { action: 'merged', merged };
271
- }
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
+ }