@klhapp/skillmux 0.5.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
@@ -1,20 +1,27 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Database } from "bun:sqlite";
3
- import { existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { hostname } from "node:os";
5
- import { join } from "node:path";
5
+ import { basename, join } from "node:path";
6
6
  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";
14
19
  import {
15
20
  assessClientReadiness,
21
+ detectInstalledClients,
16
22
  planClientSurfaces,
17
23
  resolveBuiltInTarget,
24
+ SUPPORTED_CLIENT_IDS,
18
25
  type ClientId,
19
26
  type ReadinessAxis,
20
27
  } from "./init-clients";
@@ -47,11 +54,28 @@ import {
47
54
  serializeManifest,
48
55
  unpinCore,
49
56
  unpinProject,
57
+ upsertProject,
58
+ updateProjectPaths,
59
+ updateProjectTargets,
50
60
  validateManifest,
61
+ writeManifestAtomic,
51
62
  } from "./manifest";
52
63
  import { downloadLocalModels } from "./models";
64
+ import { resolveProjectDirectory, suggestProjectName } from "./project-setup";
65
+ import {
66
+ parseCommaList,
67
+ promptMultiSelect,
68
+ promptText,
69
+ shouldUseWizard,
70
+ } from "./prompts";
53
71
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
54
- 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";
55
79
  import {
56
80
  applyConfigInit,
57
81
  inspectVault,
@@ -79,7 +103,15 @@ import {
79
103
  type ResolvedTarget,
80
104
  } from "./context";
81
105
  import { createTargetAdapter, type TargetAdapter } from "./adapters";
82
- 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";
83
115
  import { generateCompletions, type ShellType } from "./completions";
84
116
 
85
117
  const KNOWN_COMMANDS = [
@@ -91,14 +123,16 @@ const KNOWN_COMMANDS = [
91
123
  "index",
92
124
  "sync",
93
125
  "init",
126
+ "project",
127
+ "target",
128
+ "core",
94
129
  "report",
95
130
  "scan",
96
131
  "install",
97
132
  "eval",
98
133
  "doctor",
99
134
  "models",
100
- "which",
101
- "manifest",
135
+ "skill",
102
136
  "local-vault",
103
137
  ];
104
138
 
@@ -133,9 +167,17 @@ async function main() {
133
167
 
134
168
  // Only resolve target if command is target-aware or context/config/calibrate
135
169
  const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
136
- if ((["context", "config", "calibrate"].includes(command) && !isLocalConfigInit) || flagContext || flagServer) {
170
+ if (
171
+ (["context", "config", "calibrate"].includes(command) &&
172
+ !isLocalConfigInit) ||
173
+ flagContext ||
174
+ flagServer
175
+ ) {
137
176
  try {
138
- resolvedTarget = await resolveTarget({ context: flagContext, server: flagServer });
177
+ resolvedTarget = await resolveTarget({
178
+ context: flagContext,
179
+ server: flagServer,
180
+ });
139
181
  } catch (err: any) {
140
182
  handleError(err, { target: resolvedTarget, isJson, isVerbose });
141
183
  return;
@@ -149,13 +191,23 @@ async function main() {
149
191
  try {
150
192
  switch (command) {
151
193
  case "context":
152
- await handleContextCommand(subCommand, commandArgs, { target: resolvedTarget, isJson });
194
+ await handleContextCommand(subCommand, commandArgs, {
195
+ target: resolvedTarget,
196
+ isJson,
197
+ });
153
198
  break;
154
199
  case "config":
155
- 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
+ });
156
205
  break;
157
206
  case "calibrate":
158
- await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), { target: resolvedTarget, isJson });
207
+ await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), {
208
+ target: resolvedTarget,
209
+ isJson,
210
+ });
159
211
  break;
160
212
  case "completions":
161
213
  await handleCompletionsCommand(subCommand);
@@ -191,40 +243,56 @@ async function main() {
191
243
  case "init":
192
244
  await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
193
245
  break;
246
+ case "project":
247
+ await runProject(subCommand, commandArgs, { isJson, dryRun: isDryRun });
248
+ break;
249
+ case "target":
250
+ await runTarget(subCommand, commandArgs, { isJson, dryRun: isDryRun });
251
+ break;
252
+ case "core":
253
+ await runCore(subCommand, commandArgs, { isJson, dryRun: isDryRun });
254
+ break;
194
255
  case "report":
195
- await runReport(rawArgv.slice(1));
256
+ await runReport(rawArgv.slice(1), { isJson });
196
257
  break;
197
258
  case "scan":
198
- await runScan(rawArgv.slice(1));
259
+ await runScan(rawArgv.slice(1), { isJson });
199
260
  break;
200
261
  case "install":
201
- await runInstall(rawArgv.slice(1));
262
+ await runInstall(rawArgv.slice(1), { isJson });
202
263
  break;
203
264
  case "eval":
204
- await runEval();
265
+ await runEval({ isJson });
205
266
  break;
206
267
  case "doctor":
207
- await runDoctor();
268
+ await runDoctor({ isJson });
208
269
  break;
209
270
  case "which":
210
- 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);
211
276
  break;
212
277
  case "manifest":
213
- await runManifest(subCommand, commandArgs);
214
- 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
+ );
215
281
  case "local-vault":
216
- if (subCommand !== "init") throw new Error("usage: skillmux local-vault init <path>");
217
- await runLocalVaultInit(commandArgs);
282
+ if (subCommand !== "init")
283
+ throw new Error("usage: skillmux local-vault init <path>");
284
+ await runLocalVaultInit(commandArgs, { isJson, dryRun: isDryRun });
218
285
  break;
219
286
  case "models":
220
- if (subCommand !== "download") throw new Error("usage: skillmux models download");
221
- await runModelDownload();
287
+ if (subCommand !== "download")
288
+ throw new Error("usage: skillmux models download");
289
+ await runModelDownload({ isJson });
222
290
  break;
223
291
  default: {
224
292
  const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
225
293
  const msg = suggestion
226
294
  ? `Unknown command "${command}". Did you mean "${suggestion}"?`
227
- : `usage: skillmux <serve|index|sync|init|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>`;
228
296
  throw new Error(msg);
229
297
  }
230
298
  }
@@ -236,13 +304,11 @@ async function main() {
236
304
  async function handleContextCommand(
237
305
  sub: string,
238
306
  args: string[],
239
- ctx: { target: ResolvedTarget; isJson: boolean }
307
+ ctx: { target: ResolvedTarget; isJson: boolean },
240
308
  ) {
241
309
  if (sub === "list") {
242
310
  const contexts = await listContexts();
243
- if (ctx.isJson) {
244
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: contexts })));
245
- } else {
311
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, contexts, () => {
246
312
  renderTargetBanner(ctx.target);
247
313
  renderTable(
248
314
  [
@@ -251,20 +317,22 @@ async function handleContextCommand(
251
317
  { key: "token_env", header: "TOKEN_ENV" },
252
318
  { key: "isDefault", header: "DEFAULT" },
253
319
  ],
254
- 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
+ })),
255
325
  );
256
- }
326
+ });
257
327
  return;
258
328
  }
259
329
 
260
330
  if (sub === "current") {
261
331
  const current = await getCurrentContext();
262
- if (ctx.isJson) {
263
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: current })));
264
- } else {
332
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, current, () => {
265
333
  renderTargetBanner(ctx.target);
266
334
  console.log(`Current context: ${current.name} (${current.server})`);
267
- }
335
+ });
268
336
  return;
269
337
  }
270
338
 
@@ -277,14 +345,18 @@ async function handleContextCommand(
277
345
  else if (args[i] === "--token-env") tokenEnv = args[++i];
278
346
  }
279
347
  if (!name || !server) {
280
- 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
+ );
281
351
  }
282
352
  await addContext(name, { server, token_env: tokenEnv });
283
- if (ctx.isJson) {
284
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { name, server, token_env: tokenEnv } })));
285
- } else {
286
- console.log(`Added context "${name}" -> ${server}`);
287
- }
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
+ );
288
360
  return;
289
361
  }
290
362
 
@@ -292,11 +364,13 @@ async function handleContextCommand(
292
364
  const name = args[0];
293
365
  if (!name) throw new Error("usage: skillmux context use <name>");
294
366
  await useContext(name);
295
- if (ctx.isJson) {
296
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { default_context: name } })));
297
- } else {
298
- console.log(`Switched default context to "${name}"`);
299
- }
367
+ emitSuccess(
368
+ { isJson: ctx.isJson, target: ctx.target },
369
+ { default_context: name },
370
+ () => {
371
+ console.log(`Switched default context to "${name}"`);
372
+ },
373
+ );
300
374
  return;
301
375
  }
302
376
 
@@ -304,22 +378,56 @@ async function handleContextCommand(
304
378
  const name = args[0];
305
379
  if (!name) throw new Error("usage: skillmux context remove <name>");
306
380
  await removeContext(name);
307
- if (ctx.isJson) {
308
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { removed: name } })));
309
- } else {
310
- console.log(`Removed context "${name}"`);
311
- }
381
+ emitSuccess(
382
+ { isJson: ctx.isJson, target: ctx.target },
383
+ { removed: name },
384
+ () => {
385
+ console.log(`Removed context "${name}"`);
386
+ },
387
+ );
312
388
  return;
313
389
  }
314
390
 
315
391
  throw new Error("usage: skillmux context <add|list|current|use|remove>");
316
392
  }
317
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
+
318
426
  async function handleConfigCommand(
319
427
  adapter: TargetAdapter,
320
428
  sub: string,
321
429
  args: string[],
322
- ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean }
430
+ ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
323
431
  ) {
324
432
  if (sub === "init") {
325
433
  let vaultPath: string | undefined;
@@ -328,7 +436,8 @@ async function handleConfigCommand(
328
436
  const option = args[i];
329
437
  if (option === "--vault") {
330
438
  vaultPath = args[++i];
331
- 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");
332
441
  } else if (option === "--yes") {
333
442
  yes = true;
334
443
  } else if (option === "--dry-run" || option === "--json") {
@@ -348,69 +457,65 @@ async function handleConfigCommand(
348
457
  migrateLegacyPaths();
349
458
  const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
350
459
  if (plan.action === "preserve") {
351
- console.log(ctx.isJson
352
- ? JSON.stringify({
353
- schema_version: 1,
354
- ok: true,
355
- command: "config init",
356
- phase: "result",
357
- dry_run: ctx.dryRun,
358
- applied: false,
359
- plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "preserve" },
360
- })
361
- : `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
+ });
362
468
  return;
363
469
  }
364
470
  if (ctx.dryRun) {
365
- console.log(ctx.isJson
366
- ? JSON.stringify({
367
- schema_version: 1,
368
- ok: true,
369
- command: "config init",
370
- phase: "plan",
371
- dry_run: true,
372
- applied: false,
373
- plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "create" },
374
- })
375
- : `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
+ });
376
479
  return;
377
480
  }
378
481
  if (!yes) {
379
482
  if (!ctx.isJson && isInteractive()) {
380
- 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
+ ) {
381
488
  console.log("config init cancelled; nothing written");
382
489
  return;
383
490
  }
384
491
  } else {
385
- throw new Error("config initialization requires --yes in noninteractive mode");
492
+ throw new Error(
493
+ "config initialization requires --yes in noninteractive mode",
494
+ );
386
495
  }
387
496
  }
388
497
 
389
498
  const result = applyConfigInit(plan);
390
- console.log(ctx.isJson
391
- ? JSON.stringify({
392
- schema_version: 1,
393
- ok: true,
394
- command: "config init",
395
- phase: "result",
396
- dry_run: false,
397
- applied: result === "created",
398
- plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: plan.action },
399
- })
400
- : result === "created"
401
- ? `created ${plan.configPath}`
402
- : `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
+ });
403
510
  return;
404
511
  }
405
512
 
406
513
  if (sub === "show") {
407
514
  const data = await adapter.getConfigShow();
408
- if (ctx.isJson) {
409
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data })));
410
- } else {
515
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
411
516
  renderTargetBanner(ctx.target);
412
517
  console.log(JSON.stringify(data.effective, null, 2));
413
- }
518
+ });
414
519
  return;
415
520
  }
416
521
 
@@ -418,32 +523,34 @@ async function handleConfigCommand(
418
523
  const key = args[0];
419
524
  if (!key) throw new Error("usage: skillmux config get <key>");
420
525
  const val = await adapter.getConfigGet(key);
421
- if (ctx.isJson) {
422
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { key, value: val } })));
423
- } else {
424
- console.log(typeof val === "object" ? JSON.stringify(val) : String(val));
425
- }
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
+ );
426
535
  return;
427
536
  }
428
537
 
429
538
  if (sub === "validate") {
430
539
  const res = await adapter.configValidate();
431
- if (ctx.isJson) {
432
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
433
- } else {
434
- console.log(res.valid ? "Configuration is valid." : "Configuration is invalid.");
435
- }
540
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
541
+ console.log(
542
+ res.valid ? "Configuration is valid." : "Configuration is invalid.",
543
+ );
544
+ });
436
545
  return;
437
546
  }
438
547
 
439
548
  if (sub === "diff") {
440
549
  const res = await adapter.configDiff();
441
- if (ctx.isJson) {
442
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
443
- } else {
550
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
444
551
  renderTargetBanner(ctx.target);
445
552
  console.log(JSON.stringify(res.diff, null, 2));
446
- }
553
+ });
447
554
  return;
448
555
  }
449
556
 
@@ -454,27 +561,27 @@ async function handleConfigCommand(
454
561
  throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
455
562
  }
456
563
  const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
457
- if (ctx.isJson) {
458
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
459
- } else {
564
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
460
565
  renderTargetBanner(ctx.target);
461
566
  const prefix = ctx.dryRun ? "[dry-run] " : "";
462
- console.log(`${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`);
463
- console.log(`Persistence: ${res.persistence}, Application: ${res.application}`);
464
- }
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
+ });
465
574
  return;
466
575
  }
467
576
 
468
577
  if (sub === "status") {
469
578
  const res = await adapter.configStatus();
470
- if (ctx.isJson) {
471
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
472
- } else {
579
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
473
580
  renderTargetBanner(ctx.target);
474
581
  console.log(`Runtime: ${res.runtime}`);
475
582
  console.log(`Active revision: ${res.active_revision}`);
476
583
  console.log(`Readiness: ${res.readiness.status}`);
477
- }
584
+ });
478
585
  return;
479
586
  }
480
587
 
@@ -482,9 +589,14 @@ async function handleConfigCommand(
482
589
  }
483
590
 
484
591
  async function confirmAction(prompt: string): Promise<boolean> {
485
- const readline = createInterface({ input: process.stdin, output: process.stdout });
592
+ const readline = createInterface({
593
+ input: process.stdin,
594
+ output: process.stdout,
595
+ });
486
596
  try {
487
- const answer = (await readline.question(`${prompt} [y/N] `)).trim().toLowerCase();
597
+ const answer = (await readline.question(`${prompt} [y/N] `))
598
+ .trim()
599
+ .toLowerCase();
488
600
  return answer === "y" || answer === "yes";
489
601
  } finally {
490
602
  readline.close();
@@ -495,7 +607,7 @@ async function handleCalibrateCommand(
495
607
  adapter: TargetAdapter,
496
608
  sub: string,
497
609
  args: string[],
498
- ctx: { target: ResolvedTarget; isJson: boolean }
610
+ ctx: { target: ResolvedTarget; isJson: boolean },
499
611
  ) {
500
612
  if (sub === "run") {
501
613
  let datasetPath: string | undefined;
@@ -503,21 +615,17 @@ async function handleCalibrateCommand(
503
615
  if (args[i] === "--dataset") datasetPath = args[++i];
504
616
  }
505
617
  const res = await adapter.calibrateRun({ datasetPath });
506
- if (ctx.isJson) {
507
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
508
- } else {
618
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
509
619
  renderTargetBanner(ctx.target);
510
620
  console.log(`Calibration run complete.`);
511
621
  if (res.result) console.log(JSON.stringify(res.result, null, 2));
512
- }
622
+ });
513
623
  return;
514
624
  }
515
625
 
516
626
  if (sub === "list") {
517
627
  const res = await adapter.calibrateList();
518
- if (ctx.isJson) {
519
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
520
- } else {
628
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
521
629
  renderTargetBanner(ctx.target);
522
630
  renderTable(
523
631
  [
@@ -525,9 +633,9 @@ async function handleCalibrateCommand(
525
633
  { key: "created_at", header: "CREATED_AT" },
526
634
  { key: "status", header: "STATUS" },
527
635
  ],
528
- res
636
+ res,
529
637
  );
530
- }
638
+ });
531
639
  return;
532
640
  }
533
641
 
@@ -535,12 +643,10 @@ async function handleCalibrateCommand(
535
643
  const runId = args[1];
536
644
  if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
537
645
  const res = await adapter.calibrateShow(runId);
538
- if (ctx.isJson) {
539
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
540
- } else {
646
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
541
647
  renderTargetBanner(ctx.target);
542
648
  console.log(JSON.stringify(res, null, 2));
543
- }
649
+ });
544
650
  return;
545
651
  }
546
652
 
@@ -548,12 +654,10 @@ async function handleCalibrateCommand(
548
654
  const runId = args[1];
549
655
  if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
550
656
  const res = await adapter.calibrateApply(runId);
551
- if (ctx.isJson) {
552
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
553
- } else {
657
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
554
658
  renderTargetBanner(ctx.target);
555
659
  console.log(`Applied calibration run "${runId}"`);
556
- }
660
+ });
557
661
  return;
558
662
  }
559
663
 
@@ -562,7 +666,9 @@ async function handleCalibrateCommand(
562
666
  return;
563
667
  }
564
668
 
565
- 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
+ );
566
672
  }
567
673
 
568
674
  async function handleCompletionsCommand(shell: string) {
@@ -574,7 +680,7 @@ async function handleCompletionsCommand(shell: string) {
574
680
 
575
681
  function handleError(
576
682
  err: any,
577
- opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean }
683
+ opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean },
578
684
  ) {
579
685
  const code = mapExitCode(err);
580
686
  process.exitCode = code;
@@ -589,7 +695,13 @@ function handleError(
589
695
  });
590
696
  console.log(JSON.stringify(env));
591
697
  } else {
592
- 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
+ );
593
705
  if (opts.isVerbose && err instanceof Error && err.stack) {
594
706
  console.error(err.stack);
595
707
  }
@@ -601,9 +713,17 @@ function printHelp(): void {
601
713
 
602
714
  Setup:
603
715
  skillmux config init --vault <path> --yes
604
- skillmux init [--client <name>...] [--target <name>...] [--path <dir>]
716
+ skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
605
717
  [--vault <path>] [--core <skill_id>...]
606
- [--migrate-full-vault] [--yes|--dry-run] [--json]
718
+ [--migrate-full-vault] [--no-instructions] [--no-sync]
719
+ [--interactive|--yes|--dry-run] [--json]
720
+ skillmux project init [path] [--name <group>] [--skill <skill_id>...]
721
+ [--client <name>...] [--target <name>...] [--no-sync]
722
+ [--interactive|--yes|--dry-run] [--json]
723
+ skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
724
+ skillmux target <list|show|add|remove>
725
+ skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
726
+ skillmux skill which <skill_id>
607
727
 
608
728
  Init clients:
609
729
  claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
@@ -613,8 +733,8 @@ Init targets:
613
733
  agent-skills, claude-code, codex, custom
614
734
 
615
735
  Commands:
616
- serve, index, sync, init, report, scan, install, eval, doctor, which,
617
- 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`);
618
738
  }
619
739
 
620
740
  // ---------------------------------------------------------------------------
@@ -623,7 +743,10 @@ Commands:
623
743
 
624
744
  type Transport = "stdio" | "http";
625
745
 
626
- function parseServeArgs(args: string[]): { transport: Transport; port?: number } {
746
+ function parseServeArgs(args: string[]): {
747
+ transport: Transport;
748
+ port?: number;
749
+ } {
627
750
  let transport: Transport = "stdio";
628
751
  let port: number | undefined;
629
752
  for (let i = 0; i < args.length; i++) {
@@ -653,7 +776,9 @@ async function runIndex(): Promise<void> {
653
776
  const config = await loadConfig();
654
777
  configure({ config, clients: createClients(config) });
655
778
  const report = await rebuildIndex((skillId, error) => {
656
- console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
779
+ console.error(
780
+ `warning: keeping previous index entry for ${skillId}: ${error}`,
781
+ );
657
782
  });
658
783
  const retainedNote =
659
784
  report.retained.length > 0
@@ -665,43 +790,56 @@ async function runIndex(): Promise<void> {
665
790
  const backfilled = await backfillEmbeddings();
666
791
  console.log(`embeddings: ${backfilled} backfilled`);
667
792
  } catch {
668
- 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
+ );
669
796
  }
670
797
  }
671
798
 
672
- async function runEval(): Promise<void> {
799
+ async function runEval(options: { isJson: boolean }): Promise<void> {
673
800
  const config = await loadConfig();
674
801
  configure({ config, clients: createClients(config) });
675
802
 
676
803
  const report = await evalVault().catch((error: unknown) => {
677
804
  throw new Error(`eval requires local embeddings: ${String(error)}`);
678
805
  });
679
- console.log(`holdout queries: ${report.queries}`);
680
- console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
681
- console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
682
- console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
683
- console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
684
- console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
685
- 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
+ });
686
815
  }
687
816
 
688
- async function runDoctor(): Promise<void> {
817
+ async function runDoctor(options: { isJson: boolean }): Promise<void> {
689
818
  const report = await diagnose(await loadConfig());
690
- console.log(`inference mode: ${report.mode}`);
691
- console.log(`routing capability: ${report.capability}`);
692
- for (const check of report.checks)
693
- 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
+ });
694
827
  if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
695
828
  }
696
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
+
697
835
  async function runWhich(args: string[]): Promise<void> {
698
836
  const skillId = args[0];
699
- if (!skillId) throw new Error("usage: skillmux which <skill_id>");
837
+ if (!skillId) throw new Error("usage: skillmux skill which <skill_id>");
700
838
  const config = await loadConfig();
701
839
  const vaultPath = expandHome(config.vault_path);
702
840
  const localVaultPaths = config.local_vault_paths.map(expandHome);
703
- const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter((root) =>
704
- existsSync(join(root, skillId, "SKILL.md")),
841
+ const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
842
+ (root) => existsSync(join(root, skillId, "SKILL.md")),
705
843
  );
706
844
  if (roots.length === 0) {
707
845
  console.log(`${skillId}: not found in vault_path or local_vault_paths`);
@@ -709,75 +847,639 @@ async function runWhich(args: string[]): Promise<void> {
709
847
  return;
710
848
  }
711
849
  console.log(`${skillId}: serving from ${roots[0]}`);
712
- for (const shadowedRoot of roots.slice(1)) console.log(` shadows: ${shadowedRoot}`);
850
+ for (const shadowedRoot of roots.slice(1))
851
+ console.log(` shadows: ${shadowedRoot}`);
713
852
  }
714
853
 
715
- const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
854
+ const PROJECT_INIT_USAGE =
855
+ "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
716
856
 
717
- function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; paths: string[] } {
718
- const skillId = args[0];
719
- if (!skillId) throw new Error(MANIFEST_USAGE);
720
- let core = false;
721
- let project: string | undefined;
722
- const paths: string[] = [];
723
- for (let i = 1; i < args.length; i++) {
724
- const arg = args[i];
725
- if (arg === "--core") core = true;
726
- else if (arg === "--project") {
727
- const value = args[++i];
728
- if (!value) throw new Error("--project requires a group name");
729
- project = value;
730
- } else if (arg === "--path") {
731
- const value = args[++i];
732
- if (!value) throw new Error("--path requires a path");
733
- paths.push(value);
734
- } else throw new Error(`unknown manifest option: ${arg}`);
857
+ interface ProjectInitArgs {
858
+ path: string;
859
+ name: string;
860
+ skills: string[];
861
+ clients: string[];
862
+ targets: string[];
863
+ yes: boolean;
864
+ sync: boolean;
865
+ }
866
+
867
+ function configuredTargetForSurface(
868
+ manifest: ReturnType<typeof parseManifest>,
869
+ surface: { targetName: string; path: string },
870
+ ): string | undefined {
871
+ if (manifest.targets[surface.targetName]) return surface.targetName;
872
+ return Object.entries(manifest.targets).find(
873
+ ([, target]) => expandHome(target.dir) === surface.path,
874
+ )?.[0];
875
+ }
876
+
877
+ function configuredTargetsForClients(
878
+ manifest: ReturnType<typeof parseManifest>,
879
+ clients: readonly string[],
880
+ ): string[] {
881
+ return planClientSurfaces(clients).surfaces.map((surface) => {
882
+ const target = configuredTargetForSurface(manifest, surface);
883
+ if (target) return target;
884
+ const client = surface.clients[0]!;
885
+ throw new Error(
886
+ `client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
887
+ );
888
+ });
889
+ }
890
+
891
+ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
892
+ let projectPath: string | undefined;
893
+ let name: string | undefined;
894
+ const skills: string[] = [];
895
+ const clients: string[] = [];
896
+ const targets: string[] = [];
897
+ let yes = false;
898
+ let sync = true;
899
+
900
+ for (let i = 0; i < args.length; i++) {
901
+ const arg = args[i]!;
902
+ if (arg === "--name") {
903
+ name = args[++i];
904
+ if (!name) throw new Error("--name requires a group name");
905
+ } else if (arg === "--skill") {
906
+ const skill = args[++i];
907
+ if (!skill) throw new Error("--skill requires a skill_id");
908
+ skills.push(skill);
909
+ } else if (arg === "--target") {
910
+ const target = args[++i];
911
+ if (!target) throw new Error("--target requires a name");
912
+ targets.push(target);
913
+ } else if (arg === "--client") {
914
+ const client = args[++i];
915
+ if (!client) throw new Error("--client requires a name");
916
+ clients.push(client);
917
+ } else if (arg === "--yes") {
918
+ yes = true;
919
+ } else if (arg === "--no-sync") {
920
+ sync = false;
921
+ } else if (
922
+ arg === "--dry-run" ||
923
+ arg === "--json" ||
924
+ arg === "--interactive"
925
+ ) {
926
+ continue;
927
+ } else if (arg.startsWith("-")) {
928
+ throw new Error(`unknown project init option: ${arg}`);
929
+ } else if (projectPath) {
930
+ throw new Error(PROJECT_INIT_USAGE);
931
+ } else {
932
+ projectPath = arg;
933
+ }
735
934
  }
736
- if (core === (project !== undefined)) throw new Error(MANIFEST_USAGE);
737
- return { skillId, core, project, paths };
935
+
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
+ };
738
948
  }
739
949
 
740
- async function runManifest(subCommand: string, args: string[]): Promise<void> {
741
- if (subCommand !== "pin" && subCommand !== "unpin") throw new Error(MANIFEST_USAGE);
742
- const { skillId, core, project, paths } = parseManifestPinArgs(args);
950
+ async function loadManifestContext() {
743
951
  const config = await loadConfig();
744
952
  const vaultPath = expandHome(config.vault_path);
745
- const localVaultPaths = config.local_vault_paths.map(expandHome);
746
953
  const manifestPath = resolveManifestPath(vaultPath);
747
- if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}`);
954
+ if (!manifestPath) {
955
+ throw new Error(
956
+ `no skillmux.toml found at ${vaultPath}; run skillmux init first`,
957
+ );
958
+ }
748
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);
974
+ }
975
+
976
+ async function runProject(
977
+ subCommand: string,
978
+ args: string[],
979
+ options: { isJson: boolean; dryRun: boolean },
980
+ ): Promise<void> {
981
+ if (subCommand === "list" || subCommand === "show") {
982
+ const { manifest } = await loadManifestContext();
983
+ const names =
984
+ subCommand === "show"
985
+ ? [args[0] ?? ""]
986
+ : Object.keys(manifest.project ?? {});
987
+ if (subCommand === "show" && !manifest.project?.[names[0]!]) {
988
+ throw new Error(`[project.${names[0]}] does not exist`);
989
+ }
990
+ const projects = names.map((name) => ({
991
+ name,
992
+ paths: manifest.project?.[name]!.paths ?? [],
993
+ skills: manifest.project?.[name]!.skills ?? [],
994
+ targets: Object.entries(manifest.targets)
995
+ .filter(([, target]) => target.project_groups.includes(name))
996
+ .map(([target]) => target),
997
+ }));
998
+ if (options.isJson) {
999
+ console.log(JSON.stringify({ schema_version: 1, projects }));
1000
+ } else if (projects.length === 0) {
1001
+ console.log("no project groups configured");
1002
+ } else {
1003
+ for (const project of projects) {
1004
+ console.log(`${project.name}:`);
1005
+ console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
1006
+ console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
1007
+ console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
1008
+ }
1009
+ }
1010
+ return;
1011
+ }
1012
+ if (subCommand === "add-path" || subCommand === "remove-path") {
1013
+ const group = args[0];
1014
+ if (!group)
1015
+ throw new Error(
1016
+ `usage: skillmux project ${subCommand} <group> [path] --yes`,
1017
+ );
1018
+ const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
1019
+ const projectPath = resolveProjectDirectory(
1020
+ rawPath ? expandHome(rawPath) : undefined,
1021
+ );
1022
+ const yes = args.includes("--yes");
1023
+ if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
1024
+ throw new Error(`project path is not a directory: ${projectPath}`);
1025
+ }
1026
+ const { config, vaultPath, manifestPath, manifest } =
1027
+ await loadManifestContext();
1028
+ const updated = updateProjectPaths(manifest, group, {
1029
+ ...(subCommand === "add-path"
1030
+ ? { add: [projectPath] }
1031
+ : { remove: [projectPath] }),
1032
+ });
1033
+ validateManifest(
1034
+ updated,
1035
+ vaultPath,
1036
+ config.local_vault_paths.map(expandHome),
1037
+ );
1038
+ if (options.dryRun) {
1039
+ console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
1040
+ return;
1041
+ }
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;
1051
+ writeManifestAtomic(manifestPath, updated);
1052
+ console.log(`${subCommand}: [project.${group}] ${projectPath}`);
1053
+ return;
1054
+ }
1055
+ if (subCommand === "pin" || subCommand === "unpin") {
1056
+ const group = args[0];
1057
+ const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
1058
+ if (!group || skills.length === 0) {
1059
+ throw new Error(
1060
+ `usage: skillmux project ${subCommand} <group> <skill_id>... --yes`,
1061
+ );
1062
+ }
1063
+ const yes = args.includes("--yes");
1064
+ const { config, vaultPath, manifestPath, manifest } =
1065
+ await loadManifestContext();
1066
+ let updated = manifest;
1067
+ for (const skill of skills) {
1068
+ updated =
1069
+ subCommand === "pin"
1070
+ ? pinProject(updated, skill, group)
1071
+ : unpinProject(updated, skill, group);
1072
+ }
1073
+ validateManifest(
1074
+ updated,
1075
+ vaultPath,
1076
+ config.local_vault_paths.map(expandHome),
1077
+ );
1078
+ if (options.dryRun) {
1079
+ console.log(
1080
+ `${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
1081
+ );
1082
+ return;
1083
+ }
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;
1093
+ writeManifestAtomic(manifestPath, updated);
1094
+ console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
1095
+ return;
1096
+ }
1097
+ if (subCommand === "attach" || subCommand === "detach") {
1098
+ const group = args[0];
1099
+ if (!group)
1100
+ throw new Error(
1101
+ `usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
1102
+ );
1103
+ const clients: string[] = [];
1104
+ const requestedTargets: string[] = [];
1105
+ for (let i = 1; i < args.length; i++) {
1106
+ if (args[i] === "--client") {
1107
+ const value = args[++i];
1108
+ if (!value) throw new Error("--client requires a name");
1109
+ clients.push(value);
1110
+ } else if (args[i] === "--target") {
1111
+ const value = args[++i];
1112
+ if (!value) throw new Error("--target requires a name");
1113
+ requestedTargets.push(value);
1114
+ } else if (
1115
+ args[i] !== "--yes" &&
1116
+ args[i] !== "--dry-run" &&
1117
+ args[i] !== "--json"
1118
+ ) {
1119
+ throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
1120
+ }
1121
+ }
1122
+ const { config, vaultPath, manifestPath, manifest } =
1123
+ await loadManifestContext();
1124
+ const clientTargets = configuredTargetsForClients(manifest, clients);
1125
+ const targets = [...new Set([...requestedTargets, ...clientTargets])];
1126
+ if (targets.length === 0) {
1127
+ throw new Error(`project ${subCommand} requires --client or --target`);
1128
+ }
1129
+ const updated = updateProjectTargets(manifest, group, {
1130
+ ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
1131
+ });
1132
+ validateManifest(
1133
+ updated,
1134
+ vaultPath,
1135
+ config.local_vault_paths.map(expandHome),
1136
+ );
1137
+ if (options.dryRun) {
1138
+ console.log(
1139
+ `${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
1140
+ );
1141
+ return;
1142
+ }
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;
1152
+ writeManifestAtomic(manifestPath, updated);
1153
+ console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
1154
+ return;
1155
+ }
1156
+ if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
1157
+ let request = parseProjectInitArgs(args);
1158
+ const guided = shouldUseWizard(args, {
1159
+ interactive: isInteractive(),
1160
+ json: options.isJson,
1161
+ dryRun: options.dryRun,
1162
+ });
1163
+ if (!existsSync(request.path))
1164
+ throw new Error(`project path does not exist: ${request.path}`);
1165
+ if (!lstatSync(request.path).isDirectory()) {
1166
+ throw new Error(`project path is not a directory: ${request.path}`);
1167
+ }
1168
+
1169
+ const { config, vaultPath, manifestPath, manifest } =
1170
+ await loadManifestContext();
1171
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
1172
+ if (guided) {
1173
+ const name = await promptText("Project group", request.name);
1174
+ const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
1175
+ const surface = planClientSurfaces([client]).surfaces[0];
1176
+ return (
1177
+ surface !== undefined &&
1178
+ configuredTargetForSurface(manifest, surface) !== undefined
1179
+ );
1180
+ });
1181
+ const clients = await promptMultiSelect(
1182
+ "Which clients should receive project skills?",
1183
+ availableClients.map((client) => ({
1184
+ value: client,
1185
+ label: client,
1186
+ selected:
1187
+ request.clients.length === 0 || request.clients.includes(client),
1188
+ })),
1189
+ );
1190
+ const skills = parseCommaList(
1191
+ await promptText(
1192
+ "Project skill IDs, comma-separated",
1193
+ request.skills.join(","),
1194
+ ),
1195
+ );
1196
+ request = { ...request, name, clients, skills };
1197
+ }
1198
+ const clientTargets = configuredTargetsForClients(manifest, request.clients);
1199
+ const targets = [...new Set([...request.targets, ...clientTargets])];
1200
+ const updated = upsertProject(manifest, {
1201
+ name: request.name,
1202
+ paths: [request.path],
1203
+ skills: request.skills,
1204
+ targets,
1205
+ });
1206
+ const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
1207
+ const plan = {
1208
+ mode: "project",
1209
+ project: request.name,
1210
+ path: request.path,
1211
+ skills: request.skills,
1212
+ clients: request.clients,
1213
+ targets,
1214
+ sync: request.sync,
1215
+ notes,
1216
+ };
749
1217
 
750
- let updated;
751
- if (core) {
752
- updated = subCommand === "pin" ? pinCore(manifest, skillId) : unpinCore(manifest, skillId);
1218
+ if (options.dryRun) {
1219
+ console.log(
1220
+ options.isJson
1221
+ ? JSON.stringify({ schema_version: 1, plan })
1222
+ : `project plan: ${JSON.stringify(plan)}`,
1223
+ );
1224
+ return;
1225
+ }
1226
+ if (!request.yes) {
1227
+ if (!options.isJson && isInteractive()) {
1228
+ if (guided) {
1229
+ console.log("\nReview");
1230
+ console.log(` project: ${request.name}`);
1231
+ console.log(` path: ${request.path}`);
1232
+ console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
1233
+ console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
1234
+ console.log(` sync: ${request.sync ? "yes" : "no"}`);
1235
+ }
1236
+ if (
1237
+ !(await confirmAction(
1238
+ `Apply project setup for ${request.name} at ${request.path}?`,
1239
+ ))
1240
+ ) {
1241
+ console.log("project setup cancelled");
1242
+ return;
1243
+ }
1244
+ } else {
1245
+ throw new Error(
1246
+ "skillmux project init requires --yes when run non-interactively",
1247
+ );
1248
+ }
1249
+ }
1250
+
1251
+ writeManifestAtomic(manifestPath, updated);
1252
+ if (request.sync) {
1253
+ try {
1254
+ await runSync([]);
1255
+ } catch (error) {
1256
+ throw new Error(
1257
+ `project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
1258
+ error instanceof Error ? error.message : String(error)
1259
+ }`,
1260
+ );
1261
+ }
1262
+ }
1263
+ if (options.isJson) {
1264
+ console.log(JSON.stringify({ schema_version: 1, result: plan }));
753
1265
  } else {
1266
+ console.log(`project "${request.name}" ready at ${request.path}`);
1267
+ }
1268
+ }
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) {
754
1287
  updated =
755
1288
  subCommand === "pin"
756
- ? pinProject(manifest, skillId, project!, paths)
757
- : unpinProject(manifest, skillId, project!);
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
+
1319
+ async function runTarget(
1320
+ subCommand: string,
1321
+ args: string[],
1322
+ options: { isJson: boolean; dryRun: boolean },
1323
+ ): Promise<void> {
1324
+ const { vaultPath, manifestPath, manifest } = await loadManifestContext();
1325
+
1326
+ if (subCommand === "list" || subCommand === "show") {
1327
+ const names =
1328
+ subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
1329
+ if (subCommand === "show" && !manifest.targets[names[0]!]) {
1330
+ throw new Error(`target "${names[0]}" does not exist`);
1331
+ }
1332
+ const targets = names.map((name) => {
1333
+ const target = manifest.targets[name]!;
1334
+ const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
1335
+ const surface = planClientSurfaces([client]).surfaces[0];
1336
+ return surface !== undefined && surface.path === expandHome(target.dir);
1337
+ });
1338
+ return { name, ...target, clients };
1339
+ });
1340
+ if (options.isJson) {
1341
+ console.log(JSON.stringify({ schema_version: 1, targets }));
1342
+ } else if (targets.length === 0) {
1343
+ console.log("no targets configured");
1344
+ } else {
1345
+ for (const target of targets) {
1346
+ console.log(`${target.name}:`);
1347
+ console.log(` dir: ${target.dir}`);
1348
+ console.log(` host: ${target.host ?? "(global)"}`);
1349
+ console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
1350
+ console.log(
1351
+ ` projects: ${target.project_groups.join(", ") || "(none)"}`,
1352
+ );
1353
+ }
1354
+ }
1355
+ return;
1356
+ }
1357
+
1358
+ if (subCommand === "add") {
1359
+ const name = args[0];
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");
1364
+ const path = expandHome(rawPath);
1365
+ if (options.dryRun) {
1366
+ const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
1367
+ console.log(
1368
+ options.isJson
1369
+ ? JSON.stringify({ schema_version: 1, target: planned.targets[name] })
1370
+ : `target add: ${name} -> ${path} (dry-run)`,
1371
+ );
1372
+ return;
1373
+ }
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;
1384
+ applyInit(vaultPath, [{ name, dir: path }]);
1385
+ console.log(`target "${name}" added at ${path}`);
1386
+ return;
1387
+ }
1388
+
1389
+ if (subCommand === "remove") {
1390
+ const name = args[0];
1391
+ if (!name || !manifest.targets[name]) {
1392
+ throw new Error(
1393
+ name
1394
+ ? `target "${name}" does not exist`
1395
+ : "usage: skillmux target remove <name> --yes",
1396
+ );
1397
+ }
1398
+ if (options.dryRun) {
1399
+ console.log(`target remove: ${name} (files preserved, dry-run)`);
1400
+ return;
1401
+ }
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;
1412
+ const targets = { ...manifest.targets };
1413
+ delete targets[name];
1414
+ writeManifestAtomic(manifestPath, { ...manifest, targets });
1415
+ console.log(
1416
+ `target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
1417
+ );
1418
+ return;
758
1419
  }
759
- validateManifest(updated, vaultPath, localVaultPaths);
760
- await Bun.write(manifestPath, serializeManifest(updated));
761
- console.log(`${subCommand === "pin" ? "pinned" : "unpinned"} "${skillId}" ${core ? "[core]" : `[project.${project}]`}`);
1420
+
1421
+ throw new Error("usage: skillmux target <list|show|add|remove>");
762
1422
  }
763
1423
 
764
- async function runLocalVaultInit(args: string[]): Promise<void> {
1424
+ async function runLocalVaultInit(
1425
+ args: string[],
1426
+ options: { isJson: boolean; dryRun: boolean },
1427
+ ): Promise<void> {
765
1428
  const path = args[0];
766
- if (!path) throw new Error("usage: skillmux local-vault init <path>");
1429
+ if (!path) throw new Error("usage: skillmux local-vault init <path> --yes");
767
1430
  const expanded = expandHome(path);
768
1431
  const config = await loadConfig();
769
1432
  const localVaultPaths = config.local_vault_paths.map(expandHome);
770
1433
  if (!localVaultPaths.includes(expanded)) {
771
- 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
+ );
772
1437
  }
773
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;
774
1462
  writeLocalVaultMarker(expanded, expandHome(config.vault_path));
775
- 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
+ }
776
1476
  }
777
1477
 
778
- async function runModelDownload(): Promise<void> {
1478
+ async function runModelDownload(options: { isJson: boolean }): Promise<void> {
779
1479
  const cacheDir = await downloadLocalModels(await loadConfig());
780
- console.log(`models ready in ${cacheDir}`);
1480
+ emitSuccess({ isJson: options.isJson }, { cache_dir: cacheDir }, () =>
1481
+ console.log(`models ready in ${cacheDir}`),
1482
+ );
781
1483
  }
782
1484
 
783
1485
  function parseSyncArgs(args: string[]): {
@@ -844,10 +1546,18 @@ async function runSync(args: string[]): Promise<void> {
844
1546
 
845
1547
  const suffix = dryRun ? " (dry-run)" : "";
846
1548
  const result = syncTarget(
847
- { vaultPath, targetDir, targetName, coreSkillIds: manifest.core.skills, localVaultPaths },
1549
+ {
1550
+ vaultPath,
1551
+ targetDir,
1552
+ targetName,
1553
+ coreSkillIds: manifest.core.skills,
1554
+ localVaultPaths,
1555
+ },
848
1556
  { dryRun },
849
1557
  );
850
- console.log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
1558
+ console.log(
1559
+ `${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
1560
+ );
851
1561
 
852
1562
  if (target.project_groups.length > 0) {
853
1563
  const allGroups = manifest.project ?? {};
@@ -873,6 +1583,8 @@ function parseInitArgs(args: string[]): {
873
1583
  coreSkillIds: string[];
874
1584
  customPath?: string;
875
1585
  migrateFullVault: boolean;
1586
+ skipInstructions: boolean;
1587
+ sync: boolean;
876
1588
  vaultPath?: string;
877
1589
  yes: boolean;
878
1590
  } {
@@ -881,6 +1593,8 @@ function parseInitArgs(args: string[]): {
881
1593
  const coreSkillIds: string[] = [];
882
1594
  let customPath: string | undefined;
883
1595
  let migrateFullVault = false;
1596
+ let skipInstructions = false;
1597
+ let sync = true;
884
1598
  let vaultPath: string | undefined;
885
1599
  let yes = false;
886
1600
  for (let i = 0; i < args.length; i++) {
@@ -900,9 +1614,9 @@ function parseInitArgs(args: string[]): {
900
1614
  if (!value) throw new Error("--vault requires a path");
901
1615
  vaultPath = value;
902
1616
  i++;
903
- } else if (option === "--path") {
1617
+ } else if (option === "--dir") {
904
1618
  const value = args[i + 1];
905
- if (!value) throw new Error("--path requires a directory");
1619
+ if (!value) throw new Error("--dir requires a directory");
906
1620
  customPath = value;
907
1621
  i++;
908
1622
  } else if (option === "--core") {
@@ -910,17 +1624,35 @@ function parseInitArgs(args: string[]): {
910
1624
  if (!value) throw new Error("--core requires a skill_id");
911
1625
  coreSkillIds.push(value);
912
1626
  i++;
913
- } else if (option === "--dry-run" || option === "--json") {
1627
+ } else if (
1628
+ option === "--dry-run" ||
1629
+ option === "--json" ||
1630
+ option === "--interactive"
1631
+ ) {
914
1632
  continue;
915
1633
  } else if (option === "--migrate-full-vault") {
916
1634
  migrateFullVault = true;
1635
+ } else if (option === "--no-instructions") {
1636
+ skipInstructions = true;
1637
+ } else if (option === "--no-sync") {
1638
+ sync = false;
917
1639
  } else if (option === "--yes") {
918
1640
  yes = true;
919
1641
  } else {
920
1642
  throw new Error(`unknown init option: ${option}`);
921
1643
  }
922
1644
  }
923
- return { targets, clients, coreSkillIds, customPath, migrateFullVault, vaultPath, yes };
1645
+ return {
1646
+ targets,
1647
+ clients,
1648
+ coreSkillIds,
1649
+ customPath,
1650
+ migrateFullVault,
1651
+ skipInstructions,
1652
+ sync,
1653
+ vaultPath,
1654
+ yes,
1655
+ };
924
1656
  }
925
1657
 
926
1658
  async function runInit(
@@ -933,18 +1665,28 @@ async function runInit(
933
1665
  coreSkillIds,
934
1666
  customPath,
935
1667
  migrateFullVault,
1668
+ skipInstructions,
1669
+ sync,
936
1670
  vaultPath: requestedVaultPath,
937
1671
  yes,
938
1672
  } = parseInitArgs(args);
1673
+ const guided = shouldUseWizard(args, {
1674
+ interactive: isInteractive(),
1675
+ json: options.isJson,
1676
+ dryRun: options.dryRun,
1677
+ });
939
1678
  migrateLegacyPaths();
940
1679
  const configPath = resolveConfigPath();
941
1680
  let configPlan: ConfigInitPlan | undefined;
942
1681
  let vaultPath: string;
943
1682
  if (!existsSync(configPath)) {
944
- const bootstrapVaultPath = requestedVaultPath ??
1683
+ const bootstrapVaultPath =
1684
+ requestedVaultPath ??
945
1685
  (!options.isJson && isInteractive() ? "~/skills" : undefined);
946
1686
  if (!bootstrapVaultPath) {
947
- 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
+ );
948
1690
  }
949
1691
  configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
950
1692
  vaultPath = configPlan.vaultPath;
@@ -966,12 +1708,51 @@ async function runInit(
966
1708
  throw new Error(vaultHealth.message);
967
1709
  }
968
1710
 
969
- const clientPlan = planClientSurfaces(requestedClients, {
970
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
971
- });
972
- const instructionPlan = planInstructionSetup(clientPlan.clients.map((client) => client.id), {
973
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1711
+ let selectedClients = requestedClients;
1712
+ if (guided) {
1713
+ const detected = detectInstalledClients({
1714
+ codexHome: process.env.CODEX_HOME
1715
+ ? expandHome(process.env.CODEX_HOME)
1716
+ : undefined,
1717
+ });
1718
+ const evidence = new Map(
1719
+ detected.map((item) => [item.client, item.evidence]),
1720
+ );
1721
+ selectedClients = await promptMultiSelect(
1722
+ "Which clients do you use?",
1723
+ SUPPORTED_CLIENT_IDS.map((client) => ({
1724
+ value: client,
1725
+ label: client,
1726
+ detail: evidence.has(client)
1727
+ ? `detected: ${evidence.get(client)}`
1728
+ : undefined,
1729
+ selected: evidence.has(client) || requestedClients.includes(client),
1730
+ })),
1731
+ );
1732
+ }
1733
+ let selectedCoreSkillIds = coreSkillIds;
1734
+ if (guided) {
1735
+ selectedCoreSkillIds = parseCommaList(
1736
+ await promptText(
1737
+ "Core skill IDs to add, comma-separated",
1738
+ coreSkillIds.join(","),
1739
+ ),
1740
+ );
1741
+ }
1742
+
1743
+ const clientPlan = planClientSurfaces(selectedClients, {
1744
+ codexHome: process.env.CODEX_HOME
1745
+ ? expandHome(process.env.CODEX_HOME)
1746
+ : undefined,
974
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
+ );
975
1756
  const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
976
1757
  for (const change of instructionPlan.changes) {
977
1758
  for (const client of change.clients) {
@@ -982,28 +1763,54 @@ async function runInit(
982
1763
  }
983
1764
  }
984
1765
  for (const manual of instructionPlan.manual) {
985
- instructionReadiness[manual.client] = { status: "manual", detail: manual.reason };
1766
+ instructionReadiness[manual.client] = {
1767
+ status: "manual",
1768
+ detail: manual.reason,
1769
+ };
986
1770
  }
987
- 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
+ ]);
988
1779
  const explicitSurfaceTargets = explicitTargets
989
1780
  .filter((name) => builtInNames.has(name))
990
1781
  .map((name) =>
991
1782
  resolveBuiltInTarget(name, {
992
- 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,
993
1786
  customPath: customPath ? expandHome(customPath) : undefined,
994
1787
  }),
995
1788
  );
996
1789
  if (customPath && !explicitTargets.includes("custom")) {
997
- throw new Error("--path may only be used with --target custom");
1790
+ throw new Error("--dir may only be used with --target custom");
998
1791
  }
999
1792
  for (const target of explicitSurfaceTargets) {
1000
1793
  if (target.warning) console.error(`warning: ${target.warning}`);
1001
1794
  }
1002
1795
  const targetByPath = new Map(
1003
- explicitSurfaceTargets.map((target) => [target.path, target.targetName] as const),
1796
+ explicitSurfaceTargets.map(
1797
+ (target) => [target.path, target.targetName] as const,
1798
+ ),
1004
1799
  );
1800
+ const existingManifestPath = resolveManifestPath(vaultPath);
1801
+ const existingManifest = existingManifestPath
1802
+ ? parseManifest(await Bun.file(existingManifestPath).text())
1803
+ : undefined;
1005
1804
  for (const surface of clientPlan.surfaces) {
1006
- if (!targetByPath.has(surface.path)) targetByPath.set(surface.path, surface.targetName);
1805
+ if (!targetByPath.has(surface.path)) {
1806
+ targetByPath.set(
1807
+ surface.path,
1808
+ existingManifest
1809
+ ? (configuredTargetForSurface(existingManifest, surface) ??
1810
+ surface.targetName)
1811
+ : surface.targetName,
1812
+ );
1813
+ }
1007
1814
  }
1008
1815
  const candidatePaths = [
1009
1816
  ...new Set([
@@ -1014,7 +1821,8 @@ async function runInit(
1014
1821
  const candidates = detectSurfaces(candidatePaths, vaultPath);
1015
1822
  if (!options.isJson) {
1016
1823
  for (const candidate of candidates) {
1017
- const name = targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1824
+ const name =
1825
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1018
1826
  if (candidate.state === "missing") {
1019
1827
  console.log(`${name} (${candidate.path}): not found`);
1020
1828
  continue;
@@ -1024,26 +1832,45 @@ async function runInit(
1024
1832
  continue;
1025
1833
  }
1026
1834
  if (candidate.state === "full-vault") {
1027
- console.log(`${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`);
1835
+ console.log(
1836
+ `${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`,
1837
+ );
1028
1838
  continue;
1029
1839
  }
1030
1840
  if (candidate.state === "external-symlink") {
1031
- console.log(`${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`);
1841
+ console.log(
1842
+ `${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`,
1843
+ );
1032
1844
  continue;
1033
1845
  }
1034
1846
  if (candidate.state === "unsupported") {
1035
- console.log(`${name} (${candidate.path}): unsupported filesystem entry`);
1847
+ console.log(
1848
+ `${name} (${candidate.path}): unsupported filesystem entry`,
1849
+ );
1036
1850
  continue;
1037
1851
  }
1038
1852
  const kind = "real dir";
1039
- const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
1040
- 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
+ );
1041
1859
  }
1042
- for (const readiness of assessClientReadiness(clientPlan, instructionReadiness)) {
1860
+ for (const readiness of assessClientReadiness(
1861
+ clientPlan,
1862
+ instructionReadiness,
1863
+ )) {
1043
1864
  console.log(`\n${readiness.client} readiness:`);
1044
- console.log(` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`);
1045
- console.log(` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`);
1046
- 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
+ );
1047
1874
  }
1048
1875
  for (const change of instructionPlan.changes) {
1049
1876
  console.log(
@@ -1068,26 +1895,34 @@ async function runInit(
1068
1895
  const hasChanges = !(
1069
1896
  requestedTargets.length === 0 &&
1070
1897
  !hasInstructionWrites &&
1071
- coreSkillIds.length === 0 &&
1898
+ selectedCoreSkillIds.length === 0 &&
1072
1899
  !hasConfigWrite
1073
1900
  );
1074
1901
 
1075
1902
  const byName = new Map(
1076
1903
  candidates
1077
- .filter((candidate) =>
1078
- candidate.deliveryMode === "managed-pins" ||
1079
- (migrateFullVault && candidate.state === "full-vault"),
1904
+ .filter(
1905
+ (candidate) =>
1906
+ candidate.deliveryMode === "managed-pins" ||
1907
+ (migrateFullVault && candidate.state === "full-vault"),
1080
1908
  )
1081
- .map((candidate) => [
1082
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1083
- candidate,
1084
- ] as const),
1909
+ .map(
1910
+ (candidate) =>
1911
+ [
1912
+ targetByPath.get(candidate.path) ??
1913
+ deriveTargetName(candidate.path),
1914
+ candidate,
1915
+ ] as const,
1916
+ ),
1085
1917
  );
1086
1918
  const allCandidatesByName = new Map(
1087
- candidates.map((candidate) => [
1088
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1089
- candidate,
1090
- ] as const),
1919
+ candidates.map(
1920
+ (candidate) =>
1921
+ [
1922
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1923
+ candidate,
1924
+ ] as const,
1925
+ ),
1091
1926
  );
1092
1927
  for (const name of requestedTargets) {
1093
1928
  if (!byName.has(name)) {
@@ -1096,7 +1931,9 @@ async function runInit(
1096
1931
  `target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
1097
1932
  );
1098
1933
  }
1099
- throw new Error(`unknown --target "${name}": not among detected surfaces`);
1934
+ throw new Error(
1935
+ `unknown --target "${name}": not among detected surfaces`,
1936
+ );
1100
1937
  }
1101
1938
  }
1102
1939
 
@@ -1108,7 +1945,11 @@ async function runInit(
1108
1945
  ...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
1109
1946
  };
1110
1947
  });
1111
- const plannedManifest = planInitManifest(vaultPath, confirmedTargets, coreSkillIds);
1948
+ const plannedManifest = planInitManifest(
1949
+ vaultPath,
1950
+ confirmedTargets,
1951
+ selectedCoreSkillIds,
1952
+ );
1112
1953
  const serializedPlan = {
1113
1954
  vault_path: vaultPath,
1114
1955
  config: configPlan
@@ -1126,44 +1967,50 @@ async function runInit(
1126
1967
  };
1127
1968
  if (!hasChanges) {
1128
1969
  if (options.isJson) {
1129
- console.log(JSON.stringify({
1130
- schema_version: 1,
1131
- ok: true,
1132
- command: "init",
1133
- phase: "plan",
1134
- dry_run: options.dryRun,
1135
- applied: false,
1136
- plan: serializedPlan,
1137
- }));
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
+ );
1138
1981
  } else {
1139
1982
  console.log("\nno managed-pins surface selected — nothing written.");
1140
1983
  }
1141
1984
  return;
1142
1985
  }
1143
1986
  if (!options.isJson) {
1144
- for (const target of confirmedTargets.filter((target) => target.migrateFullVault)) {
1987
+ for (const target of confirmedTargets.filter(
1988
+ (target) => target.migrateFullVault,
1989
+ )) {
1145
1990
  console.log(
1146
1991
  `full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
1147
- `${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`,
1148
1993
  );
1149
1994
  }
1150
1995
  }
1151
1996
  if (options.dryRun) {
1152
1997
  if (options.isJson) {
1153
- console.log(JSON.stringify({
1154
- schema_version: 1,
1155
- ok: true,
1156
- command: "init",
1157
- phase: "plan",
1158
- dry_run: true,
1159
- applied: false,
1160
- plan: serializedPlan,
1161
- }));
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
+ );
1162
2009
  } else {
1163
2010
  console.log(
1164
2011
  `\ndry-run: ${confirmedTargets.length} target(s), ` +
1165
- `${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
1166
- `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)"}`,
1167
2014
  );
1168
2015
  }
1169
2016
  return;
@@ -1171,19 +2018,44 @@ async function runInit(
1171
2018
 
1172
2019
  if (!yes) {
1173
2020
  if (!options.isJson && isInteractive()) {
1174
- const prompts = [
1175
- ...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
1176
- ...instructionPlan.changes
1177
- .filter((change) => change.status !== "unchanged")
1178
- .map((change) => `${change.status} instruction file ${change.path}?`),
1179
- ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1180
- ...(coreSkillIds.length > 0 ? [`Pin core skills: ${coreSkillIds.join(", ")}?`] : []),
1181
- ];
1182
- for (const prompt of prompts) {
1183
- if (!(await confirmAction(prompt))) {
1184
- console.log("init cancelled; nothing written");
2021
+ if (guided) {
2022
+ console.log("\nReview");
2023
+ console.log(` clients: ${selectedClients.join(", ") || "(none)"}`);
2024
+ console.log(
2025
+ ` targets: ${confirmedTargets.map((target) => `${target.name} -> ${target.dir}`).join(", ") || "(none)"}`,
2026
+ );
2027
+ console.log(
2028
+ ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
2029
+ );
2030
+ console.log(
2031
+ ` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`,
2032
+ );
2033
+ console.log(` sync: ${sync ? "yes" : "no"}`);
2034
+ if (!(await confirmAction("Apply this setup plan?"))) {
2035
+ console.log("init cancelled");
1185
2036
  return;
1186
2037
  }
2038
+ } else {
2039
+ const prompts = [
2040
+ ...confirmedTargets.map(
2041
+ (target) => `Adopt ${target.name} at ${target.dir}?`,
2042
+ ),
2043
+ ...instructionPlan.changes
2044
+ .filter((change) => change.status !== "unchanged")
2045
+ .map(
2046
+ (change) => `${change.status} instruction file ${change.path}?`,
2047
+ ),
2048
+ ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
2049
+ ...(selectedCoreSkillIds.length > 0
2050
+ ? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
2051
+ : []),
2052
+ ];
2053
+ for (const prompt of prompts) {
2054
+ if (!(await confirmAction(prompt))) {
2055
+ console.log("init cancelled; nothing written");
2056
+ return;
2057
+ }
2058
+ }
1187
2059
  }
1188
2060
  } else {
1189
2061
  throw new Error(
@@ -1214,7 +2086,7 @@ async function runInit(
1214
2086
  if (configCreated && configPlan) rollbackConfigInit(configPlan);
1215
2087
  };
1216
2088
 
1217
- if (confirmedTargets.length === 0 && coreSkillIds.length === 0) {
2089
+ if (confirmedTargets.length === 0 && selectedCoreSkillIds.length === 0) {
1218
2090
  applyAdditional();
1219
2091
  } else {
1220
2092
  applyInit(
@@ -1226,46 +2098,58 @@ async function runInit(
1226
2098
  rollback: rollbackAdditional,
1227
2099
  }
1228
2100
  : undefined,
1229
- coreSkillIds,
2101
+ selectedCoreSkillIds,
1230
2102
  );
1231
2103
  }
1232
2104
 
1233
2105
  if (options.isJson) {
1234
- console.log(JSON.stringify({
1235
- schema_version: 1,
1236
- ok: true,
1237
- command: "init",
1238
- phase: "result",
1239
- dry_run: false,
1240
- applied: true,
1241
- plan: serializedPlan,
1242
- result: {
1243
- config_created: configCreated,
1244
- targets_adopted: confirmedTargets.map((target) => target.name),
1245
- instructions_changed: instructionPlan.changes
1246
- .filter((change) => change.status !== "unchanged")
1247
- .map((change) => change.path),
1248
- core: plannedManifest.core.skills,
1249
- },
1250
- }));
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
+ );
1251
2125
  return;
1252
2126
  }
1253
2127
  if (configCreated) console.log(`created ${configPath}`);
1254
2128
  if (confirmedTargets.length > 0) {
1255
- console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
1256
- } else if (coreSkillIds.length > 0) {
2129
+ console.log(
2130
+ `\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`,
2131
+ );
2132
+ } else if (selectedCoreSkillIds.length > 0) {
1257
2133
  console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
1258
2134
  }
1259
2135
  if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
1260
- console.log("next: skillmux manifest pin <skill_id> --core");
2136
+ console.log("next: skillmux core pin <skill_id> --yes");
1261
2137
  }
1262
2138
  if (confirmedTargets.length > 0) console.log("next: skillmux sync");
1263
- if (requestedClients.length === 0 || requestedClients.includes("skillmux-mcp")) {
2139
+ if (
2140
+ selectedClients.length === 0 ||
2141
+ selectedClients.includes("skillmux-mcp")
2142
+ ) {
1264
2143
  console.log(`\n${printLastMile()}`);
1265
2144
  }
2145
+ if (guided && sync && confirmedTargets.length > 0) await runSync([]);
1266
2146
  }
1267
2147
 
1268
- 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
+ } {
1269
2153
  let server: string | undefined;
1270
2154
  let db: string | undefined;
1271
2155
  let since: string | undefined;
@@ -1284,6 +2168,8 @@ function parseReportArgs(args: string[]): { server?: string; db?: string; since?
1284
2168
  if (!value) throw new Error("--since requires a window");
1285
2169
  since = value;
1286
2170
  i++;
2171
+ } else if (option === "--json") {
2172
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1287
2173
  } else {
1288
2174
  throw new Error(`unknown report option: ${option}`);
1289
2175
  }
@@ -1292,24 +2178,45 @@ function parseReportArgs(args: string[]): { server?: string; db?: string; since?
1292
2178
  return { server, db, since };
1293
2179
  }
1294
2180
 
1295
- async function runReport(args: string[]): Promise<void> {
2181
+ async function runReport(
2182
+ args: string[],
2183
+ options: { isJson: boolean },
2184
+ ): Promise<void> {
1296
2185
  const { server, db: dbPath, since } = parseReportArgs(args);
1297
- 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
+ );
1298
2190
 
1299
2191
  if (server) {
1300
2192
  const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
1301
2193
  const res = await fetch(url);
1302
- if (!res.ok) throw new Error(`skillmux report --server failed: ${res.status} ${await res.text()}`);
1303
- 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
+ );
1304
2202
  return;
1305
2203
  }
1306
2204
 
1307
- const db = dbPath ? new Database(dbPath, { readonly: true }) : openIndex(expandHome((await loadConfig()).state_dir));
1308
- 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
+ );
1309
2212
  db.close();
1310
2213
  }
1311
2214
 
1312
- 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
+ } {
1313
2220
  let path: string | undefined;
1314
2221
  let format: "text" | "json" = "text";
1315
2222
  let failOn: ScanSeverity | undefined;
@@ -1317,7 +2224,8 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1317
2224
  const option = args[i];
1318
2225
  if (option === "--format") {
1319
2226
  const value = args[++i];
1320
- 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");
1321
2229
  format = value;
1322
2230
  } else if (option === "--fail-on") {
1323
2231
  const value = args[++i];
@@ -1325,6 +2233,8 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1325
2233
  throw new Error("--fail-on must be low, medium, or high");
1326
2234
  }
1327
2235
  failOn = value;
2236
+ } else if (option === "--json") {
2237
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1328
2238
  } else if (option?.startsWith("--")) {
1329
2239
  throw new Error(`unknown scan option: ${option}`);
1330
2240
  } else if (path !== undefined) {
@@ -1336,11 +2246,20 @@ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"
1336
2246
  return { path, format, failOn };
1337
2247
  }
1338
2248
 
1339
- async function runScan(args: string[]): Promise<void> {
2249
+ async function runScan(
2250
+ args: string[],
2251
+ options: { isJson: boolean },
2252
+ ): Promise<void> {
1340
2253
  const { path, format, failOn } = parseScanArgs(args);
1341
- const rootPath = path ? expandHome(path) : expandHome((await loadConfig()).vault_path);
2254
+ const rootPath = path
2255
+ ? expandHome(path)
2256
+ : expandHome((await loadConfig()).vault_path);
1342
2257
  const result = await scanPath(rootPath);
1343
- 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
+ });
1344
2263
  process.exitCode = scanExitCode(result.findings, failOn);
1345
2264
  }
1346
2265
 
@@ -1364,6 +2283,8 @@ function parseInstallArgs(args: string[]): {
1364
2283
  throw new Error("--fail-on must be low, medium, or high");
1365
2284
  }
1366
2285
  failOn = value;
2286
+ } else if (option === "--json") {
2287
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1367
2288
  } else if (option?.startsWith("--")) {
1368
2289
  throw new Error(`unknown install option: ${option}`);
1369
2290
  } else if (repo !== undefined) {
@@ -1375,39 +2296,73 @@ function parseInstallArgs(args: string[]): {
1375
2296
  return { repo, force, dryRun, failOn };
1376
2297
  }
1377
2298
 
1378
- async function runInstall(args: string[]): Promise<void> {
2299
+ async function runInstall(
2300
+ args: string[],
2301
+ options: { isJson: boolean },
2302
+ ): Promise<void> {
1379
2303
  const { repo, force, dryRun, failOn } = parseInstallArgs(args);
1380
2304
  if (!repo) {
1381
- 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
+ );
1382
2308
  }
1383
2309
 
1384
2310
  const source = resolveRepoSource(repo);
1385
2311
  const cloneDir = await cloneToTemp(source.url);
1386
2312
  try {
1387
- const resolved = resolveSkillDir(cloneDir, deriveRepoName(source.url), source.skillPath);
1388
- const { findings } = await validateSkillCandidate(resolved.skillId, resolved.dir);
1389
- 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 }));
1390
2323
 
1391
2324
  if (scanExitCode(findings, failOn) !== 0) {
1392
2325
  process.exitCode = 1;
1393
- 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
+ );
1394
2329
  return;
1395
2330
  }
1396
2331
 
1397
2332
  const vaultPath = expandHome((await loadConfig()).vault_path);
1398
2333
  if (dryRun) {
1399
- 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
+ );
1400
2343
  return;
1401
2344
  }
1402
2345
 
1403
- const targetDir = installIntoVault(vaultPath, resolved.skillId, resolved.dir, force);
1404
- 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
+ );
1405
2357
  } finally {
1406
2358
  rmSync(cloneDir, { recursive: true, force: true });
1407
2359
  }
1408
2360
  }
1409
2361
 
1410
- function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; out?: string } {
2362
+ function parseCalibrateGenerateDatasetArgs(args: string[]): {
2363
+ vault?: string;
2364
+ out?: string;
2365
+ } {
1411
2366
  let vault: string | undefined;
1412
2367
  let out: string | undefined;
1413
2368
  for (let i = 0; i < args.length; i++) {
@@ -1428,7 +2383,8 @@ function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; ou
1428
2383
  }
1429
2384
 
1430
2385
  async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
1431
- const { vault: vaultArg, out: outArg } = parseCalibrateGenerateDatasetArgs(args);
2386
+ const { vault: vaultArg, out: outArg } =
2387
+ parseCalibrateGenerateDatasetArgs(args);
1432
2388
  const config = await loadConfig();
1433
2389
  const vaultPath = expandHome(vaultArg ?? config.vault_path);
1434
2390
  const outPath = expandHome(outArg ?? join(config.state_dir, "queries.json"));
@@ -1439,7 +2395,9 @@ async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
1439
2395
  const parentDir = join(outPath, "..");
1440
2396
  mkdirSync(parentDir, { recursive: true });
1441
2397
  await Bun.write(outPath, JSON.stringify(dataset, null, 2) + "\n");
1442
- 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
+ );
1443
2401
  }
1444
2402
 
1445
2403
  if (import.meta.main) {