@klhapp/skillmux 0.6.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -7,7 +7,12 @@ import { createInterface } from "node:readline/promises";
7
7
  import { generateDataset } from "./dataset-generator";
8
8
 
9
9
  import { createClients } from "./clients";
10
- import { expandHome, loadConfig, migrateLegacyPaths, resolveConfigPath } from "./config";
10
+ import {
11
+ expandHome,
12
+ loadConfig,
13
+ migrateLegacyPaths,
14
+ resolveConfigPath,
15
+ } from "./config";
11
16
  import { openIndex } from "./db";
12
17
  import { diagnose } from "./doctor";
13
18
  import { evalVault } from "./eval";
@@ -57,9 +62,20 @@ import {
57
62
  } from "./manifest";
58
63
  import { downloadLocalModels } from "./models";
59
64
  import { resolveProjectDirectory, suggestProjectName } from "./project-setup";
60
- import { parseCommaList, promptMultiSelect, promptText, shouldUseWizard } from "./prompts";
65
+ import {
66
+ parseCommaList,
67
+ promptMultiSelect,
68
+ promptText,
69
+ shouldUseWizard,
70
+ } from "./prompts";
61
71
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
62
- import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
72
+ import {
73
+ renderScanJson,
74
+ renderScanText,
75
+ scanExitCode,
76
+ scanPath,
77
+ type ScanSeverity,
78
+ } from "./scan";
63
79
  import {
64
80
  applyConfigInit,
65
81
  inspectVault,
@@ -87,7 +103,15 @@ import {
87
103
  type ResolvedTarget,
88
104
  } from "./context";
89
105
  import { createTargetAdapter, type TargetAdapter } from "./adapters";
90
- import { formatJsonEnvelope, isInteractive, mapExitCode, renderTable, renderTargetBanner, suggestCorrection } from "./output";
106
+ import {
107
+ emitSuccess,
108
+ formatJsonEnvelope,
109
+ isInteractive,
110
+ mapExitCode,
111
+ renderTable,
112
+ renderTargetBanner,
113
+ suggestCorrection,
114
+ } from "./output";
91
115
  import { generateCompletions, type ShellType } from "./completions";
92
116
 
93
117
  const KNOWN_COMMANDS = [
@@ -101,14 +125,14 @@ const KNOWN_COMMANDS = [
101
125
  "init",
102
126
  "project",
103
127
  "target",
128
+ "core",
104
129
  "report",
105
130
  "scan",
106
131
  "install",
107
132
  "eval",
108
133
  "doctor",
109
134
  "models",
110
- "which",
111
- "manifest",
135
+ "skill",
112
136
  "local-vault",
113
137
  ];
114
138
 
@@ -143,9 +167,17 @@ async function main() {
143
167
 
144
168
  // Only resolve target if command is target-aware or context/config/calibrate
145
169
  const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
146
- if ((["context", "config", "calibrate"].includes(command) && !isLocalConfigInit) || flagContext || flagServer) {
170
+ if (
171
+ (["context", "config", "calibrate"].includes(command) &&
172
+ !isLocalConfigInit) ||
173
+ flagContext ||
174
+ flagServer
175
+ ) {
147
176
  try {
148
- resolvedTarget = await resolveTarget({ context: flagContext, server: flagServer });
177
+ resolvedTarget = await resolveTarget({
178
+ context: flagContext,
179
+ server: flagServer,
180
+ });
149
181
  } catch (err: any) {
150
182
  handleError(err, { target: resolvedTarget, isJson, isVerbose });
151
183
  return;
@@ -159,13 +191,23 @@ async function main() {
159
191
  try {
160
192
  switch (command) {
161
193
  case "context":
162
- await handleContextCommand(subCommand, commandArgs, { target: resolvedTarget, isJson });
194
+ await handleContextCommand(subCommand, commandArgs, {
195
+ target: resolvedTarget,
196
+ isJson,
197
+ });
163
198
  break;
164
199
  case "config":
165
- await handleConfigCommand(adapter, subCommand, commandArgs, { target: resolvedTarget, isJson, dryRun: isDryRun });
200
+ await handleConfigCommand(adapter, subCommand, commandArgs, {
201
+ target: resolvedTarget,
202
+ isJson,
203
+ dryRun: isDryRun,
204
+ });
166
205
  break;
167
206
  case "calibrate":
168
- await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), { target: resolvedTarget, isJson });
207
+ await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), {
208
+ target: resolvedTarget,
209
+ isJson,
210
+ });
169
211
  break;
170
212
  case "completions":
171
213
  await handleCompletionsCommand(subCommand);
@@ -207,40 +249,50 @@ async function main() {
207
249
  case "target":
208
250
  await runTarget(subCommand, commandArgs, { isJson, dryRun: isDryRun });
209
251
  break;
252
+ case "core":
253
+ await runCore(subCommand, commandArgs, { isJson, dryRun: isDryRun });
254
+ break;
210
255
  case "report":
211
- await runReport(rawArgv.slice(1));
256
+ await runReport(rawArgv.slice(1), { isJson });
212
257
  break;
213
258
  case "scan":
214
- await runScan(rawArgv.slice(1));
259
+ await runScan(rawArgv.slice(1), { isJson });
215
260
  break;
216
261
  case "install":
217
- await runInstall(rawArgv.slice(1));
262
+ await runInstall(rawArgv.slice(1), { isJson });
218
263
  break;
219
264
  case "eval":
220
- await runEval();
265
+ await runEval({ isJson });
221
266
  break;
222
267
  case "doctor":
223
- await runDoctor();
268
+ await runDoctor({ isJson });
224
269
  break;
225
270
  case "which":
226
- await runWhich(rawArgv.slice(1));
271
+ throw new Error(
272
+ `skillmux which is removed - use "skillmux skill which ${subCommand || "<skill_id>"}" instead`,
273
+ );
274
+ case "skill":
275
+ await runSkill(subCommand, commandArgs);
227
276
  break;
228
277
  case "manifest":
229
- await runManifest(subCommand, commandArgs);
230
- break;
278
+ throw new Error(
279
+ `skillmux manifest is removed - use "skillmux core ${subCommand || "pin|unpin"}" for [core] skills, or "skillmux project ${subCommand || "pin|unpin"} <group>" for [project.*] skills`,
280
+ );
231
281
  case "local-vault":
232
- if (subCommand !== "init") throw new Error("usage: skillmux local-vault init <path>");
233
- await runLocalVaultInit(commandArgs);
282
+ if (subCommand !== "init")
283
+ throw new Error("usage: skillmux local-vault init <path>");
284
+ await runLocalVaultInit(commandArgs, { isJson, dryRun: isDryRun });
234
285
  break;
235
286
  case "models":
236
- if (subCommand !== "download") throw new Error("usage: skillmux models download");
237
- await runModelDownload();
287
+ if (subCommand !== "download")
288
+ throw new Error("usage: skillmux models download");
289
+ await runModelDownload({ isJson });
238
290
  break;
239
291
  default: {
240
292
  const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
241
293
  const msg = suggestion
242
294
  ? `Unknown command "${command}". Did you mean "${suggestion}"?`
243
- : `usage: skillmux <serve|index|sync|init|project|target|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset>`;
295
+ : `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|scan|install|eval|doctor|skill which|local-vault init|config show|models download|calibrate generate-dataset>`;
244
296
  throw new Error(msg);
245
297
  }
246
298
  }
@@ -252,13 +304,11 @@ async function main() {
252
304
  async function handleContextCommand(
253
305
  sub: string,
254
306
  args: string[],
255
- ctx: { target: ResolvedTarget; isJson: boolean }
307
+ ctx: { target: ResolvedTarget; isJson: boolean },
256
308
  ) {
257
309
  if (sub === "list") {
258
310
  const contexts = await listContexts();
259
- if (ctx.isJson) {
260
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: contexts })));
261
- } else {
311
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, contexts, () => {
262
312
  renderTargetBanner(ctx.target);
263
313
  renderTable(
264
314
  [
@@ -267,20 +317,22 @@ async function handleContextCommand(
267
317
  { key: "token_env", header: "TOKEN_ENV" },
268
318
  { key: "isDefault", header: "DEFAULT" },
269
319
  ],
270
- contexts.map((c) => ({ ...c, token_env: c.token_env ?? "-", isDefault: c.isDefault ? "*" : "" }))
320
+ contexts.map((c) => ({
321
+ ...c,
322
+ token_env: c.token_env ?? "-",
323
+ isDefault: c.isDefault ? "*" : "",
324
+ })),
271
325
  );
272
- }
326
+ });
273
327
  return;
274
328
  }
275
329
 
276
330
  if (sub === "current") {
277
331
  const current = await getCurrentContext();
278
- if (ctx.isJson) {
279
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: current })));
280
- } else {
332
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, current, () => {
281
333
  renderTargetBanner(ctx.target);
282
334
  console.log(`Current context: ${current.name} (${current.server})`);
283
- }
335
+ });
284
336
  return;
285
337
  }
286
338
 
@@ -293,14 +345,18 @@ async function handleContextCommand(
293
345
  else if (args[i] === "--token-env") tokenEnv = args[++i];
294
346
  }
295
347
  if (!name || !server) {
296
- throw new Error("usage: skillmux context add <name> --server <url> [--token-env <env_name>]");
348
+ throw new Error(
349
+ "usage: skillmux context add <name> --server <url> [--token-env <env_name>]",
350
+ );
297
351
  }
298
352
  await addContext(name, { server, token_env: tokenEnv });
299
- if (ctx.isJson) {
300
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { name, server, token_env: tokenEnv } })));
301
- } else {
302
- console.log(`Added context "${name}" -> ${server}`);
303
- }
353
+ emitSuccess(
354
+ { isJson: ctx.isJson, target: ctx.target },
355
+ { name, server, token_env: tokenEnv },
356
+ () => {
357
+ console.log(`Added context "${name}" -> ${server}`);
358
+ },
359
+ );
304
360
  return;
305
361
  }
306
362
 
@@ -308,11 +364,13 @@ async function handleContextCommand(
308
364
  const name = args[0];
309
365
  if (!name) throw new Error("usage: skillmux context use <name>");
310
366
  await useContext(name);
311
- if (ctx.isJson) {
312
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { default_context: name } })));
313
- } else {
314
- console.log(`Switched default context to "${name}"`);
315
- }
367
+ emitSuccess(
368
+ { isJson: ctx.isJson, target: ctx.target },
369
+ { default_context: name },
370
+ () => {
371
+ console.log(`Switched default context to "${name}"`);
372
+ },
373
+ );
316
374
  return;
317
375
  }
318
376
 
@@ -320,22 +378,56 @@ async function handleContextCommand(
320
378
  const name = args[0];
321
379
  if (!name) throw new Error("usage: skillmux context remove <name>");
322
380
  await removeContext(name);
323
- if (ctx.isJson) {
324
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { removed: name } })));
325
- } else {
326
- console.log(`Removed context "${name}"`);
327
- }
381
+ emitSuccess(
382
+ { isJson: ctx.isJson, target: ctx.target },
383
+ { removed: name },
384
+ () => {
385
+ console.log(`Removed context "${name}"`);
386
+ },
387
+ );
328
388
  return;
329
389
  }
330
390
 
331
391
  throw new Error("usage: skillmux context <add|list|current|use|remove>");
332
392
  }
333
393
 
394
+ function emitConfigInitOutcome(
395
+ ctx: { isJson: boolean },
396
+ opts: {
397
+ phase: "plan" | "result";
398
+ dryRun: boolean;
399
+ applied: boolean;
400
+ plan: ConfigInitPlan;
401
+ action: "create" | "preserve";
402
+ text: string;
403
+ },
404
+ ): void {
405
+ if (ctx.isJson) {
406
+ console.log(
407
+ JSON.stringify({
408
+ schema_version: 1,
409
+ ok: true,
410
+ command: "config init",
411
+ phase: opts.phase,
412
+ dry_run: opts.dryRun,
413
+ applied: opts.applied,
414
+ plan: {
415
+ config_path: opts.plan.configPath,
416
+ vault_path: opts.plan.vaultPath,
417
+ action: opts.action,
418
+ },
419
+ }),
420
+ );
421
+ return;
422
+ }
423
+ console.log(opts.text);
424
+ }
425
+
334
426
  async function handleConfigCommand(
335
427
  adapter: TargetAdapter,
336
428
  sub: string,
337
429
  args: string[],
338
- ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean }
430
+ ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
339
431
  ) {
340
432
  if (sub === "init") {
341
433
  let vaultPath: string | undefined;
@@ -344,7 +436,8 @@ async function handleConfigCommand(
344
436
  const option = args[i];
345
437
  if (option === "--vault") {
346
438
  vaultPath = args[++i];
347
- if (!vaultPath) throw new Error("usage: skillmux config init --vault <path> --yes");
439
+ if (!vaultPath)
440
+ throw new Error("usage: skillmux config init --vault <path> --yes");
348
441
  } else if (option === "--yes") {
349
442
  yes = true;
350
443
  } else if (option === "--dry-run" || option === "--json") {
@@ -364,69 +457,65 @@ async function handleConfigCommand(
364
457
  migrateLegacyPaths();
365
458
  const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
366
459
  if (plan.action === "preserve") {
367
- console.log(ctx.isJson
368
- ? JSON.stringify({
369
- schema_version: 1,
370
- ok: true,
371
- command: "config init",
372
- phase: "result",
373
- dry_run: ctx.dryRun,
374
- applied: false,
375
- plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "preserve" },
376
- })
377
- : `preserved existing config: ${plan.configPath}`);
460
+ emitConfigInitOutcome(ctx, {
461
+ phase: "result",
462
+ dryRun: ctx.dryRun,
463
+ applied: false,
464
+ plan,
465
+ action: "preserve",
466
+ text: `preserved existing config: ${plan.configPath}`,
467
+ });
378
468
  return;
379
469
  }
380
470
  if (ctx.dryRun) {
381
- console.log(ctx.isJson
382
- ? JSON.stringify({
383
- schema_version: 1,
384
- ok: true,
385
- command: "config init",
386
- phase: "plan",
387
- dry_run: true,
388
- applied: false,
389
- plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "create" },
390
- })
391
- : `config create: ${plan.configPath} (dry-run)`);
471
+ emitConfigInitOutcome(ctx, {
472
+ phase: "plan",
473
+ dryRun: true,
474
+ applied: false,
475
+ plan,
476
+ action: "create",
477
+ text: `config create: ${plan.configPath} (dry-run)`,
478
+ });
392
479
  return;
393
480
  }
394
481
  if (!yes) {
395
482
  if (!ctx.isJson && isInteractive()) {
396
- if (!(await confirmAction(`Create ${plan.configPath} with vault_path ${plan.vaultPath}?`))) {
483
+ if (
484
+ !(await confirmAction(
485
+ `Create ${plan.configPath} with vault_path ${plan.vaultPath}?`,
486
+ ))
487
+ ) {
397
488
  console.log("config init cancelled; nothing written");
398
489
  return;
399
490
  }
400
491
  } else {
401
- throw new Error("config initialization requires --yes in noninteractive mode");
492
+ throw new Error(
493
+ "config initialization requires --yes in noninteractive mode",
494
+ );
402
495
  }
403
496
  }
404
497
 
405
498
  const result = applyConfigInit(plan);
406
- console.log(ctx.isJson
407
- ? JSON.stringify({
408
- schema_version: 1,
409
- ok: true,
410
- command: "config init",
411
- phase: "result",
412
- dry_run: false,
413
- applied: result === "created",
414
- plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: plan.action },
415
- })
416
- : result === "created"
417
- ? `created ${plan.configPath}`
418
- : `preserved existing config: ${plan.configPath}`);
499
+ emitConfigInitOutcome(ctx, {
500
+ phase: "result",
501
+ dryRun: false,
502
+ applied: result === "created",
503
+ plan,
504
+ action: plan.action,
505
+ text:
506
+ result === "created"
507
+ ? `created ${plan.configPath}`
508
+ : `preserved existing config: ${plan.configPath}`,
509
+ });
419
510
  return;
420
511
  }
421
512
 
422
513
  if (sub === "show") {
423
514
  const data = await adapter.getConfigShow();
424
- if (ctx.isJson) {
425
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data })));
426
- } else {
515
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
427
516
  renderTargetBanner(ctx.target);
428
517
  console.log(JSON.stringify(data.effective, null, 2));
429
- }
518
+ });
430
519
  return;
431
520
  }
432
521
 
@@ -434,32 +523,34 @@ async function handleConfigCommand(
434
523
  const key = args[0];
435
524
  if (!key) throw new Error("usage: skillmux config get <key>");
436
525
  const val = await adapter.getConfigGet(key);
437
- if (ctx.isJson) {
438
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { key, value: val } })));
439
- } else {
440
- console.log(typeof val === "object" ? JSON.stringify(val) : String(val));
441
- }
526
+ emitSuccess(
527
+ { isJson: ctx.isJson, target: ctx.target },
528
+ { key, value: val },
529
+ () => {
530
+ console.log(
531
+ typeof val === "object" ? JSON.stringify(val) : String(val),
532
+ );
533
+ },
534
+ );
442
535
  return;
443
536
  }
444
537
 
445
538
  if (sub === "validate") {
446
539
  const res = await adapter.configValidate();
447
- if (ctx.isJson) {
448
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
449
- } else {
450
- console.log(res.valid ? "Configuration is valid." : "Configuration is invalid.");
451
- }
540
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
541
+ console.log(
542
+ res.valid ? "Configuration is valid." : "Configuration is invalid.",
543
+ );
544
+ });
452
545
  return;
453
546
  }
454
547
 
455
548
  if (sub === "diff") {
456
549
  const res = await adapter.configDiff();
457
- if (ctx.isJson) {
458
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
459
- } else {
550
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
460
551
  renderTargetBanner(ctx.target);
461
552
  console.log(JSON.stringify(res.diff, null, 2));
462
- }
553
+ });
463
554
  return;
464
555
  }
465
556
 
@@ -470,27 +561,27 @@ async function handleConfigCommand(
470
561
  throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
471
562
  }
472
563
  const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
473
- if (ctx.isJson) {
474
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
475
- } else {
564
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
476
565
  renderTargetBanner(ctx.target);
477
566
  const prefix = ctx.dryRun ? "[dry-run] " : "";
478
- console.log(`${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`);
479
- console.log(`Persistence: ${res.persistence}, Application: ${res.application}`);
480
- }
567
+ console.log(
568
+ `${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`,
569
+ );
570
+ console.log(
571
+ `Persistence: ${res.persistence}, Application: ${res.application}`,
572
+ );
573
+ });
481
574
  return;
482
575
  }
483
576
 
484
577
  if (sub === "status") {
485
578
  const res = await adapter.configStatus();
486
- if (ctx.isJson) {
487
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
488
- } else {
579
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
489
580
  renderTargetBanner(ctx.target);
490
581
  console.log(`Runtime: ${res.runtime}`);
491
582
  console.log(`Active revision: ${res.active_revision}`);
492
583
  console.log(`Readiness: ${res.readiness.status}`);
493
- }
584
+ });
494
585
  return;
495
586
  }
496
587
 
@@ -498,9 +589,14 @@ async function handleConfigCommand(
498
589
  }
499
590
 
500
591
  async function confirmAction(prompt: string): Promise<boolean> {
501
- const readline = createInterface({ input: process.stdin, output: process.stdout });
592
+ const readline = createInterface({
593
+ input: process.stdin,
594
+ output: process.stdout,
595
+ });
502
596
  try {
503
- const answer = (await readline.question(`${prompt} [y/N] `)).trim().toLowerCase();
597
+ const answer = (await readline.question(`${prompt} [y/N] `))
598
+ .trim()
599
+ .toLowerCase();
504
600
  return answer === "y" || answer === "yes";
505
601
  } finally {
506
602
  readline.close();
@@ -511,7 +607,7 @@ async function handleCalibrateCommand(
511
607
  adapter: TargetAdapter,
512
608
  sub: string,
513
609
  args: string[],
514
- ctx: { target: ResolvedTarget; isJson: boolean }
610
+ ctx: { target: ResolvedTarget; isJson: boolean },
515
611
  ) {
516
612
  if (sub === "run") {
517
613
  let datasetPath: string | undefined;
@@ -519,21 +615,17 @@ async function handleCalibrateCommand(
519
615
  if (args[i] === "--dataset") datasetPath = args[++i];
520
616
  }
521
617
  const res = await adapter.calibrateRun({ datasetPath });
522
- if (ctx.isJson) {
523
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
524
- } else {
618
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
525
619
  renderTargetBanner(ctx.target);
526
620
  console.log(`Calibration run complete.`);
527
621
  if (res.result) console.log(JSON.stringify(res.result, null, 2));
528
- }
622
+ });
529
623
  return;
530
624
  }
531
625
 
532
626
  if (sub === "list") {
533
627
  const res = await adapter.calibrateList();
534
- if (ctx.isJson) {
535
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
536
- } else {
628
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
537
629
  renderTargetBanner(ctx.target);
538
630
  renderTable(
539
631
  [
@@ -541,9 +633,9 @@ async function handleCalibrateCommand(
541
633
  { key: "created_at", header: "CREATED_AT" },
542
634
  { key: "status", header: "STATUS" },
543
635
  ],
544
- res
636
+ res,
545
637
  );
546
- }
638
+ });
547
639
  return;
548
640
  }
549
641
 
@@ -551,12 +643,10 @@ async function handleCalibrateCommand(
551
643
  const runId = args[1];
552
644
  if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
553
645
  const res = await adapter.calibrateShow(runId);
554
- if (ctx.isJson) {
555
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
556
- } else {
646
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
557
647
  renderTargetBanner(ctx.target);
558
648
  console.log(JSON.stringify(res, null, 2));
559
- }
649
+ });
560
650
  return;
561
651
  }
562
652
 
@@ -564,12 +654,10 @@ async function handleCalibrateCommand(
564
654
  const runId = args[1];
565
655
  if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
566
656
  const res = await adapter.calibrateApply(runId);
567
- if (ctx.isJson) {
568
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
569
- } else {
657
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
570
658
  renderTargetBanner(ctx.target);
571
659
  console.log(`Applied calibration run "${runId}"`);
572
- }
660
+ });
573
661
  return;
574
662
  }
575
663
 
@@ -578,7 +666,9 @@ async function handleCalibrateCommand(
578
666
  return;
579
667
  }
580
668
 
581
- throw new Error("usage: skillmux calibrate generate-dataset [--vault <path>] [--out <file>]");
669
+ throw new Error(
670
+ "usage: skillmux calibrate generate-dataset [--vault <path>] [--out <file>]",
671
+ );
582
672
  }
583
673
 
584
674
  async function handleCompletionsCommand(shell: string) {
@@ -590,7 +680,7 @@ async function handleCompletionsCommand(shell: string) {
590
680
 
591
681
  function handleError(
592
682
  err: any,
593
- opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean }
683
+ opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean },
594
684
  ) {
595
685
  const code = mapExitCode(err);
596
686
  process.exitCode = code;
@@ -605,7 +695,13 @@ function handleError(
605
695
  });
606
696
  console.log(JSON.stringify(env));
607
697
  } else {
608
- console.error(msg.startsWith("usage:") || msg.startsWith("Unknown") || msg.startsWith("error:") ? msg : `error: ${msg}`);
698
+ console.error(
699
+ msg.startsWith("usage:") ||
700
+ msg.startsWith("Unknown") ||
701
+ msg.startsWith("error:")
702
+ ? msg
703
+ : `error: ${msg}`,
704
+ );
609
705
  if (opts.isVerbose && err instanceof Error && err.stack) {
610
706
  console.error(err.stack);
611
707
  }
@@ -617,7 +713,7 @@ function printHelp(): void {
617
713
 
618
714
  Setup:
619
715
  skillmux config init --vault <path> --yes
620
- skillmux init [--client <name>...] [--target <name>...] [--path <dir>]
716
+ skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
621
717
  [--vault <path>] [--core <skill_id>...]
622
718
  [--migrate-full-vault] [--no-instructions] [--no-sync]
623
719
  [--interactive|--yes|--dry-run] [--json]
@@ -626,6 +722,8 @@ Setup:
626
722
  [--interactive|--yes|--dry-run] [--json]
627
723
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
628
724
  skillmux target <list|show|add|remove>
725
+ skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
726
+ skillmux skill which <skill_id>
629
727
 
630
728
  Init clients:
631
729
  claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
@@ -635,8 +733,8 @@ Init targets:
635
733
  agent-skills, claude-code, codex, custom
636
734
 
637
735
  Commands:
638
- serve, index, sync, init, project, target, report, scan, install, eval, doctor, which,
639
- manifest, local-vault, config, models, calibrate, context, completions`);
736
+ serve, index, sync, init, project, target, core, report, scan, install, eval, doctor, skill,
737
+ local-vault, config, models, calibrate, context, completions`);
640
738
  }
641
739
 
642
740
  // ---------------------------------------------------------------------------
@@ -645,7 +743,10 @@ Commands:
645
743
 
646
744
  type Transport = "stdio" | "http";
647
745
 
648
- function parseServeArgs(args: string[]): { transport: Transport; port?: number } {
746
+ function parseServeArgs(args: string[]): {
747
+ transport: Transport;
748
+ port?: number;
749
+ } {
649
750
  let transport: Transport = "stdio";
650
751
  let port: number | undefined;
651
752
  for (let i = 0; i < args.length; i++) {
@@ -675,7 +776,9 @@ async function runIndex(): Promise<void> {
675
776
  const config = await loadConfig();
676
777
  configure({ config, clients: createClients(config) });
677
778
  const report = await rebuildIndex((skillId, error) => {
678
- console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
779
+ console.error(
780
+ `warning: keeping previous index entry for ${skillId}: ${error}`,
781
+ );
679
782
  });
680
783
  const retainedNote =
681
784
  report.retained.length > 0
@@ -687,43 +790,56 @@ async function runIndex(): Promise<void> {
687
790
  const backfilled = await backfillEmbeddings();
688
791
  console.log(`embeddings: ${backfilled} backfilled`);
689
792
  } catch {
690
- console.log("embeddings: skipped (endpoint unreachable; lexical-only recall until next index)");
793
+ console.log(
794
+ "embeddings: skipped (endpoint unreachable; lexical-only recall until next index)",
795
+ );
691
796
  }
692
797
  }
693
798
 
694
- async function runEval(): Promise<void> {
799
+ async function runEval(options: { isJson: boolean }): Promise<void> {
695
800
  const config = await loadConfig();
696
801
  configure({ config, clients: createClients(config) });
697
802
 
698
803
  const report = await evalVault().catch((error: unknown) => {
699
804
  throw new Error(`eval requires local embeddings: ${String(error)}`);
700
805
  });
701
- console.log(`holdout queries: ${report.queries}`);
702
- console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
703
- console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
704
- console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
705
- console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
706
- console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
707
- console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
806
+ emitSuccess({ isJson: options.isJson }, report, () => {
807
+ console.log(`holdout queries: ${report.queries}`);
808
+ console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
809
+ console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
810
+ console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
811
+ console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
812
+ console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
813
+ console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
814
+ });
708
815
  }
709
816
 
710
- async function runDoctor(): Promise<void> {
817
+ async function runDoctor(options: { isJson: boolean }): Promise<void> {
711
818
  const report = await diagnose(await loadConfig());
712
- console.log(`inference mode: ${report.mode}`);
713
- console.log(`routing capability: ${report.capability}`);
714
- for (const check of report.checks)
715
- console.log(`${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`);
819
+ emitSuccess({ isJson: options.isJson }, report, () => {
820
+ console.log(`inference mode: ${report.mode}`);
821
+ console.log(`routing capability: ${report.capability}`);
822
+ for (const check of report.checks)
823
+ console.log(
824
+ `${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
825
+ );
826
+ });
716
827
  if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
717
828
  }
718
829
 
830
+ async function runSkill(subCommand: string, args: string[]): Promise<void> {
831
+ if (subCommand !== "which") throw new Error("usage: skillmux skill <which>");
832
+ await runWhich(args);
833
+ }
834
+
719
835
  async function runWhich(args: string[]): Promise<void> {
720
836
  const skillId = args[0];
721
- if (!skillId) throw new Error("usage: skillmux which <skill_id>");
837
+ if (!skillId) throw new Error("usage: skillmux skill which <skill_id>");
722
838
  const config = await loadConfig();
723
839
  const vaultPath = expandHome(config.vault_path);
724
840
  const localVaultPaths = config.local_vault_paths.map(expandHome);
725
- const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter((root) =>
726
- existsSync(join(root, skillId, "SKILL.md")),
841
+ const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
842
+ (root) => existsSync(join(root, skillId, "SKILL.md")),
727
843
  );
728
844
  if (roots.length === 0) {
729
845
  console.log(`${skillId}: not found in vault_path or local_vault_paths`);
@@ -731,10 +847,10 @@ async function runWhich(args: string[]): Promise<void> {
731
847
  return;
732
848
  }
733
849
  console.log(`${skillId}: serving from ${roots[0]}`);
734
- for (const shadowedRoot of roots.slice(1)) console.log(` shadows: ${shadowedRoot}`);
850
+ for (const shadowedRoot of roots.slice(1))
851
+ console.log(` shadows: ${shadowedRoot}`);
735
852
  }
736
853
 
737
- const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
738
854
  const PROJECT_INIT_USAGE =
739
855
  "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
740
856
 
@@ -802,7 +918,11 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
802
918
  yes = true;
803
919
  } else if (arg === "--no-sync") {
804
920
  sync = false;
805
- } else if (arg === "--dry-run" || arg === "--json" || arg === "--interactive") {
921
+ } else if (
922
+ arg === "--dry-run" ||
923
+ arg === "--json" ||
924
+ arg === "--interactive"
925
+ ) {
806
926
  continue;
807
927
  } else if (arg.startsWith("-")) {
808
928
  throw new Error(`unknown project init option: ${arg}`);
@@ -813,8 +933,44 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
813
933
  }
814
934
  }
815
935
 
816
- const path = resolveProjectDirectory(projectPath ? expandHome(projectPath) : undefined);
817
- return { path, name: name ?? suggestProjectName(basename(path)), skills, clients, targets, yes, sync };
936
+ const path = resolveProjectDirectory(
937
+ projectPath ? expandHome(projectPath) : undefined,
938
+ );
939
+ return {
940
+ path,
941
+ name: name ?? suggestProjectName(basename(path)),
942
+ skills,
943
+ clients,
944
+ targets,
945
+ yes,
946
+ sync,
947
+ };
948
+ }
949
+
950
+ async function loadManifestContext() {
951
+ const config = await loadConfig();
952
+ const vaultPath = expandHome(config.vault_path);
953
+ const manifestPath = resolveManifestPath(vaultPath);
954
+ if (!manifestPath) {
955
+ throw new Error(
956
+ `no skillmux.toml found at ${vaultPath}; run skillmux init first`,
957
+ );
958
+ }
959
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
960
+ return { config, vaultPath, manifestPath, manifest };
961
+ }
962
+
963
+ async function confirmIfNeeded(opts: {
964
+ confirmed: boolean;
965
+ isJson: boolean;
966
+ prompt: string;
967
+ nonInteractiveError: string;
968
+ }): Promise<boolean> {
969
+ if (opts.confirmed) return true;
970
+ if (opts.isJson || !isInteractive()) {
971
+ throw new Error(opts.nonInteractiveError);
972
+ }
973
+ return confirmAction(opts.prompt);
818
974
  }
819
975
 
820
976
  async function runProject(
@@ -823,14 +979,11 @@ async function runProject(
823
979
  options: { isJson: boolean; dryRun: boolean },
824
980
  ): Promise<void> {
825
981
  if (subCommand === "list" || subCommand === "show") {
826
- const config = await loadConfig();
827
- const vaultPath = expandHome(config.vault_path);
828
- const manifestPath = resolveManifestPath(vaultPath);
829
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
830
- const manifest = parseManifest(await Bun.file(manifestPath).text());
831
- const names = subCommand === "show"
832
- ? [args[0] ?? ""]
833
- : Object.keys(manifest.project ?? {});
982
+ const { manifest } = await loadManifestContext();
983
+ const names =
984
+ subCommand === "show"
985
+ ? [args[0] ?? ""]
986
+ : Object.keys(manifest.project ?? {});
834
987
  if (subCommand === "show" && !manifest.project?.[names[0]!]) {
835
988
  throw new Error(`[project.${names[0]}] does not exist`);
836
989
  }
@@ -858,33 +1011,43 @@ async function runProject(
858
1011
  }
859
1012
  if (subCommand === "add-path" || subCommand === "remove-path") {
860
1013
  const group = args[0];
861
- if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> [path] --yes`);
1014
+ if (!group)
1015
+ throw new Error(
1016
+ `usage: skillmux project ${subCommand} <group> [path] --yes`,
1017
+ );
862
1018
  const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
863
- const projectPath = resolveProjectDirectory(rawPath ? expandHome(rawPath) : undefined);
1019
+ const projectPath = resolveProjectDirectory(
1020
+ rawPath ? expandHome(rawPath) : undefined,
1021
+ );
864
1022
  const yes = args.includes("--yes");
865
1023
  if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
866
1024
  throw new Error(`project path is not a directory: ${projectPath}`);
867
1025
  }
868
- const config = await loadConfig();
869
- const vaultPath = expandHome(config.vault_path);
870
- const manifestPath = resolveManifestPath(vaultPath);
871
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
872
- const manifest = parseManifest(await Bun.file(manifestPath).text());
1026
+ const { config, vaultPath, manifestPath, manifest } =
1027
+ await loadManifestContext();
873
1028
  const updated = updateProjectPaths(manifest, group, {
874
- ...(subCommand === "add-path" ? { add: [projectPath] } : { remove: [projectPath] }),
1029
+ ...(subCommand === "add-path"
1030
+ ? { add: [projectPath] }
1031
+ : { remove: [projectPath] }),
875
1032
  });
876
- validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
1033
+ validateManifest(
1034
+ updated,
1035
+ vaultPath,
1036
+ config.local_vault_paths.map(expandHome),
1037
+ );
877
1038
  if (options.dryRun) {
878
1039
  console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
879
1040
  return;
880
1041
  }
881
- if (!yes) {
882
- if (!options.isJson && isInteractive()) {
883
- if (!(await confirmAction(`${subCommand} ${projectPath} in [project.${group}]?`))) return;
884
- } else {
885
- throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
886
- }
887
- }
1042
+ if (
1043
+ !(await confirmIfNeeded({
1044
+ confirmed: yes,
1045
+ isJson: options.isJson,
1046
+ prompt: `${subCommand} ${projectPath} in [project.${group}]?`,
1047
+ nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
1048
+ }))
1049
+ )
1050
+ return;
888
1051
  writeManifestAtomic(manifestPath, updated);
889
1052
  console.log(`${subCommand}: [project.${group}] ${projectPath}`);
890
1053
  return;
@@ -893,38 +1056,50 @@ async function runProject(
893
1056
  const group = args[0];
894
1057
  const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
895
1058
  if (!group || skills.length === 0) {
896
- throw new Error(`usage: skillmux project ${subCommand} <group> <skill_id>... --yes`);
1059
+ throw new Error(
1060
+ `usage: skillmux project ${subCommand} <group> <skill_id>... --yes`,
1061
+ );
897
1062
  }
898
1063
  const yes = args.includes("--yes");
899
- const config = await loadConfig();
900
- const vaultPath = expandHome(config.vault_path);
901
- const manifestPath = resolveManifestPath(vaultPath);
902
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
903
- let updated = parseManifest(await Bun.file(manifestPath).text());
1064
+ const { config, vaultPath, manifestPath, manifest } =
1065
+ await loadManifestContext();
1066
+ let updated = manifest;
904
1067
  for (const skill of skills) {
905
- updated = subCommand === "pin"
906
- ? pinProject(updated, skill, group)
907
- : unpinProject(updated, skill, group);
1068
+ updated =
1069
+ subCommand === "pin"
1070
+ ? pinProject(updated, skill, group)
1071
+ : unpinProject(updated, skill, group);
908
1072
  }
909
- validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
1073
+ validateManifest(
1074
+ updated,
1075
+ vaultPath,
1076
+ config.local_vault_paths.map(expandHome),
1077
+ );
910
1078
  if (options.dryRun) {
911
- console.log(`${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`);
1079
+ console.log(
1080
+ `${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
1081
+ );
912
1082
  return;
913
1083
  }
914
- if (!yes) {
915
- if (!options.isJson && isInteractive()) {
916
- if (!(await confirmAction(`${subCommand} ${skills.join(", ")} in [project.${group}]?`))) return;
917
- } else {
918
- throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
919
- }
920
- }
1084
+ if (
1085
+ !(await confirmIfNeeded({
1086
+ confirmed: yes,
1087
+ isJson: options.isJson,
1088
+ prompt: `${subCommand} ${skills.join(", ")} in [project.${group}]?`,
1089
+ nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
1090
+ }))
1091
+ )
1092
+ return;
921
1093
  writeManifestAtomic(manifestPath, updated);
922
1094
  console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
923
1095
  return;
924
1096
  }
925
1097
  if (subCommand === "attach" || subCommand === "detach") {
926
1098
  const group = args[0];
927
- if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`);
1099
+ if (!group)
1100
+ throw new Error(
1101
+ `usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
1102
+ );
928
1103
  const clients: string[] = [];
929
1104
  const requestedTargets: string[] = [];
930
1105
  for (let i = 1; i < args.length; i++) {
@@ -936,15 +1111,16 @@ async function runProject(
936
1111
  const value = args[++i];
937
1112
  if (!value) throw new Error("--target requires a name");
938
1113
  requestedTargets.push(value);
939
- } else if (args[i] !== "--yes" && args[i] !== "--dry-run" && args[i] !== "--json") {
1114
+ } else if (
1115
+ args[i] !== "--yes" &&
1116
+ args[i] !== "--dry-run" &&
1117
+ args[i] !== "--json"
1118
+ ) {
940
1119
  throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
941
1120
  }
942
1121
  }
943
- const config = await loadConfig();
944
- const vaultPath = expandHome(config.vault_path);
945
- const manifestPath = resolveManifestPath(vaultPath);
946
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
947
- const manifest = parseManifest(await Bun.file(manifestPath).text());
1122
+ const { config, vaultPath, manifestPath, manifest } =
1123
+ await loadManifestContext();
948
1124
  const clientTargets = configuredTargetsForClients(manifest, clients);
949
1125
  const targets = [...new Set([...requestedTargets, ...clientTargets])];
950
1126
  if (targets.length === 0) {
@@ -953,18 +1129,26 @@ async function runProject(
953
1129
  const updated = updateProjectTargets(manifest, group, {
954
1130
  ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
955
1131
  });
956
- validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
1132
+ validateManifest(
1133
+ updated,
1134
+ vaultPath,
1135
+ config.local_vault_paths.map(expandHome),
1136
+ );
957
1137
  if (options.dryRun) {
958
- console.log(`${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`);
1138
+ console.log(
1139
+ `${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
1140
+ );
959
1141
  return;
960
1142
  }
961
- if (!args.includes("--yes")) {
962
- if (!options.isJson && isInteractive()) {
963
- if (!(await confirmAction(`${subCommand} [project.${group}] to ${targets.join(", ")}?`))) return;
964
- } else {
965
- throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
966
- }
967
- }
1143
+ if (
1144
+ !(await confirmIfNeeded({
1145
+ confirmed: args.includes("--yes"),
1146
+ isJson: options.isJson,
1147
+ prompt: `${subCommand} [project.${group}] to ${targets.join(", ")}?`,
1148
+ nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
1149
+ }))
1150
+ )
1151
+ return;
968
1152
  writeManifestAtomic(manifestPath, updated);
969
1153
  console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
970
1154
  return;
@@ -976,33 +1160,38 @@ async function runProject(
976
1160
  json: options.isJson,
977
1161
  dryRun: options.dryRun,
978
1162
  });
979
- if (!existsSync(request.path)) throw new Error(`project path does not exist: ${request.path}`);
1163
+ if (!existsSync(request.path))
1164
+ throw new Error(`project path does not exist: ${request.path}`);
980
1165
  if (!lstatSync(request.path).isDirectory()) {
981
1166
  throw new Error(`project path is not a directory: ${request.path}`);
982
1167
  }
983
1168
 
984
- const config = await loadConfig();
985
- const vaultPath = expandHome(config.vault_path);
1169
+ const { config, vaultPath, manifestPath, manifest } =
1170
+ await loadManifestContext();
986
1171
  const localVaultPaths = config.local_vault_paths.map(expandHome);
987
- const manifestPath = resolveManifestPath(vaultPath);
988
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
989
- const manifest = parseManifest(await Bun.file(manifestPath).text());
990
1172
  if (guided) {
991
1173
  const name = await promptText("Project group", request.name);
992
1174
  const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
993
1175
  const surface = planClientSurfaces([client]).surfaces[0];
994
- return surface !== undefined && configuredTargetForSurface(manifest, surface) !== undefined;
1176
+ return (
1177
+ surface !== undefined &&
1178
+ configuredTargetForSurface(manifest, surface) !== undefined
1179
+ );
995
1180
  });
996
1181
  const clients = await promptMultiSelect(
997
1182
  "Which clients should receive project skills?",
998
1183
  availableClients.map((client) => ({
999
1184
  value: client,
1000
1185
  label: client,
1001
- selected: request.clients.length === 0 || request.clients.includes(client),
1186
+ selected:
1187
+ request.clients.length === 0 || request.clients.includes(client),
1002
1188
  })),
1003
1189
  );
1004
1190
  const skills = parseCommaList(
1005
- await promptText("Project skill IDs, comma-separated", request.skills.join(",")),
1191
+ await promptText(
1192
+ "Project skill IDs, comma-separated",
1193
+ request.skills.join(","),
1194
+ ),
1006
1195
  );
1007
1196
  request = { ...request, name, clients, skills };
1008
1197
  }
@@ -1027,7 +1216,11 @@ async function runProject(
1027
1216
  };
1028
1217
 
1029
1218
  if (options.dryRun) {
1030
- console.log(options.isJson ? JSON.stringify({ schema_version: 1, plan }) : `project plan: ${JSON.stringify(plan)}`);
1219
+ console.log(
1220
+ options.isJson
1221
+ ? JSON.stringify({ schema_version: 1, plan })
1222
+ : `project plan: ${JSON.stringify(plan)}`,
1223
+ );
1031
1224
  return;
1032
1225
  }
1033
1226
  if (!request.yes) {
@@ -1040,12 +1233,18 @@ async function runProject(
1040
1233
  console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
1041
1234
  console.log(` sync: ${request.sync ? "yes" : "no"}`);
1042
1235
  }
1043
- if (!(await confirmAction(`Apply project setup for ${request.name} at ${request.path}?`))) {
1236
+ if (
1237
+ !(await confirmAction(
1238
+ `Apply project setup for ${request.name} at ${request.path}?`,
1239
+ ))
1240
+ ) {
1044
1241
  console.log("project setup cancelled");
1045
1242
  return;
1046
1243
  }
1047
1244
  } else {
1048
- throw new Error("skillmux project init requires --yes when run non-interactively");
1245
+ throw new Error(
1246
+ "skillmux project init requires --yes when run non-interactively",
1247
+ );
1049
1248
  }
1050
1249
  }
1051
1250
 
@@ -1068,19 +1267,65 @@ async function runProject(
1068
1267
  }
1069
1268
  }
1070
1269
 
1270
+ async function runCore(
1271
+ subCommand: string,
1272
+ args: string[],
1273
+ options: { isJson: boolean; dryRun: boolean },
1274
+ ): Promise<void> {
1275
+ if (subCommand !== "pin" && subCommand !== "unpin") {
1276
+ throw new Error("usage: skillmux core <pin|unpin>");
1277
+ }
1278
+ const skillIds = args.filter((arg) => !arg.startsWith("-"));
1279
+ if (skillIds.length === 0) {
1280
+ throw new Error(`usage: skillmux core ${subCommand} <skill_id>... --yes`);
1281
+ }
1282
+ const yes = args.includes("--yes");
1283
+ const { config, vaultPath, manifestPath, manifest } =
1284
+ await loadManifestContext();
1285
+ let updated = manifest;
1286
+ for (const skillId of skillIds) {
1287
+ updated =
1288
+ subCommand === "pin"
1289
+ ? pinCore(updated, skillId)
1290
+ : unpinCore(updated, skillId);
1291
+ }
1292
+ validateManifest(
1293
+ updated,
1294
+ vaultPath,
1295
+ config.local_vault_paths.map(expandHome),
1296
+ );
1297
+ if (options.dryRun) {
1298
+ emitSuccess(
1299
+ { isJson: options.isJson },
1300
+ { subcommand: subCommand, skill_ids: skillIds },
1301
+ () =>
1302
+ console.log(`${subCommand}: [core] ${skillIds.join(", ")} (dry-run)`),
1303
+ );
1304
+ return;
1305
+ }
1306
+ if (
1307
+ !(await confirmIfNeeded({
1308
+ confirmed: yes,
1309
+ isJson: options.isJson,
1310
+ prompt: `${subCommand} ${skillIds.join(", ")} in [core]?`,
1311
+ nonInteractiveError: `skillmux core ${subCommand} requires --yes when run non-interactively`,
1312
+ }))
1313
+ )
1314
+ return;
1315
+ writeManifestAtomic(manifestPath, updated);
1316
+ console.log(`${subCommand}: [core] ${skillIds.join(", ")}`);
1317
+ }
1318
+
1071
1319
  async function runTarget(
1072
1320
  subCommand: string,
1073
1321
  args: string[],
1074
1322
  options: { isJson: boolean; dryRun: boolean },
1075
1323
  ): Promise<void> {
1076
- const config = await loadConfig();
1077
- const vaultPath = expandHome(config.vault_path);
1078
- const manifestPath = resolveManifestPath(vaultPath);
1079
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
1080
- const manifest = parseManifest(await Bun.file(manifestPath).text());
1324
+ const { vaultPath, manifestPath, manifest } = await loadManifestContext();
1081
1325
 
1082
1326
  if (subCommand === "list" || subCommand === "show") {
1083
- const names = subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
1327
+ const names =
1328
+ subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
1084
1329
  if (subCommand === "show" && !manifest.targets[names[0]!]) {
1085
1330
  throw new Error(`target "${names[0]}" does not exist`);
1086
1331
  }
@@ -1102,7 +1347,9 @@ async function runTarget(
1102
1347
  console.log(` dir: ${target.dir}`);
1103
1348
  console.log(` host: ${target.host ?? "(global)"}`);
1104
1349
  console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
1105
- console.log(` projects: ${target.project_groups.join(", ") || "(none)"}`);
1350
+ console.log(
1351
+ ` projects: ${target.project_groups.join(", ") || "(none)"}`,
1352
+ );
1106
1353
  }
1107
1354
  }
1108
1355
  return;
@@ -1110,24 +1357,30 @@ async function runTarget(
1110
1357
 
1111
1358
  if (subCommand === "add") {
1112
1359
  const name = args[0];
1113
- const pathIndex = args.indexOf("--path");
1114
- const rawPath = pathIndex === -1 ? undefined : args[pathIndex + 1];
1115
- if (!name || !rawPath) throw new Error("usage: skillmux target add <name> --path <dir> --yes");
1360
+ const dirIndex = args.indexOf("--dir");
1361
+ const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
1362
+ if (!name || !rawPath)
1363
+ throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
1116
1364
  const path = expandHome(rawPath);
1117
1365
  if (options.dryRun) {
1118
1366
  const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
1119
- console.log(options.isJson
1120
- ? JSON.stringify({ schema_version: 1, target: planned.targets[name] })
1121
- : `target add: ${name} -> ${path} (dry-run)`);
1367
+ console.log(
1368
+ options.isJson
1369
+ ? JSON.stringify({ schema_version: 1, target: planned.targets[name] })
1370
+ : `target add: ${name} -> ${path} (dry-run)`,
1371
+ );
1122
1372
  return;
1123
1373
  }
1124
- if (!args.includes("--yes")) {
1125
- if (!options.isJson && isInteractive()) {
1126
- if (!(await confirmAction(`Adopt target ${name} at ${path}?`))) return;
1127
- } else {
1128
- throw new Error("skillmux target add requires --yes when run non-interactively");
1129
- }
1130
- }
1374
+ if (
1375
+ !(await confirmIfNeeded({
1376
+ confirmed: args.includes("--yes"),
1377
+ isJson: options.isJson,
1378
+ prompt: `Adopt target ${name} at ${path}?`,
1379
+ nonInteractiveError:
1380
+ "skillmux target add requires --yes when run non-interactively",
1381
+ }))
1382
+ )
1383
+ return;
1131
1384
  applyInit(vaultPath, [{ name, dir: path }]);
1132
1385
  console.log(`target "${name}" added at ${path}`);
1133
1386
  return;
@@ -1136,93 +1389,97 @@ async function runTarget(
1136
1389
  if (subCommand === "remove") {
1137
1390
  const name = args[0];
1138
1391
  if (!name || !manifest.targets[name]) {
1139
- throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target remove <name> --yes");
1392
+ throw new Error(
1393
+ name
1394
+ ? `target "${name}" does not exist`
1395
+ : "usage: skillmux target remove <name> --yes",
1396
+ );
1140
1397
  }
1141
1398
  if (options.dryRun) {
1142
1399
  console.log(`target remove: ${name} (files preserved, dry-run)`);
1143
1400
  return;
1144
1401
  }
1145
- if (!args.includes("--yes")) {
1146
- if (!options.isJson && isInteractive()) {
1147
- if (!(await confirmAction(`Remove target ${name} from the manifest and preserve its files?`))) return;
1148
- } else {
1149
- throw new Error("skillmux target remove requires --yes when run non-interactively");
1150
- }
1151
- }
1402
+ if (
1403
+ !(await confirmIfNeeded({
1404
+ confirmed: args.includes("--yes"),
1405
+ isJson: options.isJson,
1406
+ prompt: `Remove target ${name} from the manifest and preserve its files?`,
1407
+ nonInteractiveError:
1408
+ "skillmux target remove requires --yes when run non-interactively",
1409
+ }))
1410
+ )
1411
+ return;
1152
1412
  const targets = { ...manifest.targets };
1153
1413
  delete targets[name];
1154
1414
  writeManifestAtomic(manifestPath, { ...manifest, targets });
1155
- console.log(`target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`);
1415
+ console.log(
1416
+ `target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
1417
+ );
1156
1418
  return;
1157
1419
  }
1158
1420
 
1159
1421
  throw new Error("usage: skillmux target <list|show|add|remove>");
1160
1422
  }
1161
1423
 
1162
- function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; paths: string[] } {
1163
- const skillId = args[0];
1164
- if (!skillId) throw new Error(MANIFEST_USAGE);
1165
- let core = false;
1166
- let project: string | undefined;
1167
- const paths: string[] = [];
1168
- for (let i = 1; i < args.length; i++) {
1169
- const arg = args[i];
1170
- if (arg === "--core") core = true;
1171
- else if (arg === "--project") {
1172
- const value = args[++i];
1173
- if (!value) throw new Error("--project requires a group name");
1174
- project = value;
1175
- } else if (arg === "--path") {
1176
- const value = args[++i];
1177
- if (!value) throw new Error("--path requires a path");
1178
- paths.push(value);
1179
- } else throw new Error(`unknown manifest option: ${arg}`);
1180
- }
1181
- if (core === (project !== undefined)) throw new Error(MANIFEST_USAGE);
1182
- return { skillId, core, project, paths };
1183
- }
1184
-
1185
- async function runManifest(subCommand: string, args: string[]): Promise<void> {
1186
- if (subCommand !== "pin" && subCommand !== "unpin") throw new Error(MANIFEST_USAGE);
1187
- const { skillId, core, project, paths } = parseManifestPinArgs(args);
1188
- const config = await loadConfig();
1189
- const vaultPath = expandHome(config.vault_path);
1190
- const localVaultPaths = config.local_vault_paths.map(expandHome);
1191
- const manifestPath = resolveManifestPath(vaultPath);
1192
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}`);
1193
- const manifest = parseManifest(await Bun.file(manifestPath).text());
1194
-
1195
- let updated;
1196
- if (core) {
1197
- updated = subCommand === "pin" ? pinCore(manifest, skillId) : unpinCore(manifest, skillId);
1198
- } else {
1199
- updated =
1200
- subCommand === "pin"
1201
- ? pinProject(manifest, skillId, project!, paths)
1202
- : unpinProject(manifest, skillId, project!);
1203
- }
1204
- validateManifest(updated, vaultPath, localVaultPaths);
1205
- await Bun.write(manifestPath, serializeManifest(updated));
1206
- console.log(`${subCommand === "pin" ? "pinned" : "unpinned"} "${skillId}" ${core ? "[core]" : `[project.${project}]`}`);
1207
- }
1208
-
1209
- async function runLocalVaultInit(args: string[]): Promise<void> {
1424
+ async function runLocalVaultInit(
1425
+ args: string[],
1426
+ options: { isJson: boolean; dryRun: boolean },
1427
+ ): Promise<void> {
1210
1428
  const path = args[0];
1211
- if (!path) throw new Error("usage: skillmux local-vault init <path>");
1429
+ if (!path) throw new Error("usage: skillmux local-vault init <path> --yes");
1212
1430
  const expanded = expandHome(path);
1213
1431
  const config = await loadConfig();
1214
1432
  const localVaultPaths = config.local_vault_paths.map(expandHome);
1215
1433
  if (!localVaultPaths.includes(expanded)) {
1216
- throw new Error(`"${path}" is not one of the configured local_vault_paths — add it to config.toml first`);
1434
+ throw new Error(
1435
+ `"${path}" is not one of the configured local_vault_paths — add it to config.toml first`,
1436
+ );
1217
1437
  }
1218
1438
  if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
1439
+ const markerPath = join(expanded, ".skillmux");
1440
+ if (options.dryRun) {
1441
+ console.log(
1442
+ options.isJson
1443
+ ? JSON.stringify({
1444
+ schema_version: 1,
1445
+ marker_path: markerPath,
1446
+ vault_path: expandHome(config.vault_path),
1447
+ })
1448
+ : `local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
1449
+ );
1450
+ return;
1451
+ }
1452
+ if (
1453
+ !(await confirmIfNeeded({
1454
+ confirmed: args.includes("--yes"),
1455
+ isJson: options.isJson,
1456
+ prompt: `Mark ${expanded} as a local_vault (role: local_vault, vault_path: ${expandHome(config.vault_path)})?`,
1457
+ nonInteractiveError:
1458
+ "skillmux local-vault init requires --yes when run non-interactively",
1459
+ }))
1460
+ )
1461
+ return;
1219
1462
  writeLocalVaultMarker(expanded, expandHome(config.vault_path));
1220
- console.log(`wrote ${join(expanded, ".skillmux")} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`);
1463
+ if (options.isJson) {
1464
+ console.log(
1465
+ JSON.stringify({
1466
+ schema_version: 1,
1467
+ marker_path: markerPath,
1468
+ vault_path: expandHome(config.vault_path),
1469
+ }),
1470
+ );
1471
+ } else {
1472
+ console.log(
1473
+ `wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
1474
+ );
1475
+ }
1221
1476
  }
1222
1477
 
1223
- async function runModelDownload(): Promise<void> {
1478
+ async function runModelDownload(options: { isJson: boolean }): Promise<void> {
1224
1479
  const cacheDir = await downloadLocalModels(await loadConfig());
1225
- console.log(`models ready in ${cacheDir}`);
1480
+ emitSuccess({ isJson: options.isJson }, { cache_dir: cacheDir }, () =>
1481
+ console.log(`models ready in ${cacheDir}`),
1482
+ );
1226
1483
  }
1227
1484
 
1228
1485
  function parseSyncArgs(args: string[]): {
@@ -1289,10 +1546,18 @@ async function runSync(args: string[]): Promise<void> {
1289
1546
 
1290
1547
  const suffix = dryRun ? " (dry-run)" : "";
1291
1548
  const result = syncTarget(
1292
- { vaultPath, targetDir, targetName, coreSkillIds: manifest.core.skills, localVaultPaths },
1549
+ {
1550
+ vaultPath,
1551
+ targetDir,
1552
+ targetName,
1553
+ coreSkillIds: manifest.core.skills,
1554
+ localVaultPaths,
1555
+ },
1293
1556
  { dryRun },
1294
1557
  );
1295
- console.log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
1558
+ console.log(
1559
+ `${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
1560
+ );
1296
1561
 
1297
1562
  if (target.project_groups.length > 0) {
1298
1563
  const allGroups = manifest.project ?? {};
@@ -1349,9 +1614,9 @@ function parseInitArgs(args: string[]): {
1349
1614
  if (!value) throw new Error("--vault requires a path");
1350
1615
  vaultPath = value;
1351
1616
  i++;
1352
- } else if (option === "--path") {
1617
+ } else if (option === "--dir") {
1353
1618
  const value = args[i + 1];
1354
- if (!value) throw new Error("--path requires a directory");
1619
+ if (!value) throw new Error("--dir requires a directory");
1355
1620
  customPath = value;
1356
1621
  i++;
1357
1622
  } else if (option === "--core") {
@@ -1359,7 +1624,11 @@ function parseInitArgs(args: string[]): {
1359
1624
  if (!value) throw new Error("--core requires a skill_id");
1360
1625
  coreSkillIds.push(value);
1361
1626
  i++;
1362
- } else if (option === "--dry-run" || option === "--json" || option === "--interactive") {
1627
+ } else if (
1628
+ option === "--dry-run" ||
1629
+ option === "--json" ||
1630
+ option === "--interactive"
1631
+ ) {
1363
1632
  continue;
1364
1633
  } else if (option === "--migrate-full-vault") {
1365
1634
  migrateFullVault = true;
@@ -1411,10 +1680,13 @@ async function runInit(
1411
1680
  let configPlan: ConfigInitPlan | undefined;
1412
1681
  let vaultPath: string;
1413
1682
  if (!existsSync(configPath)) {
1414
- const bootstrapVaultPath = requestedVaultPath ??
1683
+ const bootstrapVaultPath =
1684
+ requestedVaultPath ??
1415
1685
  (!options.isJson && isInteractive() ? "~/skills" : undefined);
1416
1686
  if (!bootstrapVaultPath) {
1417
- throw new Error(`machine config does not exist: ${configPath}; re-run with --vault <path>`);
1687
+ throw new Error(
1688
+ `machine config does not exist: ${configPath}; re-run with --vault <path>`,
1689
+ );
1418
1690
  }
1419
1691
  configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
1420
1692
  vaultPath = configPlan.vaultPath;
@@ -1439,15 +1711,21 @@ async function runInit(
1439
1711
  let selectedClients = requestedClients;
1440
1712
  if (guided) {
1441
1713
  const detected = detectInstalledClients({
1442
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1714
+ codexHome: process.env.CODEX_HOME
1715
+ ? expandHome(process.env.CODEX_HOME)
1716
+ : undefined,
1443
1717
  });
1444
- const evidence = new Map(detected.map((item) => [item.client, item.evidence]));
1718
+ const evidence = new Map(
1719
+ detected.map((item) => [item.client, item.evidence]),
1720
+ );
1445
1721
  selectedClients = await promptMultiSelect(
1446
1722
  "Which clients do you use?",
1447
1723
  SUPPORTED_CLIENT_IDS.map((client) => ({
1448
1724
  value: client,
1449
1725
  label: client,
1450
- detail: evidence.has(client) ? `detected: ${evidence.get(client)}` : undefined,
1726
+ detail: evidence.has(client)
1727
+ ? `detected: ${evidence.get(client)}`
1728
+ : undefined,
1451
1729
  selected: evidence.has(client) || requestedClients.includes(client),
1452
1730
  })),
1453
1731
  );
@@ -1455,16 +1733,26 @@ async function runInit(
1455
1733
  let selectedCoreSkillIds = coreSkillIds;
1456
1734
  if (guided) {
1457
1735
  selectedCoreSkillIds = parseCommaList(
1458
- await promptText("Core skill IDs to add, comma-separated", coreSkillIds.join(",")),
1736
+ await promptText(
1737
+ "Core skill IDs to add, comma-separated",
1738
+ coreSkillIds.join(","),
1739
+ ),
1459
1740
  );
1460
1741
  }
1461
1742
 
1462
1743
  const clientPlan = planClientSurfaces(selectedClients, {
1463
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1464
- });
1465
- const instructionPlan = planInstructionSetup(skipInstructions ? [] : clientPlan.clients.map((client) => client.id), {
1466
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1744
+ codexHome: process.env.CODEX_HOME
1745
+ ? expandHome(process.env.CODEX_HOME)
1746
+ : undefined,
1467
1747
  });
1748
+ const instructionPlan = planInstructionSetup(
1749
+ skipInstructions ? [] : clientPlan.clients.map((client) => client.id),
1750
+ {
1751
+ codexHome: process.env.CODEX_HOME
1752
+ ? expandHome(process.env.CODEX_HOME)
1753
+ : undefined,
1754
+ },
1755
+ );
1468
1756
  const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
1469
1757
  for (const change of instructionPlan.changes) {
1470
1758
  for (const client of change.clients) {
@@ -1475,25 +1763,39 @@ async function runInit(
1475
1763
  }
1476
1764
  }
1477
1765
  for (const manual of instructionPlan.manual) {
1478
- instructionReadiness[manual.client] = { status: "manual", detail: manual.reason };
1766
+ instructionReadiness[manual.client] = {
1767
+ status: "manual",
1768
+ detail: manual.reason,
1769
+ };
1479
1770
  }
1480
- const builtInNames = new Set(["agent-skills", "claude-code", "codex", "custom", "agents", "claude"]);
1771
+ const builtInNames = new Set([
1772
+ "agent-skills",
1773
+ "claude-code",
1774
+ "codex",
1775
+ "custom",
1776
+ "agents",
1777
+ "claude",
1778
+ ]);
1481
1779
  const explicitSurfaceTargets = explicitTargets
1482
1780
  .filter((name) => builtInNames.has(name))
1483
1781
  .map((name) =>
1484
1782
  resolveBuiltInTarget(name, {
1485
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1783
+ codexHome: process.env.CODEX_HOME
1784
+ ? expandHome(process.env.CODEX_HOME)
1785
+ : undefined,
1486
1786
  customPath: customPath ? expandHome(customPath) : undefined,
1487
1787
  }),
1488
1788
  );
1489
1789
  if (customPath && !explicitTargets.includes("custom")) {
1490
- throw new Error("--path may only be used with --target custom");
1790
+ throw new Error("--dir may only be used with --target custom");
1491
1791
  }
1492
1792
  for (const target of explicitSurfaceTargets) {
1493
1793
  if (target.warning) console.error(`warning: ${target.warning}`);
1494
1794
  }
1495
1795
  const targetByPath = new Map(
1496
- explicitSurfaceTargets.map((target) => [target.path, target.targetName] as const),
1796
+ explicitSurfaceTargets.map(
1797
+ (target) => [target.path, target.targetName] as const,
1798
+ ),
1497
1799
  );
1498
1800
  const existingManifestPath = resolveManifestPath(vaultPath);
1499
1801
  const existingManifest = existingManifestPath
@@ -1504,7 +1806,8 @@ async function runInit(
1504
1806
  targetByPath.set(
1505
1807
  surface.path,
1506
1808
  existingManifest
1507
- ? configuredTargetForSurface(existingManifest, surface) ?? surface.targetName
1809
+ ? (configuredTargetForSurface(existingManifest, surface) ??
1810
+ surface.targetName)
1508
1811
  : surface.targetName,
1509
1812
  );
1510
1813
  }
@@ -1518,7 +1821,8 @@ async function runInit(
1518
1821
  const candidates = detectSurfaces(candidatePaths, vaultPath);
1519
1822
  if (!options.isJson) {
1520
1823
  for (const candidate of candidates) {
1521
- const name = targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1824
+ const name =
1825
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1522
1826
  if (candidate.state === "missing") {
1523
1827
  console.log(`${name} (${candidate.path}): not found`);
1524
1828
  continue;
@@ -1528,26 +1832,45 @@ async function runInit(
1528
1832
  continue;
1529
1833
  }
1530
1834
  if (candidate.state === "full-vault") {
1531
- console.log(`${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`);
1835
+ console.log(
1836
+ `${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`,
1837
+ );
1532
1838
  continue;
1533
1839
  }
1534
1840
  if (candidate.state === "external-symlink") {
1535
- console.log(`${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`);
1841
+ console.log(
1842
+ `${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`,
1843
+ );
1536
1844
  continue;
1537
1845
  }
1538
1846
  if (candidate.state === "unsupported") {
1539
- console.log(`${name} (${candidate.path}): unsupported filesystem entry`);
1847
+ console.log(
1848
+ `${name} (${candidate.path}): unsupported filesystem entry`,
1849
+ );
1540
1850
  continue;
1541
1851
  }
1542
1852
  const kind = "real dir";
1543
- const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
1544
- console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
1853
+ const marked = candidate.alreadyMarked
1854
+ ? ", already skillmux-managed"
1855
+ : "";
1856
+ console.log(
1857
+ `${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`,
1858
+ );
1545
1859
  }
1546
- for (const readiness of assessClientReadiness(clientPlan, instructionReadiness)) {
1860
+ for (const readiness of assessClientReadiness(
1861
+ clientPlan,
1862
+ instructionReadiness,
1863
+ )) {
1547
1864
  console.log(`\n${readiness.client} readiness:`);
1548
- console.log(` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`);
1549
- console.log(` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`);
1550
- console.log(` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`);
1865
+ console.log(
1866
+ ` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`,
1867
+ );
1868
+ console.log(
1869
+ ` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`,
1870
+ );
1871
+ console.log(
1872
+ ` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`,
1873
+ );
1551
1874
  }
1552
1875
  for (const change of instructionPlan.changes) {
1553
1876
  console.log(
@@ -1578,20 +1901,28 @@ async function runInit(
1578
1901
 
1579
1902
  const byName = new Map(
1580
1903
  candidates
1581
- .filter((candidate) =>
1582
- candidate.deliveryMode === "managed-pins" ||
1583
- (migrateFullVault && candidate.state === "full-vault"),
1904
+ .filter(
1905
+ (candidate) =>
1906
+ candidate.deliveryMode === "managed-pins" ||
1907
+ (migrateFullVault && candidate.state === "full-vault"),
1584
1908
  )
1585
- .map((candidate) => [
1586
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1587
- candidate,
1588
- ] as const),
1909
+ .map(
1910
+ (candidate) =>
1911
+ [
1912
+ targetByPath.get(candidate.path) ??
1913
+ deriveTargetName(candidate.path),
1914
+ candidate,
1915
+ ] as const,
1916
+ ),
1589
1917
  );
1590
1918
  const allCandidatesByName = new Map(
1591
- candidates.map((candidate) => [
1592
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1593
- candidate,
1594
- ] as const),
1919
+ candidates.map(
1920
+ (candidate) =>
1921
+ [
1922
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1923
+ candidate,
1924
+ ] as const,
1925
+ ),
1595
1926
  );
1596
1927
  for (const name of requestedTargets) {
1597
1928
  if (!byName.has(name)) {
@@ -1600,7 +1931,9 @@ async function runInit(
1600
1931
  `target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
1601
1932
  );
1602
1933
  }
1603
- throw new Error(`unknown --target "${name}": not among detected surfaces`);
1934
+ throw new Error(
1935
+ `unknown --target "${name}": not among detected surfaces`,
1936
+ );
1604
1937
  }
1605
1938
  }
1606
1939
 
@@ -1612,7 +1945,11 @@ async function runInit(
1612
1945
  ...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
1613
1946
  };
1614
1947
  });
1615
- const plannedManifest = planInitManifest(vaultPath, confirmedTargets, selectedCoreSkillIds);
1948
+ const plannedManifest = planInitManifest(
1949
+ vaultPath,
1950
+ confirmedTargets,
1951
+ selectedCoreSkillIds,
1952
+ );
1616
1953
  const serializedPlan = {
1617
1954
  vault_path: vaultPath,
1618
1955
  config: configPlan
@@ -1630,44 +1967,50 @@ async function runInit(
1630
1967
  };
1631
1968
  if (!hasChanges) {
1632
1969
  if (options.isJson) {
1633
- console.log(JSON.stringify({
1634
- schema_version: 1,
1635
- ok: true,
1636
- command: "init",
1637
- phase: "plan",
1638
- dry_run: options.dryRun,
1639
- applied: false,
1640
- plan: serializedPlan,
1641
- }));
1970
+ console.log(
1971
+ JSON.stringify({
1972
+ schema_version: 1,
1973
+ ok: true,
1974
+ command: "init",
1975
+ phase: "plan",
1976
+ dry_run: options.dryRun,
1977
+ applied: false,
1978
+ plan: serializedPlan,
1979
+ }),
1980
+ );
1642
1981
  } else {
1643
1982
  console.log("\nno managed-pins surface selected — nothing written.");
1644
1983
  }
1645
1984
  return;
1646
1985
  }
1647
1986
  if (!options.isJson) {
1648
- for (const target of confirmedTargets.filter((target) => target.migrateFullVault)) {
1987
+ for (const target of confirmedTargets.filter(
1988
+ (target) => target.migrateFullVault,
1989
+ )) {
1649
1990
  console.log(
1650
1991
  `full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
1651
- `${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
1992
+ `${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
1652
1993
  );
1653
1994
  }
1654
1995
  }
1655
1996
  if (options.dryRun) {
1656
1997
  if (options.isJson) {
1657
- console.log(JSON.stringify({
1658
- schema_version: 1,
1659
- ok: true,
1660
- command: "init",
1661
- phase: "plan",
1662
- dry_run: true,
1663
- applied: false,
1664
- plan: serializedPlan,
1665
- }));
1998
+ console.log(
1999
+ JSON.stringify({
2000
+ schema_version: 1,
2001
+ ok: true,
2002
+ command: "init",
2003
+ phase: "plan",
2004
+ dry_run: true,
2005
+ applied: false,
2006
+ plan: serializedPlan,
2007
+ }),
2008
+ );
1666
2009
  } else {
1667
2010
  console.log(
1668
2011
  `\ndry-run: ${confirmedTargets.length} target(s), ` +
1669
- `${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
1670
- `core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
2012
+ `${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
2013
+ `core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
1671
2014
  );
1672
2015
  }
1673
2016
  return;
@@ -1684,7 +2027,9 @@ async function runInit(
1684
2027
  console.log(
1685
2028
  ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
1686
2029
  );
1687
- console.log(` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`);
2030
+ console.log(
2031
+ ` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`,
2032
+ );
1688
2033
  console.log(` sync: ${sync ? "yes" : "no"}`);
1689
2034
  if (!(await confirmAction("Apply this setup plan?"))) {
1690
2035
  console.log("init cancelled");
@@ -1692,10 +2037,14 @@ async function runInit(
1692
2037
  }
1693
2038
  } else {
1694
2039
  const prompts = [
1695
- ...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
2040
+ ...confirmedTargets.map(
2041
+ (target) => `Adopt ${target.name} at ${target.dir}?`,
2042
+ ),
1696
2043
  ...instructionPlan.changes
1697
2044
  .filter((change) => change.status !== "unchanged")
1698
- .map((change) => `${change.status} instruction file ${change.path}?`),
2045
+ .map(
2046
+ (change) => `${change.status} instruction file ${change.path}?`,
2047
+ ),
1699
2048
  ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1700
2049
  ...(selectedCoreSkillIds.length > 0
1701
2050
  ? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
@@ -1754,42 +2103,53 @@ async function runInit(
1754
2103
  }
1755
2104
 
1756
2105
  if (options.isJson) {
1757
- console.log(JSON.stringify({
1758
- schema_version: 1,
1759
- ok: true,
1760
- command: "init",
1761
- phase: "result",
1762
- dry_run: false,
1763
- applied: true,
1764
- plan: serializedPlan,
1765
- result: {
1766
- config_created: configCreated,
1767
- targets_adopted: confirmedTargets.map((target) => target.name),
1768
- instructions_changed: instructionPlan.changes
1769
- .filter((change) => change.status !== "unchanged")
1770
- .map((change) => change.path),
1771
- core: plannedManifest.core.skills,
1772
- },
1773
- }));
2106
+ console.log(
2107
+ JSON.stringify({
2108
+ schema_version: 1,
2109
+ ok: true,
2110
+ command: "init",
2111
+ phase: "result",
2112
+ dry_run: false,
2113
+ applied: true,
2114
+ plan: serializedPlan,
2115
+ result: {
2116
+ config_created: configCreated,
2117
+ targets_adopted: confirmedTargets.map((target) => target.name),
2118
+ instructions_changed: instructionPlan.changes
2119
+ .filter((change) => change.status !== "unchanged")
2120
+ .map((change) => change.path),
2121
+ core: plannedManifest.core.skills,
2122
+ },
2123
+ }),
2124
+ );
1774
2125
  return;
1775
2126
  }
1776
2127
  if (configCreated) console.log(`created ${configPath}`);
1777
2128
  if (confirmedTargets.length > 0) {
1778
- console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
2129
+ console.log(
2130
+ `\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`,
2131
+ );
1779
2132
  } else if (selectedCoreSkillIds.length > 0) {
1780
2133
  console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
1781
2134
  }
1782
2135
  if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
1783
- console.log("next: skillmux manifest pin <skill_id> --core");
2136
+ console.log("next: skillmux core pin <skill_id> --yes");
1784
2137
  }
1785
2138
  if (confirmedTargets.length > 0) console.log("next: skillmux sync");
1786
- if (selectedClients.length === 0 || selectedClients.includes("skillmux-mcp")) {
2139
+ if (
2140
+ selectedClients.length === 0 ||
2141
+ selectedClients.includes("skillmux-mcp")
2142
+ ) {
1787
2143
  console.log(`\n${printLastMile()}`);
1788
2144
  }
1789
2145
  if (guided && sync && confirmedTargets.length > 0) await runSync([]);
1790
2146
  }
1791
2147
 
1792
- function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {
2148
+ function parseReportArgs(args: string[]): {
2149
+ server?: string;
2150
+ db?: string;
2151
+ since?: string;
2152
+ } {
1793
2153
  let server: string | undefined;
1794
2154
  let db: string | undefined;
1795
2155
  let since: string | undefined;
@@ -1808,6 +2168,8 @@ function parseReportArgs(args: string[]): { server?: string; db?: string; since?
1808
2168
  if (!value) throw new Error("--since requires a window");
1809
2169
  since = value;
1810
2170
  i++;
2171
+ } else if (option === "--json") {
2172
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1811
2173
  } else {
1812
2174
  throw new Error(`unknown report option: ${option}`);
1813
2175
  }
@@ -1816,24 +2178,45 @@ function parseReportArgs(args: string[]): { server?: string; db?: string; since?
1816
2178
  return { server, db, since };
1817
2179
  }
1818
2180
 
1819
- async function runReport(args: string[]): Promise<void> {
2181
+ async function runReport(
2182
+ args: string[],
2183
+ options: { isJson: boolean },
2184
+ ): Promise<void> {
1820
2185
  const { server, db: dbPath, since } = parseReportArgs(args);
1821
- if (!since) throw new Error("usage: skillmux report [--server <url> | --db <path>] --since <window>");
2186
+ if (!since)
2187
+ throw new Error(
2188
+ "usage: skillmux report [--server <url> | --db <path>] --since <window> [--json]",
2189
+ );
1822
2190
 
1823
2191
  if (server) {
1824
2192
  const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
1825
2193
  const res = await fetch(url);
1826
- if (!res.ok) throw new Error(`skillmux report --server failed: ${res.status} ${await res.text()}`);
1827
- console.log(renderStatsText((await res.json()) as StatsResponse));
2194
+ if (!res.ok)
2195
+ throw new Error(
2196
+ `skillmux report --server failed: ${res.status} ${await res.text()}`,
2197
+ );
2198
+ const stats = (await res.json()) as StatsResponse;
2199
+ emitSuccess({ isJson: options.isJson }, stats, () =>
2200
+ console.log(renderStatsText(stats)),
2201
+ );
1828
2202
  return;
1829
2203
  }
1830
2204
 
1831
- const db = dbPath ? new Database(dbPath, { readonly: true }) : openIndex(expandHome((await loadConfig()).state_dir));
1832
- console.log(renderStatsText(getStats(db, since)));
2205
+ const db = dbPath
2206
+ ? new Database(dbPath, { readonly: true })
2207
+ : openIndex(expandHome((await loadConfig()).state_dir));
2208
+ const stats = getStats(db, since);
2209
+ emitSuccess({ isJson: options.isJson }, stats, () =>
2210
+ console.log(renderStatsText(stats)),
2211
+ );
1833
2212
  db.close();
1834
2213
  }
1835
2214
 
1836
- function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"; failOn?: ScanSeverity } {
2215
+ function parseScanArgs(args: string[]): {
2216
+ path?: string;
2217
+ format: "text" | "json";
2218
+ failOn?: ScanSeverity;
2219
+ } {
1837
2220
  let path: string | undefined;
1838
2221
  let format: "text" | "json" = "text";
1839
2222
  let failOn: ScanSeverity | undefined;
@@ -1841,7 +2224,8 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1841
2224
  const option = args[i];
1842
2225
  if (option === "--format") {
1843
2226
  const value = args[++i];
1844
- if (value !== "text" && value !== "json") throw new Error("--format must be text or json");
2227
+ if (value !== "text" && value !== "json")
2228
+ throw new Error("--format must be text or json");
1845
2229
  format = value;
1846
2230
  } else if (option === "--fail-on") {
1847
2231
  const value = args[++i];
@@ -1849,6 +2233,8 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1849
2233
  throw new Error("--fail-on must be low, medium, or high");
1850
2234
  }
1851
2235
  failOn = value;
2236
+ } else if (option === "--json") {
2237
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1852
2238
  } else if (option?.startsWith("--")) {
1853
2239
  throw new Error(`unknown scan option: ${option}`);
1854
2240
  } else if (path !== undefined) {
@@ -1860,11 +2246,20 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1860
2246
  return { path, format, failOn };
1861
2247
  }
1862
2248
 
1863
- async function runScan(args: string[]): Promise<void> {
2249
+ async function runScan(
2250
+ args: string[],
2251
+ options: { isJson: boolean },
2252
+ ): Promise<void> {
1864
2253
  const { path, format, failOn } = parseScanArgs(args);
1865
- const rootPath = path ? expandHome(path) : expandHome((await loadConfig()).vault_path);
2254
+ const rootPath = path
2255
+ ? expandHome(path)
2256
+ : expandHome((await loadConfig()).vault_path);
1866
2257
  const result = await scanPath(rootPath);
1867
- console.log(format === "json" ? renderScanJson(result) : renderScanText(result));
2258
+ emitSuccess({ isJson: options.isJson }, result, () => {
2259
+ console.log(
2260
+ format === "json" ? renderScanJson(result) : renderScanText(result),
2261
+ );
2262
+ });
1868
2263
  process.exitCode = scanExitCode(result.findings, failOn);
1869
2264
  }
1870
2265
 
@@ -1888,6 +2283,8 @@ function parseInstallArgs(args: string[]): {
1888
2283
  throw new Error("--fail-on must be low, medium, or high");
1889
2284
  }
1890
2285
  failOn = value;
2286
+ } else if (option === "--json") {
2287
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1891
2288
  } else if (option?.startsWith("--")) {
1892
2289
  throw new Error(`unknown install option: ${option}`);
1893
2290
  } else if (repo !== undefined) {
@@ -1899,39 +2296,73 @@ function parseInstallArgs(args: string[]): {
1899
2296
  return { repo, force, dryRun, failOn };
1900
2297
  }
1901
2298
 
1902
- async function runInstall(args: string[]): Promise<void> {
2299
+ async function runInstall(
2300
+ args: string[],
2301
+ options: { isJson: boolean },
2302
+ ): Promise<void> {
1903
2303
  const { repo, force, dryRun, failOn } = parseInstallArgs(args);
1904
2304
  if (!repo) {
1905
- throw new Error("usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run]");
2305
+ throw new Error(
2306
+ "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--json]",
2307
+ );
1906
2308
  }
1907
2309
 
1908
2310
  const source = resolveRepoSource(repo);
1909
2311
  const cloneDir = await cloneToTemp(source.url);
1910
2312
  try {
1911
- const resolved = resolveSkillDir(cloneDir, deriveRepoName(source.url), source.skillPath);
1912
- const { findings } = await validateSkillCandidate(resolved.skillId, resolved.dir);
1913
- console.log(renderScanText({ scanned: 1, findings }));
2313
+ const resolved = resolveSkillDir(
2314
+ cloneDir,
2315
+ deriveRepoName(source.url),
2316
+ source.skillPath,
2317
+ );
2318
+ const { findings } = await validateSkillCandidate(
2319
+ resolved.skillId,
2320
+ resolved.dir,
2321
+ );
2322
+ if (!options.isJson) console.log(renderScanText({ scanned: 1, findings }));
1914
2323
 
1915
2324
  if (scanExitCode(findings, failOn) !== 0) {
1916
2325
  process.exitCode = 1;
1917
- console.error(`aborting install: a finding met the --fail-on ${failOn} threshold`);
2326
+ console.error(
2327
+ `aborting install: a finding met the --fail-on ${failOn} threshold`,
2328
+ );
1918
2329
  return;
1919
2330
  }
1920
2331
 
1921
2332
  const vaultPath = expandHome((await loadConfig()).vault_path);
1922
2333
  if (dryRun) {
1923
- console.log(`dry-run: would install "${resolved.skillId}" into ${join(vaultPath, resolved.skillId)}`);
2334
+ const plannedPath = join(vaultPath, resolved.skillId);
2335
+ emitSuccess(
2336
+ { isJson: options.isJson },
2337
+ { skill_id: resolved.skillId, would_install_at: plannedPath },
2338
+ () =>
2339
+ console.log(
2340
+ `dry-run: would install "${resolved.skillId}" into ${plannedPath}`,
2341
+ ),
2342
+ );
1924
2343
  return;
1925
2344
  }
1926
2345
 
1927
- const targetDir = installIntoVault(vaultPath, resolved.skillId, resolved.dir, force);
1928
- console.log(`installed "${resolved.skillId}" into ${targetDir}`);
2346
+ const targetDir = installIntoVault(
2347
+ vaultPath,
2348
+ resolved.skillId,
2349
+ resolved.dir,
2350
+ force,
2351
+ );
2352
+ emitSuccess(
2353
+ { isJson: options.isJson },
2354
+ { skill_id: resolved.skillId, installed_at: targetDir },
2355
+ () => console.log(`installed "${resolved.skillId}" into ${targetDir}`),
2356
+ );
1929
2357
  } finally {
1930
2358
  rmSync(cloneDir, { recursive: true, force: true });
1931
2359
  }
1932
2360
  }
1933
2361
 
1934
- function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; out?: string } {
2362
+ function parseCalibrateGenerateDatasetArgs(args: string[]): {
2363
+ vault?: string;
2364
+ out?: string;
2365
+ } {
1935
2366
  let vault: string | undefined;
1936
2367
  let out: string | undefined;
1937
2368
  for (let i = 0; i < args.length; i++) {
@@ -1952,7 +2383,8 @@ function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; ou
1952
2383
  }
1953
2384
 
1954
2385
  async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
1955
- const { vault: vaultArg, out: outArg } = parseCalibrateGenerateDatasetArgs(args);
2386
+ const { vault: vaultArg, out: outArg } =
2387
+ parseCalibrateGenerateDatasetArgs(args);
1956
2388
  const config = await loadConfig();
1957
2389
  const vaultPath = expandHome(vaultArg ?? config.vault_path);
1958
2390
  const outPath = expandHome(outArg ?? join(config.state_dir, "queries.json"));
@@ -1963,7 +2395,9 @@ async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
1963
2395
  const parentDir = join(outPath, "..");
1964
2396
  mkdirSync(parentDir, { recursive: true });
1965
2397
  await Bun.write(outPath, JSON.stringify(dataset, null, 2) + "\n");
1966
- console.log(`generated synthetic dataset with ${dataset.length} cases at ${outPath}`);
2398
+ console.log(
2399
+ `generated synthetic dataset with ${dataset.length} cases at ${outPath}`,
2400
+ );
1967
2401
  }
1968
2402
 
1969
2403
  if (import.meta.main) {