@khanhcan148/mk 0.1.22 → 0.1.25

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.22",
3
+ "version": "0.1.25",
4
4
  "description": "CLI to install and manage MyClaudeKit (.claude/) in your projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,15 +22,14 @@
22
22
  "codex:convert-and-diff": "node scripts/codex-diff-check.js"
23
23
  },
24
24
  "dependencies": {
25
+ "@iarna/toml": "3.0.0",
25
26
  "chalk": "^5.0.0",
26
27
  "commander": "^12.0.0",
27
28
  "fs-extra": "^11.0.0",
29
+ "js-yaml": "4.1.0",
28
30
  "semver": "^7.0.0"
29
31
  },
30
- "devDependencies": {
31
- "@iarna/toml": "3.0.0",
32
- "js-yaml": "4.1.0"
33
- },
32
+ "devDependencies": {},
34
33
  "keywords": [
35
34
  "claude",
36
35
  "claude-code",
@@ -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,10 @@ 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
+ }
249
260
 
250
261
  // Start both spawns concurrently.
251
262
  const skillsPromise = isDirectory(skillsDir)
@@ -288,20 +299,24 @@ export async function runCodexConversion(options = {}) {
288
299
  // Uses mirrorDirWithRewrite so .claude/<subdir>/ refs in .md/.txt/.toml files
289
300
  // are rewritten to .codex/<subdir>/ for Codex-runtime resolution.
290
301
  if (isDirectory(workflowsDir)) {
291
- console.log(chalk.cyan(`Mirroring ${workflowsDir}`));
292
- console.log(chalk.cyan(`${workflowsOut}`));
302
+ if (verbose) {
303
+ console.log(chalk.cyan(`Mirroring ${workflowsDir}`));
304
+ console.log(chalk.cyan(` → ${workflowsOut}`));
305
+ }
293
306
  mirrorDirWithRewrite(workflowsDir, workflowsOut);
294
307
  }
295
308
 
296
309
  const hooksResult = await convertHooksToCodex({ projectRoot, outputDir: codexRoot });
297
- if (hooksResult.dropped.length > 0) {
310
+ if (verbose && hooksResult.dropped.length > 0) {
298
311
  console.warn(
299
312
  chalk.yellow(
300
313
  `Warning: dropped ${hooksResult.dropped.length} hooks (SubagentStart/TeammateIdle/TaskCompleted have no Codex equivalent)`
301
314
  )
302
315
  );
303
316
  }
304
- console.log(chalk.green('✓ codex conversion complete'));
317
+ if (verbose) {
318
+ console.log(chalk.green('✓ codex conversion complete'));
319
+ }
305
320
  return { exitCode: 0, hooksResult, errors };
306
321
  }
307
322
 
@@ -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(