@klhapp/skillmux 0.6.0 → 1.0.1

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
@@ -2,12 +2,16 @@
2
2
  import { Database } from "bun:sqlite";
3
3
  import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { hostname } from "node:os";
5
- import { basename, join } from "node:path";
6
- import { createInterface } from "node:readline/promises";
5
+ import { join } from "node:path";
7
6
  import { generateDataset } from "./dataset-generator";
8
7
 
9
8
  import { createClients } from "./clients";
10
- import { expandHome, loadConfig, migrateLegacyPaths, resolveConfigPath } from "./config";
9
+ import {
10
+ expandHome,
11
+ loadConfig,
12
+ migrateLegacyPaths,
13
+ resolveConfigPath,
14
+ } from "./config";
11
15
  import { openIndex } from "./db";
12
16
  import { diagnose } from "./doctor";
13
17
  import { evalVault } from "./eval";
@@ -43,23 +47,26 @@ import {
43
47
  } from "./install";
44
48
  import {
45
49
  parseManifest,
46
- pinCore,
47
- pinProject,
48
50
  resolveManifestPath,
49
51
  serializeManifest,
50
- unpinCore,
51
- unpinProject,
52
- upsertProject,
53
- updateProjectPaths,
54
- updateProjectTargets,
55
52
  validateManifest,
56
- writeManifestAtomic,
57
53
  } from "./manifest";
58
54
  import { downloadLocalModels } from "./models";
59
- import { resolveProjectDirectory, suggestProjectName } from "./project-setup";
60
- import { parseCommaList, promptMultiSelect, promptText, shouldUseWizard } from "./prompts";
55
+
56
+ import {
57
+ parseCommaList,
58
+ promptMultiSelect,
59
+ promptText,
60
+ shouldUseWizard,
61
+ } from "./prompts";
61
62
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
62
- import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
63
+ import {
64
+ renderScanJson,
65
+ renderScanText,
66
+ scanExitCode,
67
+ scanPath,
68
+ type ScanSeverity,
69
+ } from "./scan";
63
70
  import {
64
71
  applyConfigInit,
65
72
  inspectVault,
@@ -87,8 +94,21 @@ import {
87
94
  type ResolvedTarget,
88
95
  } from "./context";
89
96
  import { createTargetAdapter, type TargetAdapter } from "./adapters";
90
- import { formatJsonEnvelope, isInteractive, mapExitCode, renderTable, renderTargetBanner, suggestCorrection } from "./output";
97
+ import {
98
+ emitSuccess,
99
+ formatJsonEnvelope,
100
+ isInteractive,
101
+ mapExitCode,
102
+ renderTable,
103
+ renderTargetBanner,
104
+ suggestCorrection,
105
+ } from "./output";
91
106
  import { generateCompletions, type ShellType } from "./completions";
107
+ import { handleConfigCommand } from "./commands/config";
108
+ import { runCore } from "./commands/core";
109
+ import { configuredTargetForSurface, runProject } from "./commands/project";
110
+ import { confirmAction, confirmIfNeeded } from "./commands/shared";
111
+ import { runTarget } from "./commands/target";
92
112
 
93
113
  const KNOWN_COMMANDS = [
94
114
  "context",
@@ -101,14 +121,14 @@ const KNOWN_COMMANDS = [
101
121
  "init",
102
122
  "project",
103
123
  "target",
124
+ "core",
104
125
  "report",
105
126
  "scan",
106
127
  "install",
107
128
  "eval",
108
129
  "doctor",
109
130
  "models",
110
- "which",
111
- "manifest",
131
+ "skill",
112
132
  "local-vault",
113
133
  ];
114
134
 
@@ -143,9 +163,17 @@ async function main() {
143
163
 
144
164
  // Only resolve target if command is target-aware or context/config/calibrate
145
165
  const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
146
- if ((["context", "config", "calibrate"].includes(command) && !isLocalConfigInit) || flagContext || flagServer) {
166
+ if (
167
+ (["context", "config", "calibrate"].includes(command) &&
168
+ !isLocalConfigInit) ||
169
+ flagContext ||
170
+ flagServer
171
+ ) {
147
172
  try {
148
- resolvedTarget = await resolveTarget({ context: flagContext, server: flagServer });
173
+ resolvedTarget = await resolveTarget({
174
+ context: flagContext,
175
+ server: flagServer,
176
+ });
149
177
  } catch (err: any) {
150
178
  handleError(err, { target: resolvedTarget, isJson, isVerbose });
151
179
  return;
@@ -159,13 +187,23 @@ async function main() {
159
187
  try {
160
188
  switch (command) {
161
189
  case "context":
162
- await handleContextCommand(subCommand, commandArgs, { target: resolvedTarget, isJson });
190
+ await handleContextCommand(subCommand, commandArgs, {
191
+ target: resolvedTarget,
192
+ isJson,
193
+ });
163
194
  break;
164
195
  case "config":
165
- await handleConfigCommand(adapter, subCommand, commandArgs, { target: resolvedTarget, isJson, dryRun: isDryRun });
196
+ await handleConfigCommand(adapter, subCommand, commandArgs, {
197
+ target: resolvedTarget,
198
+ isJson,
199
+ dryRun: isDryRun,
200
+ });
166
201
  break;
167
202
  case "calibrate":
168
- await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), { target: resolvedTarget, isJson });
203
+ await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), {
204
+ target: resolvedTarget,
205
+ isJson,
206
+ });
169
207
  break;
170
208
  case "completions":
171
209
  await handleCompletionsCommand(subCommand);
@@ -202,45 +240,59 @@ async function main() {
202
240
  await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
203
241
  break;
204
242
  case "project":
205
- await runProject(subCommand, commandArgs, { isJson, dryRun: isDryRun });
243
+ await runProject(subCommand, commandArgs, {
244
+ isJson,
245
+ dryRun: isDryRun,
246
+ sync: runSync,
247
+ });
206
248
  break;
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,198 +378,24 @@ 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
 
334
- async function handleConfigCommand(
335
- adapter: TargetAdapter,
336
- sub: string,
337
- args: string[],
338
- ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean }
339
- ) {
340
- if (sub === "init") {
341
- let vaultPath: string | undefined;
342
- let yes = false;
343
- for (let i = 0; i < args.length; i++) {
344
- const option = args[i];
345
- if (option === "--vault") {
346
- vaultPath = args[++i];
347
- if (!vaultPath) throw new Error("usage: skillmux config init --vault <path> --yes");
348
- } else if (option === "--yes") {
349
- yes = true;
350
- } else if (option === "--dry-run" || option === "--json") {
351
- continue;
352
- } else {
353
- throw new Error(`unknown config init option: ${option}`);
354
- }
355
- }
356
- if (!vaultPath) {
357
- if (isInteractive() && !ctx.isJson) {
358
- vaultPath = "~/skills";
359
- } else {
360
- throw new Error("usage: skillmux config init --vault <path> --yes");
361
- }
362
- }
363
-
364
- migrateLegacyPaths();
365
- const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
366
- 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}`);
378
- return;
379
- }
380
- 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)`);
392
- return;
393
- }
394
- if (!yes) {
395
- if (!ctx.isJson && isInteractive()) {
396
- if (!(await confirmAction(`Create ${plan.configPath} with vault_path ${plan.vaultPath}?`))) {
397
- console.log("config init cancelled; nothing written");
398
- return;
399
- }
400
- } else {
401
- throw new Error("config initialization requires --yes in noninteractive mode");
402
- }
403
- }
404
-
405
- 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}`);
419
- return;
420
- }
421
-
422
- if (sub === "show") {
423
- const data = await adapter.getConfigShow();
424
- if (ctx.isJson) {
425
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data })));
426
- } else {
427
- renderTargetBanner(ctx.target);
428
- console.log(JSON.stringify(data.effective, null, 2));
429
- }
430
- return;
431
- }
432
-
433
- if (sub === "get") {
434
- const key = args[0];
435
- if (!key) throw new Error("usage: skillmux config get <key>");
436
- 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
- }
442
- return;
443
- }
444
-
445
- if (sub === "validate") {
446
- 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
- }
452
- return;
453
- }
454
-
455
- if (sub === "diff") {
456
- const res = await adapter.configDiff();
457
- if (ctx.isJson) {
458
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
459
- } else {
460
- renderTargetBanner(ctx.target);
461
- console.log(JSON.stringify(res.diff, null, 2));
462
- }
463
- return;
464
- }
465
-
466
- if (sub === "set") {
467
- const key = args[0];
468
- const value = args[1];
469
- if (!key || value === undefined) {
470
- throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
471
- }
472
- 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 {
476
- renderTargetBanner(ctx.target);
477
- 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
- }
481
- return;
482
- }
483
-
484
- if (sub === "status") {
485
- const res = await adapter.configStatus();
486
- if (ctx.isJson) {
487
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
488
- } else {
489
- renderTargetBanner(ctx.target);
490
- console.log(`Runtime: ${res.runtime}`);
491
- console.log(`Active revision: ${res.active_revision}`);
492
- console.log(`Readiness: ${res.readiness.status}`);
493
- }
494
- return;
495
- }
496
-
497
- throw new Error("usage: skillmux config show");
498
- }
499
-
500
- async function confirmAction(prompt: string): Promise<boolean> {
501
- const readline = createInterface({ input: process.stdin, output: process.stdout });
502
- try {
503
- const answer = (await readline.question(`${prompt} [y/N] `)).trim().toLowerCase();
504
- return answer === "y" || answer === "yes";
505
- } finally {
506
- readline.close();
507
- }
508
- }
509
-
510
394
  async function handleCalibrateCommand(
511
395
  adapter: TargetAdapter,
512
396
  sub: string,
513
397
  args: string[],
514
- ctx: { target: ResolvedTarget; isJson: boolean }
398
+ ctx: { target: ResolvedTarget; isJson: boolean },
515
399
  ) {
516
400
  if (sub === "run") {
517
401
  let datasetPath: string | undefined;
@@ -519,21 +403,17 @@ async function handleCalibrateCommand(
519
403
  if (args[i] === "--dataset") datasetPath = args[++i];
520
404
  }
521
405
  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 {
406
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
525
407
  renderTargetBanner(ctx.target);
526
408
  console.log(`Calibration run complete.`);
527
409
  if (res.result) console.log(JSON.stringify(res.result, null, 2));
528
- }
410
+ });
529
411
  return;
530
412
  }
531
413
 
532
414
  if (sub === "list") {
533
415
  const res = await adapter.calibrateList();
534
- if (ctx.isJson) {
535
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
536
- } else {
416
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
537
417
  renderTargetBanner(ctx.target);
538
418
  renderTable(
539
419
  [
@@ -541,9 +421,9 @@ async function handleCalibrateCommand(
541
421
  { key: "created_at", header: "CREATED_AT" },
542
422
  { key: "status", header: "STATUS" },
543
423
  ],
544
- res
424
+ res,
545
425
  );
546
- }
426
+ });
547
427
  return;
548
428
  }
549
429
 
@@ -551,12 +431,10 @@ async function handleCalibrateCommand(
551
431
  const runId = args[1];
552
432
  if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
553
433
  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 {
434
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
557
435
  renderTargetBanner(ctx.target);
558
436
  console.log(JSON.stringify(res, null, 2));
559
- }
437
+ });
560
438
  return;
561
439
  }
562
440
 
@@ -564,12 +442,10 @@ async function handleCalibrateCommand(
564
442
  const runId = args[1];
565
443
  if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
566
444
  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 {
445
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
570
446
  renderTargetBanner(ctx.target);
571
447
  console.log(`Applied calibration run "${runId}"`);
572
- }
448
+ });
573
449
  return;
574
450
  }
575
451
 
@@ -578,7 +454,9 @@ async function handleCalibrateCommand(
578
454
  return;
579
455
  }
580
456
 
581
- throw new Error("usage: skillmux calibrate generate-dataset [--vault <path>] [--out <file>]");
457
+ throw new Error(
458
+ "usage: skillmux calibrate generate-dataset [--vault <path>] [--out <file>]",
459
+ );
582
460
  }
583
461
 
584
462
  async function handleCompletionsCommand(shell: string) {
@@ -590,7 +468,7 @@ async function handleCompletionsCommand(shell: string) {
590
468
 
591
469
  function handleError(
592
470
  err: any,
593
- opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean }
471
+ opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean },
594
472
  ) {
595
473
  const code = mapExitCode(err);
596
474
  process.exitCode = code;
@@ -605,7 +483,13 @@ function handleError(
605
483
  });
606
484
  console.log(JSON.stringify(env));
607
485
  } else {
608
- console.error(msg.startsWith("usage:") || msg.startsWith("Unknown") || msg.startsWith("error:") ? msg : `error: ${msg}`);
486
+ console.error(
487
+ msg.startsWith("usage:") ||
488
+ msg.startsWith("Unknown") ||
489
+ msg.startsWith("error:")
490
+ ? msg
491
+ : `error: ${msg}`,
492
+ );
609
493
  if (opts.isVerbose && err instanceof Error && err.stack) {
610
494
  console.error(err.stack);
611
495
  }
@@ -617,7 +501,7 @@ function printHelp(): void {
617
501
 
618
502
  Setup:
619
503
  skillmux config init --vault <path> --yes
620
- skillmux init [--client <name>...] [--target <name>...] [--path <dir>]
504
+ skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
621
505
  [--vault <path>] [--core <skill_id>...]
622
506
  [--migrate-full-vault] [--no-instructions] [--no-sync]
623
507
  [--interactive|--yes|--dry-run] [--json]
@@ -626,6 +510,8 @@ Setup:
626
510
  [--interactive|--yes|--dry-run] [--json]
627
511
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
628
512
  skillmux target <list|show|add|remove>
513
+ skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
514
+ skillmux skill which <skill_id>
629
515
 
630
516
  Init clients:
631
517
  claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
@@ -635,8 +521,8 @@ Init targets:
635
521
  agent-skills, claude-code, codex, custom
636
522
 
637
523
  Commands:
638
- serve, index, sync, init, project, target, report, scan, install, eval, doctor, which,
639
- manifest, local-vault, config, models, calibrate, context, completions`);
524
+ serve, index, sync, init, project, target, core, report, scan, install, eval, doctor, skill,
525
+ local-vault, config, models, calibrate, context, completions`);
640
526
  }
641
527
 
642
528
  // ---------------------------------------------------------------------------
@@ -645,7 +531,10 @@ Commands:
645
531
 
646
532
  type Transport = "stdio" | "http";
647
533
 
648
- function parseServeArgs(args: string[]): { transport: Transport; port?: number } {
534
+ function parseServeArgs(args: string[]): {
535
+ transport: Transport;
536
+ port?: number;
537
+ } {
649
538
  let transport: Transport = "stdio";
650
539
  let port: number | undefined;
651
540
  for (let i = 0; i < args.length; i++) {
@@ -675,7 +564,9 @@ async function runIndex(): Promise<void> {
675
564
  const config = await loadConfig();
676
565
  configure({ config, clients: createClients(config) });
677
566
  const report = await rebuildIndex((skillId, error) => {
678
- console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
567
+ console.error(
568
+ `warning: keeping previous index entry for ${skillId}: ${error}`,
569
+ );
679
570
  });
680
571
  const retainedNote =
681
572
  report.retained.length > 0
@@ -687,43 +578,56 @@ async function runIndex(): Promise<void> {
687
578
  const backfilled = await backfillEmbeddings();
688
579
  console.log(`embeddings: ${backfilled} backfilled`);
689
580
  } catch {
690
- console.log("embeddings: skipped (endpoint unreachable; lexical-only recall until next index)");
581
+ console.log(
582
+ "embeddings: skipped (endpoint unreachable; lexical-only recall until next index)",
583
+ );
691
584
  }
692
585
  }
693
586
 
694
- async function runEval(): Promise<void> {
587
+ async function runEval(options: { isJson: boolean }): Promise<void> {
695
588
  const config = await loadConfig();
696
589
  configure({ config, clients: createClients(config) });
697
590
 
698
591
  const report = await evalVault().catch((error: unknown) => {
699
592
  throw new Error(`eval requires local embeddings: ${String(error)}`);
700
593
  });
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)}`);
594
+ emitSuccess({ isJson: options.isJson }, report, () => {
595
+ console.log(`holdout queries: ${report.queries}`);
596
+ console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
597
+ console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
598
+ console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
599
+ console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
600
+ console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
601
+ console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
602
+ });
708
603
  }
709
604
 
710
- async function runDoctor(): Promise<void> {
605
+ async function runDoctor(options: { isJson: boolean }): Promise<void> {
711
606
  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}`);
607
+ emitSuccess({ isJson: options.isJson }, report, () => {
608
+ console.log(`inference mode: ${report.mode}`);
609
+ console.log(`routing capability: ${report.capability}`);
610
+ for (const check of report.checks)
611
+ console.log(
612
+ `${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
613
+ );
614
+ });
716
615
  if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
717
616
  }
718
617
 
618
+ async function runSkill(subCommand: string, args: string[]): Promise<void> {
619
+ if (subCommand !== "which") throw new Error("usage: skillmux skill <which>");
620
+ await runWhich(args);
621
+ }
622
+
719
623
  async function runWhich(args: string[]): Promise<void> {
720
624
  const skillId = args[0];
721
- if (!skillId) throw new Error("usage: skillmux which <skill_id>");
625
+ if (!skillId) throw new Error("usage: skillmux skill which <skill_id>");
722
626
  const config = await loadConfig();
723
627
  const vaultPath = expandHome(config.vault_path);
724
628
  const localVaultPaths = config.local_vault_paths.map(expandHome);
725
- const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter((root) =>
726
- existsSync(join(root, skillId, "SKILL.md")),
629
+ const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
630
+ (root) => existsSync(join(root, skillId, "SKILL.md")),
727
631
  );
728
632
  if (roots.length === 0) {
729
633
  console.log(`${skillId}: not found in vault_path or local_vault_paths`);
@@ -731,498 +635,69 @@ async function runWhich(args: string[]): Promise<void> {
731
635
  return;
732
636
  }
733
637
  console.log(`${skillId}: serving from ${roots[0]}`);
734
- for (const shadowedRoot of roots.slice(1)) console.log(` shadows: ${shadowedRoot}`);
735
- }
736
-
737
- const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
738
- const PROJECT_INIT_USAGE =
739
- "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
740
-
741
- interface ProjectInitArgs {
742
- path: string;
743
- name: string;
744
- skills: string[];
745
- clients: string[];
746
- targets: string[];
747
- yes: boolean;
748
- sync: boolean;
749
- }
750
-
751
- function configuredTargetForSurface(
752
- manifest: ReturnType<typeof parseManifest>,
753
- surface: { targetName: string; path: string },
754
- ): string | undefined {
755
- if (manifest.targets[surface.targetName]) return surface.targetName;
756
- return Object.entries(manifest.targets).find(
757
- ([, target]) => expandHome(target.dir) === surface.path,
758
- )?.[0];
759
- }
760
-
761
- function configuredTargetsForClients(
762
- manifest: ReturnType<typeof parseManifest>,
763
- clients: readonly string[],
764
- ): string[] {
765
- return planClientSurfaces(clients).surfaces.map((surface) => {
766
- const target = configuredTargetForSurface(manifest, surface);
767
- if (target) return target;
768
- const client = surface.clients[0]!;
769
- throw new Error(
770
- `client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
771
- );
772
- });
773
- }
774
-
775
- function parseProjectInitArgs(args: string[]): ProjectInitArgs {
776
- let projectPath: string | undefined;
777
- let name: string | undefined;
778
- const skills: string[] = [];
779
- const clients: string[] = [];
780
- const targets: string[] = [];
781
- let yes = false;
782
- let sync = true;
783
-
784
- for (let i = 0; i < args.length; i++) {
785
- const arg = args[i]!;
786
- if (arg === "--name") {
787
- name = args[++i];
788
- if (!name) throw new Error("--name requires a group name");
789
- } else if (arg === "--skill") {
790
- const skill = args[++i];
791
- if (!skill) throw new Error("--skill requires a skill_id");
792
- skills.push(skill);
793
- } else if (arg === "--target") {
794
- const target = args[++i];
795
- if (!target) throw new Error("--target requires a name");
796
- targets.push(target);
797
- } else if (arg === "--client") {
798
- const client = args[++i];
799
- if (!client) throw new Error("--client requires a name");
800
- clients.push(client);
801
- } else if (arg === "--yes") {
802
- yes = true;
803
- } else if (arg === "--no-sync") {
804
- sync = false;
805
- } else if (arg === "--dry-run" || arg === "--json" || arg === "--interactive") {
806
- continue;
807
- } else if (arg.startsWith("-")) {
808
- throw new Error(`unknown project init option: ${arg}`);
809
- } else if (projectPath) {
810
- throw new Error(PROJECT_INIT_USAGE);
811
- } else {
812
- projectPath = arg;
813
- }
814
- }
815
-
816
- const path = resolveProjectDirectory(projectPath ? expandHome(projectPath) : undefined);
817
- return { path, name: name ?? suggestProjectName(basename(path)), skills, clients, targets, yes, sync };
638
+ for (const shadowedRoot of roots.slice(1))
639
+ console.log(` shadows: ${shadowedRoot}`);
818
640
  }
819
641
 
820
- async function runProject(
821
- subCommand: string,
642
+ async function runLocalVaultInit(
822
643
  args: string[],
823
644
  options: { isJson: boolean; dryRun: boolean },
824
645
  ): Promise<void> {
825
- 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 ?? {});
834
- if (subCommand === "show" && !manifest.project?.[names[0]!]) {
835
- throw new Error(`[project.${names[0]}] does not exist`);
836
- }
837
- const projects = names.map((name) => ({
838
- name,
839
- paths: manifest.project?.[name]!.paths ?? [],
840
- skills: manifest.project?.[name]!.skills ?? [],
841
- targets: Object.entries(manifest.targets)
842
- .filter(([, target]) => target.project_groups.includes(name))
843
- .map(([target]) => target),
844
- }));
845
- if (options.isJson) {
846
- console.log(JSON.stringify({ schema_version: 1, projects }));
847
- } else if (projects.length === 0) {
848
- console.log("no project groups configured");
849
- } else {
850
- for (const project of projects) {
851
- console.log(`${project.name}:`);
852
- console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
853
- console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
854
- console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
855
- }
856
- }
857
- return;
858
- }
859
- if (subCommand === "add-path" || subCommand === "remove-path") {
860
- const group = args[0];
861
- if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> [path] --yes`);
862
- const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
863
- const projectPath = resolveProjectDirectory(rawPath ? expandHome(rawPath) : undefined);
864
- const yes = args.includes("--yes");
865
- if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
866
- throw new Error(`project path is not a directory: ${projectPath}`);
867
- }
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());
873
- const updated = updateProjectPaths(manifest, group, {
874
- ...(subCommand === "add-path" ? { add: [projectPath] } : { remove: [projectPath] }),
875
- });
876
- validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
877
- if (options.dryRun) {
878
- console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
879
- return;
880
- }
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
- }
888
- writeManifestAtomic(manifestPath, updated);
889
- console.log(`${subCommand}: [project.${group}] ${projectPath}`);
890
- return;
891
- }
892
- if (subCommand === "pin" || subCommand === "unpin") {
893
- const group = args[0];
894
- const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
895
- if (!group || skills.length === 0) {
896
- throw new Error(`usage: skillmux project ${subCommand} <group> <skill_id>... --yes`);
897
- }
898
- 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());
904
- for (const skill of skills) {
905
- updated = subCommand === "pin"
906
- ? pinProject(updated, skill, group)
907
- : unpinProject(updated, skill, group);
908
- }
909
- validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
910
- if (options.dryRun) {
911
- console.log(`${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`);
912
- return;
913
- }
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
- }
921
- writeManifestAtomic(manifestPath, updated);
922
- console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
923
- return;
924
- }
925
- if (subCommand === "attach" || subCommand === "detach") {
926
- const group = args[0];
927
- if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`);
928
- const clients: string[] = [];
929
- const requestedTargets: string[] = [];
930
- for (let i = 1; i < args.length; i++) {
931
- if (args[i] === "--client") {
932
- const value = args[++i];
933
- if (!value) throw new Error("--client requires a name");
934
- clients.push(value);
935
- } else if (args[i] === "--target") {
936
- const value = args[++i];
937
- if (!value) throw new Error("--target requires a name");
938
- requestedTargets.push(value);
939
- } else if (args[i] !== "--yes" && args[i] !== "--dry-run" && args[i] !== "--json") {
940
- throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
941
- }
942
- }
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());
948
- const clientTargets = configuredTargetsForClients(manifest, clients);
949
- const targets = [...new Set([...requestedTargets, ...clientTargets])];
950
- if (targets.length === 0) {
951
- throw new Error(`project ${subCommand} requires --client or --target`);
952
- }
953
- const updated = updateProjectTargets(manifest, group, {
954
- ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
955
- });
956
- validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
957
- if (options.dryRun) {
958
- console.log(`${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`);
959
- return;
960
- }
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
- }
968
- writeManifestAtomic(manifestPath, updated);
969
- console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
970
- return;
971
- }
972
- if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
973
- let request = parseProjectInitArgs(args);
974
- const guided = shouldUseWizard(args, {
975
- interactive: isInteractive(),
976
- json: options.isJson,
977
- dryRun: options.dryRun,
978
- });
979
- if (!existsSync(request.path)) throw new Error(`project path does not exist: ${request.path}`);
980
- if (!lstatSync(request.path).isDirectory()) {
981
- throw new Error(`project path is not a directory: ${request.path}`);
982
- }
983
-
646
+ const path = args[0];
647
+ if (!path) throw new Error("usage: skillmux local-vault init <path> --yes");
648
+ const expanded = expandHome(path);
984
649
  const config = await loadConfig();
985
- const vaultPath = expandHome(config.vault_path);
986
650
  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
- if (guided) {
991
- const name = await promptText("Project group", request.name);
992
- const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
993
- const surface = planClientSurfaces([client]).surfaces[0];
994
- return surface !== undefined && configuredTargetForSurface(manifest, surface) !== undefined;
995
- });
996
- const clients = await promptMultiSelect(
997
- "Which clients should receive project skills?",
998
- availableClients.map((client) => ({
999
- value: client,
1000
- label: client,
1001
- selected: request.clients.length === 0 || request.clients.includes(client),
1002
- })),
1003
- );
1004
- const skills = parseCommaList(
1005
- await promptText("Project skill IDs, comma-separated", request.skills.join(",")),
651
+ if (!localVaultPaths.includes(expanded)) {
652
+ throw new Error(
653
+ `"${path}" is not one of the configured local_vault_paths — add it to config.toml first`,
1006
654
  );
1007
- request = { ...request, name, clients, skills };
1008
655
  }
1009
- const clientTargets = configuredTargetsForClients(manifest, request.clients);
1010
- const targets = [...new Set([...request.targets, ...clientTargets])];
1011
- const updated = upsertProject(manifest, {
1012
- name: request.name,
1013
- paths: [request.path],
1014
- skills: request.skills,
1015
- targets,
1016
- });
1017
- const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
1018
- const plan = {
1019
- mode: "project",
1020
- project: request.name,
1021
- path: request.path,
1022
- skills: request.skills,
1023
- clients: request.clients,
1024
- targets,
1025
- sync: request.sync,
1026
- notes,
1027
- };
1028
-
656
+ if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
657
+ const markerPath = join(expanded, ".skillmux");
1029
658
  if (options.dryRun) {
1030
- console.log(options.isJson ? JSON.stringify({ schema_version: 1, plan }) : `project plan: ${JSON.stringify(plan)}`);
1031
- return;
1032
- }
1033
- if (!request.yes) {
1034
- if (!options.isJson && isInteractive()) {
1035
- if (guided) {
1036
- console.log("\nReview");
1037
- console.log(` project: ${request.name}`);
1038
- console.log(` path: ${request.path}`);
1039
- console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
1040
- console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
1041
- console.log(` sync: ${request.sync ? "yes" : "no"}`);
1042
- }
1043
- if (!(await confirmAction(`Apply project setup for ${request.name} at ${request.path}?`))) {
1044
- console.log("project setup cancelled");
1045
- return;
1046
- }
1047
- } else {
1048
- throw new Error("skillmux project init requires --yes when run non-interactively");
1049
- }
1050
- }
1051
-
1052
- writeManifestAtomic(manifestPath, updated);
1053
- if (request.sync) {
1054
- try {
1055
- await runSync([]);
1056
- } catch (error) {
1057
- throw new Error(
1058
- `project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
1059
- error instanceof Error ? error.message : String(error)
1060
- }`,
1061
- );
1062
- }
1063
- }
1064
- if (options.isJson) {
1065
- console.log(JSON.stringify({ schema_version: 1, result: plan }));
1066
- } else {
1067
- console.log(`project "${request.name}" ready at ${request.path}`);
1068
- }
1069
- }
1070
-
1071
- async function runTarget(
1072
- subCommand: string,
1073
- args: string[],
1074
- options: { isJson: boolean; dryRun: boolean },
1075
- ): 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());
1081
-
1082
- if (subCommand === "list" || subCommand === "show") {
1083
- const names = subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
1084
- if (subCommand === "show" && !manifest.targets[names[0]!]) {
1085
- throw new Error(`target "${names[0]}" does not exist`);
1086
- }
1087
- const targets = names.map((name) => {
1088
- const target = manifest.targets[name]!;
1089
- const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
1090
- const surface = planClientSurfaces([client]).surfaces[0];
1091
- return surface !== undefined && surface.path === expandHome(target.dir);
1092
- });
1093
- return { name, ...target, clients };
1094
- });
1095
- if (options.isJson) {
1096
- console.log(JSON.stringify({ schema_version: 1, targets }));
1097
- } else if (targets.length === 0) {
1098
- console.log("no targets configured");
1099
- } else {
1100
- for (const target of targets) {
1101
- console.log(`${target.name}:`);
1102
- console.log(` dir: ${target.dir}`);
1103
- console.log(` host: ${target.host ?? "(global)"}`);
1104
- console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
1105
- console.log(` projects: ${target.project_groups.join(", ") || "(none)"}`);
1106
- }
1107
- }
1108
- return;
1109
- }
1110
-
1111
- if (subCommand === "add") {
1112
- 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");
1116
- const path = expandHome(rawPath);
1117
- if (options.dryRun) {
1118
- 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)`);
1122
- return;
1123
- }
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
- }
1131
- applyInit(vaultPath, [{ name, dir: path }]);
1132
- console.log(`target "${name}" added at ${path}`);
659
+ emitSuccess(
660
+ { isJson: options.isJson },
661
+ {
662
+ marker_path: markerPath,
663
+ vault_path: expandHome(config.vault_path),
664
+ },
665
+ () =>
666
+ console.log(
667
+ `local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
668
+ ),
669
+ );
1133
670
  return;
1134
671
  }
1135
-
1136
- if (subCommand === "remove") {
1137
- const name = args[0];
1138
- if (!name || !manifest.targets[name]) {
1139
- throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target remove <name> --yes");
1140
- }
1141
- if (options.dryRun) {
1142
- console.log(`target remove: ${name} (files preserved, dry-run)`);
1143
- return;
1144
- }
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
- }
1152
- const targets = { ...manifest.targets };
1153
- delete targets[name];
1154
- writeManifestAtomic(manifestPath, { ...manifest, targets });
1155
- console.log(`target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`);
672
+ if (
673
+ !(await confirmIfNeeded({
674
+ confirmed: args.includes("--yes"),
675
+ isJson: options.isJson,
676
+ prompt: `Mark ${expanded} as a local_vault (role: local_vault, vault_path: ${expandHome(config.vault_path)})?`,
677
+ nonInteractiveError:
678
+ "skillmux local-vault init requires --yes when run non-interactively",
679
+ }))
680
+ )
1156
681
  return;
1157
- }
1158
-
1159
- throw new Error("usage: skillmux target <list|show|add|remove>");
1160
- }
1161
-
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> {
1210
- const path = args[0];
1211
- if (!path) throw new Error("usage: skillmux local-vault init <path>");
1212
- const expanded = expandHome(path);
1213
- const config = await loadConfig();
1214
- const localVaultPaths = config.local_vault_paths.map(expandHome);
1215
- if (!localVaultPaths.includes(expanded)) {
1216
- throw new Error(`"${path}" is not one of the configured local_vault_paths — add it to config.toml first`);
1217
- }
1218
- if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
1219
682
  writeLocalVaultMarker(expanded, expandHome(config.vault_path));
1220
- console.log(`wrote ${join(expanded, ".skillmux")} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`);
683
+ emitSuccess(
684
+ { isJson: options.isJson },
685
+ {
686
+ marker_path: markerPath,
687
+ vault_path: expandHome(config.vault_path),
688
+ },
689
+ () =>
690
+ console.log(
691
+ `wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
692
+ ),
693
+ );
1221
694
  }
1222
695
 
1223
- async function runModelDownload(): Promise<void> {
696
+ async function runModelDownload(options: { isJson: boolean }): Promise<void> {
1224
697
  const cacheDir = await downloadLocalModels(await loadConfig());
1225
- console.log(`models ready in ${cacheDir}`);
698
+ emitSuccess({ isJson: options.isJson }, { cache_dir: cacheDir }, () =>
699
+ console.log(`models ready in ${cacheDir}`),
700
+ );
1226
701
  }
1227
702
 
1228
703
  function parseSyncArgs(args: string[]): {
@@ -1289,10 +764,18 @@ async function runSync(args: string[]): Promise<void> {
1289
764
 
1290
765
  const suffix = dryRun ? " (dry-run)" : "";
1291
766
  const result = syncTarget(
1292
- { vaultPath, targetDir, targetName, coreSkillIds: manifest.core.skills, localVaultPaths },
767
+ {
768
+ vaultPath,
769
+ targetDir,
770
+ targetName,
771
+ coreSkillIds: manifest.core.skills,
772
+ localVaultPaths,
773
+ },
1293
774
  { dryRun },
1294
775
  );
1295
- console.log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
776
+ console.log(
777
+ `${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
778
+ );
1296
779
 
1297
780
  if (target.project_groups.length > 0) {
1298
781
  const allGroups = manifest.project ?? {};
@@ -1349,9 +832,9 @@ function parseInitArgs(args: string[]): {
1349
832
  if (!value) throw new Error("--vault requires a path");
1350
833
  vaultPath = value;
1351
834
  i++;
1352
- } else if (option === "--path") {
835
+ } else if (option === "--dir") {
1353
836
  const value = args[i + 1];
1354
- if (!value) throw new Error("--path requires a directory");
837
+ if (!value) throw new Error("--dir requires a directory");
1355
838
  customPath = value;
1356
839
  i++;
1357
840
  } else if (option === "--core") {
@@ -1359,7 +842,11 @@ function parseInitArgs(args: string[]): {
1359
842
  if (!value) throw new Error("--core requires a skill_id");
1360
843
  coreSkillIds.push(value);
1361
844
  i++;
1362
- } else if (option === "--dry-run" || option === "--json" || option === "--interactive") {
845
+ } else if (
846
+ option === "--dry-run" ||
847
+ option === "--json" ||
848
+ option === "--interactive"
849
+ ) {
1363
850
  continue;
1364
851
  } else if (option === "--migrate-full-vault") {
1365
852
  migrateFullVault = true;
@@ -1411,10 +898,13 @@ async function runInit(
1411
898
  let configPlan: ConfigInitPlan | undefined;
1412
899
  let vaultPath: string;
1413
900
  if (!existsSync(configPath)) {
1414
- const bootstrapVaultPath = requestedVaultPath ??
901
+ const bootstrapVaultPath =
902
+ requestedVaultPath ??
1415
903
  (!options.isJson && isInteractive() ? "~/skills" : undefined);
1416
904
  if (!bootstrapVaultPath) {
1417
- throw new Error(`machine config does not exist: ${configPath}; re-run with --vault <path>`);
905
+ throw new Error(
906
+ `machine config does not exist: ${configPath}; re-run with --vault <path>`,
907
+ );
1418
908
  }
1419
909
  configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
1420
910
  vaultPath = configPlan.vaultPath;
@@ -1439,15 +929,21 @@ async function runInit(
1439
929
  let selectedClients = requestedClients;
1440
930
  if (guided) {
1441
931
  const detected = detectInstalledClients({
1442
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
932
+ codexHome: process.env.CODEX_HOME
933
+ ? expandHome(process.env.CODEX_HOME)
934
+ : undefined,
1443
935
  });
1444
- const evidence = new Map(detected.map((item) => [item.client, item.evidence]));
936
+ const evidence = new Map(
937
+ detected.map((item) => [item.client, item.evidence]),
938
+ );
1445
939
  selectedClients = await promptMultiSelect(
1446
940
  "Which clients do you use?",
1447
941
  SUPPORTED_CLIENT_IDS.map((client) => ({
1448
942
  value: client,
1449
943
  label: client,
1450
- detail: evidence.has(client) ? `detected: ${evidence.get(client)}` : undefined,
944
+ detail: evidence.has(client)
945
+ ? `detected: ${evidence.get(client)}`
946
+ : undefined,
1451
947
  selected: evidence.has(client) || requestedClients.includes(client),
1452
948
  })),
1453
949
  );
@@ -1455,16 +951,26 @@ async function runInit(
1455
951
  let selectedCoreSkillIds = coreSkillIds;
1456
952
  if (guided) {
1457
953
  selectedCoreSkillIds = parseCommaList(
1458
- await promptText("Core skill IDs to add, comma-separated", coreSkillIds.join(",")),
954
+ await promptText(
955
+ "Core skill IDs to add, comma-separated",
956
+ coreSkillIds.join(","),
957
+ ),
1459
958
  );
1460
959
  }
1461
960
 
1462
961
  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,
962
+ codexHome: process.env.CODEX_HOME
963
+ ? expandHome(process.env.CODEX_HOME)
964
+ : undefined,
1467
965
  });
966
+ const instructionPlan = planInstructionSetup(
967
+ skipInstructions ? [] : clientPlan.clients.map((client) => client.id),
968
+ {
969
+ codexHome: process.env.CODEX_HOME
970
+ ? expandHome(process.env.CODEX_HOME)
971
+ : undefined,
972
+ },
973
+ );
1468
974
  const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
1469
975
  for (const change of instructionPlan.changes) {
1470
976
  for (const client of change.clients) {
@@ -1475,25 +981,39 @@ async function runInit(
1475
981
  }
1476
982
  }
1477
983
  for (const manual of instructionPlan.manual) {
1478
- instructionReadiness[manual.client] = { status: "manual", detail: manual.reason };
984
+ instructionReadiness[manual.client] = {
985
+ status: "manual",
986
+ detail: manual.reason,
987
+ };
1479
988
  }
1480
- const builtInNames = new Set(["agent-skills", "claude-code", "codex", "custom", "agents", "claude"]);
989
+ const builtInNames = new Set([
990
+ "agent-skills",
991
+ "claude-code",
992
+ "codex",
993
+ "custom",
994
+ "agents",
995
+ "claude",
996
+ ]);
1481
997
  const explicitSurfaceTargets = explicitTargets
1482
998
  .filter((name) => builtInNames.has(name))
1483
999
  .map((name) =>
1484
1000
  resolveBuiltInTarget(name, {
1485
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1001
+ codexHome: process.env.CODEX_HOME
1002
+ ? expandHome(process.env.CODEX_HOME)
1003
+ : undefined,
1486
1004
  customPath: customPath ? expandHome(customPath) : undefined,
1487
1005
  }),
1488
1006
  );
1489
1007
  if (customPath && !explicitTargets.includes("custom")) {
1490
- throw new Error("--path may only be used with --target custom");
1008
+ throw new Error("--dir may only be used with --target custom");
1491
1009
  }
1492
1010
  for (const target of explicitSurfaceTargets) {
1493
1011
  if (target.warning) console.error(`warning: ${target.warning}`);
1494
1012
  }
1495
1013
  const targetByPath = new Map(
1496
- explicitSurfaceTargets.map((target) => [target.path, target.targetName] as const),
1014
+ explicitSurfaceTargets.map(
1015
+ (target) => [target.path, target.targetName] as const,
1016
+ ),
1497
1017
  );
1498
1018
  const existingManifestPath = resolveManifestPath(vaultPath);
1499
1019
  const existingManifest = existingManifestPath
@@ -1504,7 +1024,8 @@ async function runInit(
1504
1024
  targetByPath.set(
1505
1025
  surface.path,
1506
1026
  existingManifest
1507
- ? configuredTargetForSurface(existingManifest, surface) ?? surface.targetName
1027
+ ? (configuredTargetForSurface(existingManifest, surface) ??
1028
+ surface.targetName)
1508
1029
  : surface.targetName,
1509
1030
  );
1510
1031
  }
@@ -1518,7 +1039,8 @@ async function runInit(
1518
1039
  const candidates = detectSurfaces(candidatePaths, vaultPath);
1519
1040
  if (!options.isJson) {
1520
1041
  for (const candidate of candidates) {
1521
- const name = targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1042
+ const name =
1043
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1522
1044
  if (candidate.state === "missing") {
1523
1045
  console.log(`${name} (${candidate.path}): not found`);
1524
1046
  continue;
@@ -1528,26 +1050,45 @@ async function runInit(
1528
1050
  continue;
1529
1051
  }
1530
1052
  if (candidate.state === "full-vault") {
1531
- console.log(`${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`);
1053
+ console.log(
1054
+ `${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`,
1055
+ );
1532
1056
  continue;
1533
1057
  }
1534
1058
  if (candidate.state === "external-symlink") {
1535
- console.log(`${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`);
1059
+ console.log(
1060
+ `${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`,
1061
+ );
1536
1062
  continue;
1537
1063
  }
1538
1064
  if (candidate.state === "unsupported") {
1539
- console.log(`${name} (${candidate.path}): unsupported filesystem entry`);
1065
+ console.log(
1066
+ `${name} (${candidate.path}): unsupported filesystem entry`,
1067
+ );
1540
1068
  continue;
1541
1069
  }
1542
1070
  const kind = "real dir";
1543
- const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
1544
- console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
1071
+ const marked = candidate.alreadyMarked
1072
+ ? ", already skillmux-managed"
1073
+ : "";
1074
+ console.log(
1075
+ `${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`,
1076
+ );
1545
1077
  }
1546
- for (const readiness of assessClientReadiness(clientPlan, instructionReadiness)) {
1078
+ for (const readiness of assessClientReadiness(
1079
+ clientPlan,
1080
+ instructionReadiness,
1081
+ )) {
1547
1082
  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}`);
1083
+ console.log(
1084
+ ` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`,
1085
+ );
1086
+ console.log(
1087
+ ` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`,
1088
+ );
1089
+ console.log(
1090
+ ` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`,
1091
+ );
1551
1092
  }
1552
1093
  for (const change of instructionPlan.changes) {
1553
1094
  console.log(
@@ -1578,20 +1119,28 @@ async function runInit(
1578
1119
 
1579
1120
  const byName = new Map(
1580
1121
  candidates
1581
- .filter((candidate) =>
1582
- candidate.deliveryMode === "managed-pins" ||
1583
- (migrateFullVault && candidate.state === "full-vault"),
1122
+ .filter(
1123
+ (candidate) =>
1124
+ candidate.deliveryMode === "managed-pins" ||
1125
+ (migrateFullVault && candidate.state === "full-vault"),
1584
1126
  )
1585
- .map((candidate) => [
1586
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1587
- candidate,
1588
- ] as const),
1127
+ .map(
1128
+ (candidate) =>
1129
+ [
1130
+ targetByPath.get(candidate.path) ??
1131
+ deriveTargetName(candidate.path),
1132
+ candidate,
1133
+ ] as const,
1134
+ ),
1589
1135
  );
1590
1136
  const allCandidatesByName = new Map(
1591
- candidates.map((candidate) => [
1592
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1593
- candidate,
1594
- ] as const),
1137
+ candidates.map(
1138
+ (candidate) =>
1139
+ [
1140
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1141
+ candidate,
1142
+ ] as const,
1143
+ ),
1595
1144
  );
1596
1145
  for (const name of requestedTargets) {
1597
1146
  if (!byName.has(name)) {
@@ -1600,7 +1149,9 @@ async function runInit(
1600
1149
  `target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
1601
1150
  );
1602
1151
  }
1603
- throw new Error(`unknown --target "${name}": not among detected surfaces`);
1152
+ throw new Error(
1153
+ `unknown --target "${name}": not among detected surfaces`,
1154
+ );
1604
1155
  }
1605
1156
  }
1606
1157
 
@@ -1612,7 +1163,11 @@ async function runInit(
1612
1163
  ...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
1613
1164
  };
1614
1165
  });
1615
- const plannedManifest = planInitManifest(vaultPath, confirmedTargets, selectedCoreSkillIds);
1166
+ const plannedManifest = planInitManifest(
1167
+ vaultPath,
1168
+ confirmedTargets,
1169
+ selectedCoreSkillIds,
1170
+ );
1616
1171
  const serializedPlan = {
1617
1172
  vault_path: vaultPath,
1618
1173
  config: configPlan
@@ -1630,44 +1185,50 @@ async function runInit(
1630
1185
  };
1631
1186
  if (!hasChanges) {
1632
1187
  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
- }));
1188
+ console.log(
1189
+ JSON.stringify({
1190
+ schema_version: 1,
1191
+ ok: true,
1192
+ command: "init",
1193
+ phase: "plan",
1194
+ dry_run: options.dryRun,
1195
+ applied: false,
1196
+ plan: serializedPlan,
1197
+ }),
1198
+ );
1642
1199
  } else {
1643
1200
  console.log("\nno managed-pins surface selected — nothing written.");
1644
1201
  }
1645
1202
  return;
1646
1203
  }
1647
1204
  if (!options.isJson) {
1648
- for (const target of confirmedTargets.filter((target) => target.migrateFullVault)) {
1205
+ for (const target of confirmedTargets.filter(
1206
+ (target) => target.migrateFullVault,
1207
+ )) {
1649
1208
  console.log(
1650
1209
  `full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
1651
- `${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
1210
+ `${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
1652
1211
  );
1653
1212
  }
1654
1213
  }
1655
1214
  if (options.dryRun) {
1656
1215
  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
- }));
1216
+ console.log(
1217
+ JSON.stringify({
1218
+ schema_version: 1,
1219
+ ok: true,
1220
+ command: "init",
1221
+ phase: "plan",
1222
+ dry_run: true,
1223
+ applied: false,
1224
+ plan: serializedPlan,
1225
+ }),
1226
+ );
1666
1227
  } else {
1667
1228
  console.log(
1668
1229
  `\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)"}`,
1230
+ `${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
1231
+ `core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
1671
1232
  );
1672
1233
  }
1673
1234
  return;
@@ -1684,7 +1245,9 @@ async function runInit(
1684
1245
  console.log(
1685
1246
  ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
1686
1247
  );
1687
- console.log(` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`);
1248
+ console.log(
1249
+ ` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`,
1250
+ );
1688
1251
  console.log(` sync: ${sync ? "yes" : "no"}`);
1689
1252
  if (!(await confirmAction("Apply this setup plan?"))) {
1690
1253
  console.log("init cancelled");
@@ -1692,10 +1255,14 @@ async function runInit(
1692
1255
  }
1693
1256
  } else {
1694
1257
  const prompts = [
1695
- ...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
1258
+ ...confirmedTargets.map(
1259
+ (target) => `Adopt ${target.name} at ${target.dir}?`,
1260
+ ),
1696
1261
  ...instructionPlan.changes
1697
1262
  .filter((change) => change.status !== "unchanged")
1698
- .map((change) => `${change.status} instruction file ${change.path}?`),
1263
+ .map(
1264
+ (change) => `${change.status} instruction file ${change.path}?`,
1265
+ ),
1699
1266
  ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1700
1267
  ...(selectedCoreSkillIds.length > 0
1701
1268
  ? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
@@ -1754,42 +1321,53 @@ async function runInit(
1754
1321
  }
1755
1322
 
1756
1323
  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
- }));
1324
+ console.log(
1325
+ JSON.stringify({
1326
+ schema_version: 1,
1327
+ ok: true,
1328
+ command: "init",
1329
+ phase: "result",
1330
+ dry_run: false,
1331
+ applied: true,
1332
+ plan: serializedPlan,
1333
+ result: {
1334
+ config_created: configCreated,
1335
+ targets_adopted: confirmedTargets.map((target) => target.name),
1336
+ instructions_changed: instructionPlan.changes
1337
+ .filter((change) => change.status !== "unchanged")
1338
+ .map((change) => change.path),
1339
+ core: plannedManifest.core.skills,
1340
+ },
1341
+ }),
1342
+ );
1774
1343
  return;
1775
1344
  }
1776
1345
  if (configCreated) console.log(`created ${configPath}`);
1777
1346
  if (confirmedTargets.length > 0) {
1778
- console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
1347
+ console.log(
1348
+ `\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`,
1349
+ );
1779
1350
  } else if (selectedCoreSkillIds.length > 0) {
1780
1351
  console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
1781
1352
  }
1782
1353
  if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
1783
- console.log("next: skillmux manifest pin <skill_id> --core");
1354
+ console.log("next: skillmux core pin <skill_id> --yes");
1784
1355
  }
1785
1356
  if (confirmedTargets.length > 0) console.log("next: skillmux sync");
1786
- if (selectedClients.length === 0 || selectedClients.includes("skillmux-mcp")) {
1357
+ if (
1358
+ selectedClients.length === 0 ||
1359
+ selectedClients.includes("skillmux-mcp")
1360
+ ) {
1787
1361
  console.log(`\n${printLastMile()}`);
1788
1362
  }
1789
1363
  if (guided && sync && confirmedTargets.length > 0) await runSync([]);
1790
1364
  }
1791
1365
 
1792
- function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {
1366
+ function parseReportArgs(args: string[]): {
1367
+ server?: string;
1368
+ db?: string;
1369
+ since?: string;
1370
+ } {
1793
1371
  let server: string | undefined;
1794
1372
  let db: string | undefined;
1795
1373
  let since: string | undefined;
@@ -1808,6 +1386,8 @@ function parseReportArgs(args: string[]): { server?: string; db?: string; since?
1808
1386
  if (!value) throw new Error("--since requires a window");
1809
1387
  since = value;
1810
1388
  i++;
1389
+ } else if (option === "--json") {
1390
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1811
1391
  } else {
1812
1392
  throw new Error(`unknown report option: ${option}`);
1813
1393
  }
@@ -1816,24 +1396,45 @@ function parseReportArgs(args: string[]): { server?: string; db?: string; since?
1816
1396
  return { server, db, since };
1817
1397
  }
1818
1398
 
1819
- async function runReport(args: string[]): Promise<void> {
1399
+ async function runReport(
1400
+ args: string[],
1401
+ options: { isJson: boolean },
1402
+ ): Promise<void> {
1820
1403
  const { server, db: dbPath, since } = parseReportArgs(args);
1821
- if (!since) throw new Error("usage: skillmux report [--server <url> | --db <path>] --since <window>");
1404
+ if (!since)
1405
+ throw new Error(
1406
+ "usage: skillmux report [--server <url> | --db <path>] --since <window> [--json]",
1407
+ );
1822
1408
 
1823
1409
  if (server) {
1824
1410
  const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
1825
1411
  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));
1412
+ if (!res.ok)
1413
+ throw new Error(
1414
+ `skillmux report --server failed: ${res.status} ${await res.text()}`,
1415
+ );
1416
+ const stats = (await res.json()) as StatsResponse;
1417
+ emitSuccess({ isJson: options.isJson }, stats, () =>
1418
+ console.log(renderStatsText(stats)),
1419
+ );
1828
1420
  return;
1829
1421
  }
1830
1422
 
1831
- const db = dbPath ? new Database(dbPath, { readonly: true }) : openIndex(expandHome((await loadConfig()).state_dir));
1832
- console.log(renderStatsText(getStats(db, since)));
1423
+ const db = dbPath
1424
+ ? new Database(dbPath, { readonly: true })
1425
+ : openIndex(expandHome((await loadConfig()).state_dir));
1426
+ const stats = getStats(db, since);
1427
+ emitSuccess({ isJson: options.isJson }, stats, () =>
1428
+ console.log(renderStatsText(stats)),
1429
+ );
1833
1430
  db.close();
1834
1431
  }
1835
1432
 
1836
- function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"; failOn?: ScanSeverity } {
1433
+ function parseScanArgs(args: string[]): {
1434
+ path?: string;
1435
+ format: "text" | "json";
1436
+ failOn?: ScanSeverity;
1437
+ } {
1837
1438
  let path: string | undefined;
1838
1439
  let format: "text" | "json" = "text";
1839
1440
  let failOn: ScanSeverity | undefined;
@@ -1841,7 +1442,8 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1841
1442
  const option = args[i];
1842
1443
  if (option === "--format") {
1843
1444
  const value = args[++i];
1844
- if (value !== "text" && value !== "json") throw new Error("--format must be text or json");
1445
+ if (value !== "text" && value !== "json")
1446
+ throw new Error("--format must be text or json");
1845
1447
  format = value;
1846
1448
  } else if (option === "--fail-on") {
1847
1449
  const value = args[++i];
@@ -1849,6 +1451,8 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1849
1451
  throw new Error("--fail-on must be low, medium, or high");
1850
1452
  }
1851
1453
  failOn = value;
1454
+ } else if (option === "--json") {
1455
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1852
1456
  } else if (option?.startsWith("--")) {
1853
1457
  throw new Error(`unknown scan option: ${option}`);
1854
1458
  } else if (path !== undefined) {
@@ -1860,11 +1464,20 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1860
1464
  return { path, format, failOn };
1861
1465
  }
1862
1466
 
1863
- async function runScan(args: string[]): Promise<void> {
1467
+ async function runScan(
1468
+ args: string[],
1469
+ options: { isJson: boolean },
1470
+ ): Promise<void> {
1864
1471
  const { path, format, failOn } = parseScanArgs(args);
1865
- const rootPath = path ? expandHome(path) : expandHome((await loadConfig()).vault_path);
1472
+ const rootPath = path
1473
+ ? expandHome(path)
1474
+ : expandHome((await loadConfig()).vault_path);
1866
1475
  const result = await scanPath(rootPath);
1867
- console.log(format === "json" ? renderScanJson(result) : renderScanText(result));
1476
+ emitSuccess({ isJson: options.isJson }, result, () => {
1477
+ console.log(
1478
+ format === "json" ? renderScanJson(result) : renderScanText(result),
1479
+ );
1480
+ });
1868
1481
  process.exitCode = scanExitCode(result.findings, failOn);
1869
1482
  }
1870
1483
 
@@ -1888,6 +1501,8 @@ function parseInstallArgs(args: string[]): {
1888
1501
  throw new Error("--fail-on must be low, medium, or high");
1889
1502
  }
1890
1503
  failOn = value;
1504
+ } else if (option === "--json") {
1505
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1891
1506
  } else if (option?.startsWith("--")) {
1892
1507
  throw new Error(`unknown install option: ${option}`);
1893
1508
  } else if (repo !== undefined) {
@@ -1899,39 +1514,73 @@ function parseInstallArgs(args: string[]): {
1899
1514
  return { repo, force, dryRun, failOn };
1900
1515
  }
1901
1516
 
1902
- async function runInstall(args: string[]): Promise<void> {
1517
+ async function runInstall(
1518
+ args: string[],
1519
+ options: { isJson: boolean },
1520
+ ): Promise<void> {
1903
1521
  const { repo, force, dryRun, failOn } = parseInstallArgs(args);
1904
1522
  if (!repo) {
1905
- throw new Error("usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run]");
1523
+ throw new Error(
1524
+ "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--json]",
1525
+ );
1906
1526
  }
1907
1527
 
1908
1528
  const source = resolveRepoSource(repo);
1909
1529
  const cloneDir = await cloneToTemp(source.url);
1910
1530
  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 }));
1531
+ const resolved = resolveSkillDir(
1532
+ cloneDir,
1533
+ deriveRepoName(source.url),
1534
+ source.skillPath,
1535
+ );
1536
+ const { findings } = await validateSkillCandidate(
1537
+ resolved.skillId,
1538
+ resolved.dir,
1539
+ );
1540
+ if (!options.isJson) console.log(renderScanText({ scanned: 1, findings }));
1914
1541
 
1915
1542
  if (scanExitCode(findings, failOn) !== 0) {
1916
1543
  process.exitCode = 1;
1917
- console.error(`aborting install: a finding met the --fail-on ${failOn} threshold`);
1544
+ console.error(
1545
+ `aborting install: a finding met the --fail-on ${failOn} threshold`,
1546
+ );
1918
1547
  return;
1919
1548
  }
1920
1549
 
1921
1550
  const vaultPath = expandHome((await loadConfig()).vault_path);
1922
1551
  if (dryRun) {
1923
- console.log(`dry-run: would install "${resolved.skillId}" into ${join(vaultPath, resolved.skillId)}`);
1552
+ const plannedPath = join(vaultPath, resolved.skillId);
1553
+ emitSuccess(
1554
+ { isJson: options.isJson },
1555
+ { skill_id: resolved.skillId, would_install_at: plannedPath },
1556
+ () =>
1557
+ console.log(
1558
+ `dry-run: would install "${resolved.skillId}" into ${plannedPath}`,
1559
+ ),
1560
+ );
1924
1561
  return;
1925
1562
  }
1926
1563
 
1927
- const targetDir = installIntoVault(vaultPath, resolved.skillId, resolved.dir, force);
1928
- console.log(`installed "${resolved.skillId}" into ${targetDir}`);
1564
+ const targetDir = installIntoVault(
1565
+ vaultPath,
1566
+ resolved.skillId,
1567
+ resolved.dir,
1568
+ force,
1569
+ );
1570
+ emitSuccess(
1571
+ { isJson: options.isJson },
1572
+ { skill_id: resolved.skillId, installed_at: targetDir },
1573
+ () => console.log(`installed "${resolved.skillId}" into ${targetDir}`),
1574
+ );
1929
1575
  } finally {
1930
1576
  rmSync(cloneDir, { recursive: true, force: true });
1931
1577
  }
1932
1578
  }
1933
1579
 
1934
- function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; out?: string } {
1580
+ function parseCalibrateGenerateDatasetArgs(args: string[]): {
1581
+ vault?: string;
1582
+ out?: string;
1583
+ } {
1935
1584
  let vault: string | undefined;
1936
1585
  let out: string | undefined;
1937
1586
  for (let i = 0; i < args.length; i++) {
@@ -1952,7 +1601,8 @@ function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; ou
1952
1601
  }
1953
1602
 
1954
1603
  async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
1955
- const { vault: vaultArg, out: outArg } = parseCalibrateGenerateDatasetArgs(args);
1604
+ const { vault: vaultArg, out: outArg } =
1605
+ parseCalibrateGenerateDatasetArgs(args);
1956
1606
  const config = await loadConfig();
1957
1607
  const vaultPath = expandHome(vaultArg ?? config.vault_path);
1958
1608
  const outPath = expandHome(outArg ?? join(config.state_dir, "queries.json"));
@@ -1963,7 +1613,9 @@ async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
1963
1613
  const parentDir = join(outPath, "..");
1964
1614
  mkdirSync(parentDir, { recursive: true });
1965
1615
  await Bun.write(outPath, JSON.stringify(dataset, null, 2) + "\n");
1966
- console.log(`generated synthetic dataset with ${dataset.length} cases at ${outPath}`);
1616
+ console.log(
1617
+ `generated synthetic dataset with ${dataset.length} cases at ${outPath}`,
1618
+ );
1967
1619
  }
1968
1620
 
1969
1621
  if (import.meta.main) {