@khanhcan148/mk 0.1.24 → 0.1.26

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/bin/mk.js CHANGED
@@ -27,6 +27,7 @@ program.command('update')
27
27
  .description('Update kit files to the latest version, preserving user modifications')
28
28
  .option('--force', 'Overwrite user-modified files without warning')
29
29
  .option('--global', 'Update global installation in ~/.claude/')
30
+ .option('-v, --verbose', 'Print per-file changes and routine warnings')
30
31
  .action((options) => updateAction(options));
31
32
 
32
33
  program.command('remove')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhcan148/mk",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "CLI to install and manage MyClaudeKit (.claude/) in your projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,6 +110,11 @@ try {
110
110
  process.exit(1);
111
111
  }
112
112
 
113
+ // Verbose flag: MK_VERBOSE=1 enables per-agent progress lines and IAC-1 warnings.
114
+ // When spawned by runCodexConversion, the parent passes MK_VERBOSE based on its
115
+ // verbose option so the child inherits the same quietness setting.
116
+ const VERBOSE = process.env.MK_VERBOSE === '1';
117
+
113
118
  // ---------------------------------------------------------------------------
114
119
  // IAC-1 patterns: Claude-only constructs to flag in agent bodies
115
120
  // ---------------------------------------------------------------------------
@@ -127,6 +132,7 @@ const IAC1_PATTERNS = [
127
132
  * @param {string} body Raw Markdown body (frontmatter stripped)
128
133
  */
129
134
  function checkIac1(agentName, body) {
135
+ if (!VERBOSE) return;
130
136
  const lines = body.split('\n');
131
137
  for (const { pattern, label } of IAC1_PATTERNS) {
132
138
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
@@ -341,10 +347,12 @@ for (const mdPath of mdFiles) {
341
347
  }
342
348
  }
343
349
 
344
- process.stdout.write(
345
- `[convert-agents-to-codex] Done. ${successCount} converted, ${errorCount} errors.\n`
346
- );
347
- process.stdout.write(`[convert-agents-to-codex] Output: ${outputDir}\n`);
350
+ if (VERBOSE) {
351
+ process.stdout.write(
352
+ `[convert-agents-to-codex] Done. ${successCount} converted, ${errorCount} errors.\n`
353
+ );
354
+ process.stdout.write(`[convert-agents-to-codex] Output: ${outputDir}\n`);
355
+ }
348
356
 
349
357
  if (errors.length > 0) {
350
358
  process.stderr.write(`[convert-agents-to-codex] Errors:\n${errors.join('\n')}\n`);
@@ -154,6 +154,9 @@ function buildToml() {
154
154
  export async function convertHooksToCodex(opts = {}) {
155
155
  const projectRoot = opts.projectRoot ? resolve(opts.projectRoot) : PACKAGE_ROOT;
156
156
  const outputDir = opts.outputDir ? resolve(opts.outputDir) : join(projectRoot, '.codex');
157
+ // verbose: caller passes explicit boolean; fallback to MK_VERBOSE env var for
158
+ // the case where this module is imported by a spawned child process.
159
+ const verbose = opts.verbose ?? process.env.MK_VERBOSE === '1';
157
160
 
158
161
  const warnings = [];
159
162
  const dropped = [];
@@ -163,7 +166,9 @@ export async function convertHooksToCodex(opts = {}) {
163
166
  const msg = `[convert-hooks-to-codex] WARNING: Dropping ${name} (${source} event has no Codex equivalent)`;
164
167
  warnings.push(msg);
165
168
  dropped.push(name);
166
- process.stderr.write(msg + '\n');
169
+ if (verbose) {
170
+ process.stderr.write(msg + '\n');
171
+ }
167
172
  }
168
173
 
169
174
  // Count emitted hooks
@@ -88,6 +88,11 @@ for (let i = 0; i < args.length; i++) {
88
88
 
89
89
  const modelMap = loadModelMap(modelMapPath);
90
90
 
91
+ // Verbose flag: MK_VERBOSE=1 enables per-skill progress lines.
92
+ // When spawned by runCodexConversion, the parent passes MK_VERBOSE based on its
93
+ // verbose option so the child inherits the same quietness setting.
94
+ const VERBOSE = process.env.MK_VERBOSE === '1';
95
+
91
96
  const kitRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
92
97
  const inputDir = inputDirArg || join(kitRoot, '.claude', 'skills');
93
98
  const outputDir = outputDirArg || join(kitRoot, '.codex', 'skills');
@@ -219,7 +224,9 @@ let errorCount = 0;
219
224
  const errors = [];
220
225
 
221
226
  function logInfo(msg) {
222
- process.stderr.write(`${LOG_PREFIX} ${msg}\n`);
227
+ if (VERBOSE) {
228
+ process.stderr.write(`${LOG_PREFIX} ${msg}\n`);
229
+ }
223
230
  }
224
231
 
225
232
  function logError(msg) {
@@ -336,10 +343,12 @@ for (const dirent of skillDirs) {
336
343
  }
337
344
  }
338
345
 
339
- process.stdout.write(
340
- `${LOG_PREFIX} Done. ${successCount} converted, ${errorCount} errors.\n`
341
- );
342
- process.stdout.write(`${LOG_PREFIX} Output: ${outputDir}\n`);
346
+ if (VERBOSE) {
347
+ process.stdout.write(
348
+ `${LOG_PREFIX} Done. ${successCount} converted, ${errorCount} errors.\n`
349
+ );
350
+ process.stdout.write(`${LOG_PREFIX} Output: ${outputDir}\n`);
351
+ }
343
352
 
344
353
  if (errors.length > 0) {
345
354
  process.stderr.write(`${LOG_PREFIX} Errors:\n${errors.join('\n')}\n`);
@@ -158,9 +158,18 @@ function isDirectory(p) {
158
158
  * path can never traverse outside its parent.
159
159
  * @param {string} [options.output] - Override for agents output dir
160
160
  * @param {string} [options.modelMap] - Path to custom model-map JSON
161
+ * @param {boolean} [options.verbose=true] - When false, suppresses the per-directory
162
+ * progress lines (`Converting`, `Mirroring`,
163
+ * `✓ codex conversion complete`) and the
164
+ * hook-drop warning. `console.error` calls
165
+ * stay loud unconditionally. Defaults to `true`
166
+ * to preserve direct `mk codex` ergonomics;
167
+ * `maybeSyncCodex` passes `false` so the codex
168
+ * sync inherits the host run's quietness.
161
169
  * @returns {Promise<{ exitCode: number, hooksResult?: object, errors: string[] }>}
162
170
  */
163
171
  export async function runCodexConversion(options = {}) {
172
+ const { verbose = true } = options;
164
173
  // S1: Reject `..` segments AFTER resolve to catch encoded traversal attempts
165
174
  // (e.g. "foo/../../../etc"). A full homedir-bound would break legitimate
166
175
  // `mk codex --cwd /tmp/…` invocations, so we use the lighter ..‑segment check.
@@ -235,7 +244,7 @@ export async function runCodexConversion(options = {}) {
235
244
  }
236
245
 
237
246
  // Emit progress lines upfront (before awaiting) so the user sees intent immediately.
238
- if (isDirectory(skillsDir)) {
247
+ if (verbose && isDirectory(skillsDir)) {
239
248
  console.log(chalk.cyan(`Converting ${skillsDir}`));
240
249
  console.log(chalk.cyan(` → ${skillsOut}`));
241
250
  }
@@ -244,8 +253,15 @@ export async function runCodexConversion(options = {}) {
244
253
  if (options.modelMap) {
245
254
  args.push('--model-map', resolve(options.modelMap));
246
255
  }
247
- console.log(chalk.cyan(`Converting ${agentsDir}`));
248
- console.log(chalk.cyan(` ${agentsOut}`));
256
+ if (verbose) {
257
+ console.log(chalk.cyan(`Converting ${agentsDir}`));
258
+ console.log(chalk.cyan(` → ${agentsOut}`));
259
+ }
260
+
261
+ // Propagate verbose flag to child converter processes via MK_VERBOSE env var.
262
+ // Child processes use { stdio: 'inherit' } so their stdout/stderr bypass the
263
+ // parent-level verbose gate — the env var is the only way to suppress them.
264
+ const childEnv = { ...process.env, MK_VERBOSE: verbose ? '1' : '0' };
249
265
 
250
266
  // Start both spawns concurrently.
251
267
  const skillsPromise = isDirectory(skillsDir)
@@ -253,7 +269,7 @@ export async function runCodexConversion(options = {}) {
253
269
  const child = spawn(
254
270
  process.execPath,
255
271
  [SKILLS_CONVERTER_SCRIPT, '--input', skillsDir, '--output', skillsOut],
256
- { stdio: 'inherit' }
272
+ { stdio: 'inherit', env: childEnv }
257
273
  );
258
274
  child.on('close', (exitCode) => resolveExit(exitCode ?? 1));
259
275
  child.on('error', (err) => {
@@ -264,7 +280,7 @@ export async function runCodexConversion(options = {}) {
264
280
  : Promise.resolve(0);
265
281
 
266
282
  const agentsPromise = new Promise((resolveExit) => {
267
- const child = spawn(process.execPath, args, { stdio: 'inherit' });
283
+ const child = spawn(process.execPath, args, { stdio: 'inherit', env: childEnv });
268
284
  child.on('close', (exitCode) => resolveExit(exitCode ?? 1));
269
285
  child.on('error', (err) => {
270
286
  console.error(chalk.red(`error: failed to spawn converter: ${err.message}`));
@@ -288,20 +304,24 @@ export async function runCodexConversion(options = {}) {
288
304
  // Uses mirrorDirWithRewrite so .claude/<subdir>/ refs in .md/.txt/.toml files
289
305
  // are rewritten to .codex/<subdir>/ for Codex-runtime resolution.
290
306
  if (isDirectory(workflowsDir)) {
291
- console.log(chalk.cyan(`Mirroring ${workflowsDir}`));
292
- console.log(chalk.cyan(`${workflowsOut}`));
307
+ if (verbose) {
308
+ console.log(chalk.cyan(`Mirroring ${workflowsDir}`));
309
+ console.log(chalk.cyan(` → ${workflowsOut}`));
310
+ }
293
311
  mirrorDirWithRewrite(workflowsDir, workflowsOut);
294
312
  }
295
313
 
296
- const hooksResult = await convertHooksToCodex({ projectRoot, outputDir: codexRoot });
297
- if (hooksResult.dropped.length > 0) {
314
+ const hooksResult = await convertHooksToCodex({ projectRoot, outputDir: codexRoot, verbose });
315
+ if (verbose && hooksResult.dropped.length > 0) {
298
316
  console.warn(
299
317
  chalk.yellow(
300
318
  `Warning: dropped ${hooksResult.dropped.length} hooks (SubagentStart/TeammateIdle/TaskCompleted have no Codex equivalent)`
301
319
  )
302
320
  );
303
321
  }
304
- console.log(chalk.green('✓ codex conversion complete'));
322
+ if (verbose) {
323
+ console.log(chalk.green('✓ codex conversion complete'));
324
+ }
305
325
  return { exitCode: 0, hooksResult, errors };
306
326
  }
307
327
 
@@ -116,15 +116,18 @@ export function hasCodexInstall(codexDir) {
116
116
  * Throws a tagged error (`.codexSyncFailure = true`) on conversion failure so
117
117
  * callers can distinguish a codex-only problem from a core update failure.
118
118
  *
119
- * @param {{ projectRoot: string, codexRunner: Function }} opts
119
+ * @param {{ projectRoot: string, codexRunner: Function, verbose?: boolean }} opts
120
120
  * @param {string} opts.projectRoot - Absolute path to the project root
121
121
  * @param {Function} opts.codexRunner - DI-injectable; defaults to runCodexConversion
122
+ * @param {boolean} [opts.verbose=false] - When true, emits the "Syncing .codex/ mirror…" heartbeat
122
123
  */
123
- export async function maybeSyncCodex({ projectRoot, codexRunner }) {
124
+ export async function maybeSyncCodex({ projectRoot, codexRunner, verbose = false }) {
124
125
  const codexDir = join(projectRoot, '.codex');
125
126
  if (!hasCodexInstall(codexDir)) return; // silent skip — no codex install detected
126
- process.stdout.write(chalk.cyan('Syncing .codex/ mirror…\n'));
127
- const result = await codexRunner({ cwd: projectRoot });
127
+ if (verbose) {
128
+ process.stdout.write(chalk.cyan('Syncing .codex/ mirror…\n'));
129
+ }
130
+ const result = await codexRunner({ cwd: projectRoot, verbose });
128
131
  if (result.exitCode !== 0) {
129
132
  const err = new Error(
130
133
  'Codex sync failed after .claude/ update completed. Retry manually: `mk codex`'
@@ -163,7 +166,8 @@ async function defaultPromptUser(question) {
163
166
  * targetDir?: string,
164
167
  * manifestPath?: string,
165
168
  * force?: boolean,
166
- * version?: string - Kit version to store in manifest (defaults to pkg.version when omitted)
169
+ * version?: string, - Kit version to store in manifest (defaults to pkg.version when omitted)
170
+ * verbose?: boolean - When true, threads verbose=true to copyKitFiles and mergeSettingsJson
167
171
  * }} params
168
172
  * @returns {Promise<{ updated: string[], added: string[], removed: string[], conflicts: string[], unchanged: string[], upToDate: boolean }>}
169
173
  */
@@ -173,7 +177,8 @@ export async function runUpdate(params = {}) {
173
177
  targetDir = resolveTargetDir({ global: false }),
174
178
  manifestPath = resolveManifestPath({ global: false }),
175
179
  force = false,
176
- version: explicitVersion
180
+ version: explicitVersion,
181
+ verbose = false
177
182
  } = params;
178
183
 
179
184
  // Read existing manifest
@@ -187,7 +192,7 @@ export async function runUpdate(params = {}) {
187
192
  // Derive project root from manifest scope (global: homedir, project: dirname(manifestPath))
188
193
  const projectRoot = deriveProjectRoot(manifest, manifestPath);
189
194
  const claudeRoot = resolve(join(projectRoot, '.claude'));
190
- const sourceFileList = copyKitFiles(sourceDir, targetDir, { dryRun: true });
195
+ const sourceFileList = copyKitFiles(sourceDir, targetDir, { dryRun: true, verbose });
191
196
  // Fix 11-12 (performance): Build a Map for O(1) lookups in applyCopy.
192
197
  // Previously sourceFileList.find() in applyCopy was O(n) per call, causing O(n²)
193
198
  // behaviour when many files need updating. The Map is built once in O(n).
@@ -241,7 +246,7 @@ export async function runUpdate(params = {}) {
241
246
  // Always merge settings.json hooks even when kit files are unchanged.
242
247
  // A user who installed before hooks were added (or whose settings.json was
243
248
  // edited/reset) would otherwise never receive hook updates via `mk update`.
244
- mergeSettingsJson(sourceDir, targetDir);
249
+ mergeSettingsJson(sourceDir, targetDir, { verbose });
245
250
  // Files are unchanged but we may still need to record the release version.
246
251
  // Without this, manifest.version stays at the old value and the next `mk update`
247
252
  // will always report "Update available" even though nothing changed on disk.
@@ -369,7 +374,7 @@ export async function runUpdate(params = {}) {
369
374
  }
370
375
 
371
376
  // Merge settings.json hooks — additive merge, never overwrites user keys
372
- mergeSettingsJson(sourceDir, targetDir);
377
+ mergeSettingsJson(sourceDir, targetDir, { verbose });
373
378
 
374
379
  // Update manifest with new file map.
375
380
  // Use explicitVersion when provided (e.g. release.version from updateAction);
@@ -393,7 +398,7 @@ export async function runUpdate(params = {}) {
393
398
  * and applies a three-way diff. Falls back to the main-branch tarball if no
394
399
  * release information is available.
395
400
  *
396
- * @param {{ force: boolean, global: boolean }} options
401
+ * @param {{ force: boolean, global: boolean, verbose?: boolean }} options
397
402
  * @param {object} [deps] - Injected dependencies (for testing)
398
403
  */
399
404
  export async function updateAction(options = {}, deps = {}) {
@@ -409,7 +414,8 @@ export async function updateAction(options = {}, deps = {}) {
409
414
  // Injectable for tests — allows overriding resolved paths without touching CWD
410
415
  manifestPath: injectedManifestPath,
411
416
  targetDir: injectedTargetDir,
412
- runCodexConversion: codexRunner = runCodexConversion
417
+ runCodexConversion: codexRunner = runCodexConversion,
418
+ runUpdate: runUpdateImpl = runUpdate
413
419
  } = deps;
414
420
 
415
421
  // Read local package version (used as fallback when manifest has no version)
@@ -425,6 +431,10 @@ export async function updateAction(options = {}, deps = {}) {
425
431
 
426
432
  let tempDir = null;
427
433
  try {
434
+ // MK_VERBOSE only honours the literal '1' to avoid CI surprises (e.g. 'true', 'false', '0').
435
+ // SECURITY: strict literal only — do not relax to truthy; coercion silently enables CI-break behaviour.
436
+ const verbose = options.verbose === true || process.env.MK_VERBOSE === '1';
437
+
428
438
  process.stdout.write('Authenticating with GitHub...\n');
429
439
  const token = await resolveAndLogin({
430
440
  readStored: readToken,
@@ -471,8 +481,9 @@ export async function updateAction(options = {}, deps = {}) {
471
481
  // The installed settings.json may be missing hooks if it was created
472
482
  // before hooks were added to the kit or if it was manually edited.
473
483
  // resolveSourceDir() points to the CLI package's own .claude/ — no download needed.
474
- mergeSettingsJson(resolveSourceDir(), targetDir);
475
- await maybeSyncCodex({ projectRoot, codexRunner });
484
+ // Note: maybeSyncCodex is intentionally NOT called here — running codex conversion
485
+ // on every no-op invocation wastes wall-clock time with no benefit (PR #132 reversal).
486
+ mergeSettingsJson(resolveSourceDir(), targetDir, { verbose });
476
487
  return;
477
488
  }
478
489
 
@@ -511,7 +522,7 @@ export async function updateAction(options = {}, deps = {}) {
511
522
  // Pass releaseVersion so runUpdate stores it in the manifest.
512
523
  // When falling back to main branch (no release), releaseVersion is undefined and
513
524
  // runUpdate falls back to pkg.version — which is the correct behaviour for that path.
514
- const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force, version: releaseVersion });
525
+ const result = await runUpdateImpl({ sourceDir, targetDir, manifestPath, force: options.force, version: releaseVersion, verbose });
515
526
 
516
527
  if (result.upToDate) {
517
528
  process.stdout.write(chalk.green('Already up to date.\n'));
@@ -524,26 +535,35 @@ export async function updateAction(options = {}, deps = {}) {
524
535
  }
525
536
  if (result.removed.length > 0) {
526
537
  process.stdout.write(chalk.yellow(`Removed: ${result.removed.length} files\n`));
527
- for (const f of result.removed) {
528
- process.stdout.write(` Removed: ${f}\n`);
538
+ if (verbose) {
539
+ for (const f of result.removed) {
540
+ process.stdout.write(` Removed: ${f}\n`);
541
+ }
529
542
  }
530
543
  }
531
544
  if (result.orphans && result.orphans.length > 0) {
532
545
  process.stdout.write(chalk.yellow(`Cleaned: ${result.orphans.length} orphan files\n`));
533
- for (const f of result.orphans) {
534
- process.stdout.write(` Cleaned: ${f}\n`);
546
+ if (verbose) {
547
+ for (const f of result.orphans) {
548
+ process.stdout.write(` Cleaned: ${f}\n`);
549
+ }
535
550
  }
536
551
  }
537
552
  if (result.conflicts.length > 0) {
538
553
  process.stdout.write(
539
554
  chalk.yellow(`Skipped: ${result.conflicts.length} user-modified files (use --force to overwrite)\n`)
540
555
  );
541
- for (const f of result.conflicts) {
542
- process.stdout.write(` ${f}\n`);
556
+ if (verbose) {
557
+ for (const f of result.conflicts) {
558
+ process.stdout.write(` ${f}\n`);
559
+ }
543
560
  }
544
561
  }
562
+ if (!verbose && (result.removed.length > 0 || (result.orphans?.length > 0) || result.conflicts.length > 0)) {
563
+ process.stdout.write(chalk.dim('(re-run with --verbose to see per-file changes)\n'));
564
+ }
545
565
  }
546
- await maybeSyncCodex({ projectRoot, codexRunner });
566
+ await maybeSyncCodex({ projectRoot, codexRunner, verbose });
547
567
  } catch (err) {
548
568
  // S3+S4: scrub tokens and absolute paths from error messages before surfacing
549
569
  let storedToken = null;
package/src/lib/copy.js CHANGED
@@ -95,14 +95,14 @@ export function collectDiskFiles(targetDir) {
95
95
  *
96
96
  * @param {string} sourceDir - Absolute path to source .claude/
97
97
  * @param {string} targetDir - Absolute path to target .claude/
98
- * @param {{ dryRun: boolean }} options
98
+ * @param {{ dryRun: boolean, verbose: boolean }} options
99
99
  * @returns {FileEntry[]}
100
100
  * @remarks Naming convention: `absolutePath` is the destination (under targetDir),
101
101
  * `sourceAbsPath` is the source (under sourceDir). The asymmetry is intentional —
102
102
  * renaming would break consumers (update.js). See DEBT-016.
103
103
  */
104
104
  export function copyKitFiles(sourceDir, targetDir, options = {}) {
105
- const { dryRun = false } = options;
105
+ const { dryRun = false, verbose = false } = options;
106
106
  const fileList = [];
107
107
  const warnings = [];
108
108
 
@@ -136,9 +136,12 @@ export function copyKitFiles(sourceDir, targetDir, options = {}) {
136
136
  }
137
137
  }
138
138
 
139
- // Print warnings
140
- for (const w of warnings) {
141
- process.stderr.write(w + '\n');
139
+ // Print warnings: always loud on real installs; suppressed only when doing a dry-run probe
140
+ // without verbose (the probe is internal and operators never see it).
141
+ if (!dryRun || verbose) {
142
+ for (const w of warnings) {
143
+ process.stderr.write(w + '\n');
144
+ }
142
145
  }
143
146
 
144
147
  if (dryRun) {
@@ -199,18 +202,21 @@ function isInSibling(siblingHooks, event, matcher) {
199
202
 
200
203
  /**
201
204
  * Filter kitEntries removing any whose event+matcher already exists in siblingHooks.
202
- * Emits a stderr info message per skipped entry.
205
+ * Emits a stderr info message per skipped entry (suppressed when verbose=false).
203
206
  * @param {object[]} kitEntries - Hook entries from kit source
204
207
  * @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
205
208
  * @param {string} event - Hook event name
209
+ * @param {boolean} [verbose] - When false, suppresses the skipped-hook stderr message
206
210
  * @returns {object[]} Entries not present in sibling
207
211
  */
208
- function dedupeAgainstSibling(kitEntries, siblingHooks, event) {
212
+ function dedupeAgainstSibling(kitEntries, siblingHooks, event, verbose = false) {
209
213
  if (!siblingHooks?.[event]) return kitEntries;
210
214
  return kitEntries.filter(ke => {
211
215
  const m = ke.matcher || '*';
212
216
  if (isInSibling(siblingHooks, event, m)) {
213
- process.stderr.write(`mk: hook ${event}[${m}] skipped (exists in settings.local.json)\n`);
217
+ if (verbose) {
218
+ process.stderr.write(`mk: hook ${event}[${m}] skipped (exists in settings.local.json)\n`);
219
+ }
214
220
  return false;
215
221
  }
216
222
  return true;
@@ -229,11 +235,11 @@ function dedupeAgainstSibling(kitEntries, siblingHooks, event) {
229
235
  *
230
236
  * @param {string} sourceDir - Absolute path to source .claude/
231
237
  * @param {string} targetDir - Absolute path to target .claude/
232
- * @param {{ dryRun: boolean }} options
238
+ * @param {{ dryRun: boolean, verbose: boolean }} options
233
239
  * @returns {{ action: 'created'|'merged'|'skipped', merged?: string[], backup?: string }}
234
240
  */
235
241
  export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
236
- const { dryRun = false } = options;
242
+ const { dryRun = false, verbose = false } = options;
237
243
  const srcPath = join(sourceDir, 'settings.json');
238
244
  const destPath = join(targetDir, 'settings.json');
239
245
 
@@ -266,7 +272,7 @@ export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
266
272
  const hooksOnly = {};
267
273
  if (kitSettings.hooks) {
268
274
  for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
269
- const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
275
+ const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event, verbose);
270
276
  if (deduped.length > 0) hooksOnly[event] = deduped;
271
277
  }
272
278
  }
@@ -295,7 +301,7 @@ export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
295
301
  if (!userSettings.hooks) userSettings.hooks = {};
296
302
  for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
297
303
  if (!userSettings.hooks[event]) {
298
- const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
304
+ const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event, verbose);
299
305
  if (deduped.length > 0) {
300
306
  userSettings.hooks[event] = deduped;
301
307
  merged.push(event);
@@ -305,7 +311,9 @@ export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
305
311
  const kitMatcher = kitEntry.matcher || '*';
306
312
  // Dedup guard: skip if sibling already has this event+matcher
307
313
  if (isInSibling(siblingHooks, event, kitMatcher)) {
308
- process.stderr.write(`mk: hook ${event}[${kitMatcher}] skipped (exists in settings.local.json)\n`);
314
+ if (verbose) {
315
+ process.stderr.write(`mk: hook ${event}[${kitMatcher}] skipped (exists in settings.local.json)\n`);
316
+ }
309
317
  continue;
310
318
  }
311
319
  const idx = userSettings.hooks[event].findIndex(