@klhapp/skillmux 1.9.3 → 1.10.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,127 +1,49 @@
1
1
  #!/usr/bin/env bun
2
2
  import packageJson from "../package.json" with { type: "json" };
3
- import { Database } from "bun:sqlite";
4
- import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
5
- import { hostname } from "node:os";
6
- import { join } from "node:path";
3
+ import { lstatSync, mkdirSync } from "node:fs";
7
4
 
8
5
  import { createClients } from "./clients";
9
- import {
10
- expandHome,
11
- loadConfig,
12
- migrateLegacyPaths,
13
- resolveConfigPath,
14
- } from "./config";
6
+ import { loadConfig } from "./config";
15
7
  import { openAudit } from "./db";
16
- import { diagnose } from "./doctor";
17
8
  import { getEffectiveConfig } from "./config-service";
18
9
  import { buildRedactor } from "./redact";
19
10
  import { evalVault } from "./eval";
20
- import {
21
- assessClientReadiness,
22
- detectInstalledClients,
23
- planClientSurfaces,
24
- resolveBuiltInTarget,
25
- SUPPORTED_CLIENT_IDS,
26
- type ClientId,
27
- type ReadinessAxis,
28
- } from "./init-clients";
29
- import {
30
- applyInstructionPlan,
31
- planInstructionSetup,
32
- rollbackInstructionPlan,
33
- } from "./init-instructions";
34
- import {
35
- applyInit,
36
- deriveTargetName,
37
- detectSurfaces,
38
- planInitManifest,
39
- printLastMile,
40
- surfaceCandidates,
41
- } from "./init";
42
- import {
43
- assertHostAllowed,
44
- cloneToTemp,
45
- deriveRepoName,
46
- installIntoVault,
47
- isLocalFileUrl,
48
- resolveCloneCommit,
49
- resolveRepoSource,
50
- resolveSkillDir,
51
- validateSkillCandidate,
52
- } from "./install";
53
11
  import { runOutdated } from "./commands/outdated";
54
12
  import { runUpdate } from "./commands/update";
55
- import { hashSkillContent, writeSkillOrigin } from "./provenance";
56
- import {
57
- parseManifest,
58
- resolveManifestPath,
59
- serializeManifest,
60
- validateManifest,
61
- } from "./manifest";
62
- import { downloadLocalModels } from "./models";
13
+ import { serializeManifest } from "./manifest";
63
14
 
64
- import {
65
- parseCommaList,
66
- promptMultiSelect,
67
- promptText,
68
- shouldUseWizard,
69
- } from "./prompts";
70
15
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
71
- import {
72
- renderScanJson,
73
- renderScanText,
74
- scanExitCode,
75
- scanPath,
76
- type ScanSeverity,
77
- } from "./scan";
78
- import {
79
- applyConfigInit,
80
- inspectVault,
81
- planConfigInit,
82
- rollbackConfigInit,
83
- type ConfigInitPlan,
84
- } from "./setup";
85
- import { getStats, renderStatsText, type StatsResponse } from "./stats";
86
- import {
87
- installPostMergeHook,
88
- resolveProjectPinDir,
89
- restoreMonolith as restoreMonolithTarget,
90
- syncProjectTargets,
91
- syncTarget,
92
- writeLocalVaultMarker,
93
- type ProjectGroupInput,
94
- } from "./sync";
95
- import { scanVault, vaultResolutionOrder } from "./vault";
16
+ import { type StatsResponse } from "./stats";
17
+ import { scanVault } from "./vault";
96
18
 
97
- import {
98
- addContext,
99
- getCurrentContext,
100
- listContexts,
101
- removeContext,
102
- resolveTarget,
103
- useContext,
104
- type ResolvedTarget,
105
- } from "./context";
19
+ import { resolveContext, type ResolvedContext } from "./context";
106
20
  import { createTargetAdapter, isLoopbackHost, type TargetAdapter } from "./adapters";
107
21
  import {
108
22
  emitSuccess,
109
23
  CliError,
110
24
  formatJsonEnvelope,
111
- isInteractive,
112
25
  mapExitCode,
113
- renderTable,
114
- renderTargetBanner,
26
+ red,
115
27
  suggestCorrection,
28
+ warn,
116
29
  } from "./output";
117
30
  import { generateCompletions, type ShellType } from "./completions";
118
31
  import { runAudit } from "./commands/audit";
119
32
  import { handleConfigCommand } from "./commands/config";
33
+ import { handleContextCommand } from "./commands/context";
34
+ import { runDoctor } from "./commands/doctor";
120
35
  import { runEvalPromote } from "./commands/eval";
36
+ import { runInstall } from "./commands/install";
121
37
  import { runCore } from "./commands/core";
122
- import { configuredTargetForSurface, runProject } from "./commands/project";
123
- import { confirmAction, confirmIfNeeded } from "./commands/shared";
38
+ import { runLocalVaultInit } from "./commands/local-vault";
39
+ import { runModelDownload } from "./commands/models";
40
+ import { runProject } from "./commands/project";
41
+ import { runReport } from "./commands/report";
42
+ import { runScan } from "./commands/scan";
43
+ import { runSkill } from "./commands/skill";
124
44
  import { runTarget } from "./commands/target";
45
+ import { runSync } from "./commands/sync";
46
+ import { runInit } from "./commands/init";
125
47
 
126
48
  const KNOWN_COMMANDS = [
127
49
  "context",
@@ -147,6 +69,43 @@ const KNOWN_COMMANDS = [
147
69
  "local-vault",
148
70
  ];
149
71
 
72
+ const LOCAL_ONLY_COMMANDS = new Set([
73
+ "install",
74
+ "update",
75
+ "outdated",
76
+ "sync",
77
+ "core",
78
+ "project",
79
+ "target",
80
+ "local-vault",
81
+ "index",
82
+ "models",
83
+ "scan",
84
+ "init",
85
+ "serve",
86
+ ]);
87
+
88
+ function getLocalOnlyCommand(command: string, subCommand: string): string | null {
89
+ if (LOCAL_ONLY_COMMANDS.has(command)) {
90
+ return command;
91
+ }
92
+ if (command === "skill" && (subCommand === "which" || !subCommand)) {
93
+ return "skill which";
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function remoteContextUnsupported(rejectedCommand: string): CliError {
99
+ return new CliError(
100
+ `\`${rejectedCommand}\` operates on the local vault only; --context/--server isn't supported here`,
101
+ 2,
102
+ "REMOTE_CONTEXT_UNSUPPORTED",
103
+ {
104
+ rejected_command: rejectedCommand,
105
+ },
106
+ );
107
+ }
108
+
150
109
  function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
151
110
  if (
152
111
  [
@@ -167,7 +126,7 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
167
126
  }
168
127
 
169
128
  // eval promote only touches the mounted state_dir, unlike bare `eval`
170
- // (vault ranking evaluation), which needs local embeddings and the vault.
129
+ // (vault ranking evaluation), which needs an embeddings client and the vault.
171
130
  if (command === "eval" && subCommand !== "promote") return true;
172
131
 
173
132
  return command === "config" && ["init", "set"].includes(subCommand);
@@ -237,7 +196,7 @@ async function main() {
237
196
  else if (arg === "--server") flagServer = rawArgv[++i];
238
197
  }
239
198
 
240
- let resolvedTarget: ResolvedTarget = { type: "local", name: "local" };
199
+ let resolvedTarget: ResolvedContext = { type: "local", name: "local" };
241
200
 
242
201
  if (
243
202
  process.env.RUNNING_IN_DOCKER === "true" &&
@@ -251,23 +210,31 @@ async function main() {
251
210
  return;
252
211
  }
253
212
 
254
- // Only resolve target if command is target-aware or context/config
255
- const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
256
213
  if (
257
- (["context", "config"].includes(command) &&
258
- !isLocalConfigInit) ||
259
- flagContext ||
260
- flagServer
214
+ (rawArgv.includes("--help") || rawArgv.includes("-h")) &&
215
+ printCommandHelp(command)
261
216
  ) {
262
- try {
263
- resolvedTarget = await resolveTarget({
264
- context: flagContext,
265
- server: flagServer,
266
- });
267
- } catch (err: any) {
268
- await handleError(err, { target: resolvedTarget, isJson, isVerbose });
269
- return;
270
- }
217
+ return;
218
+ }
219
+
220
+ try {
221
+ resolvedTarget = await resolveContext({
222
+ context: flagContext,
223
+ server: flagServer,
224
+ });
225
+ } catch (err: any) {
226
+ await handleError(err, { target: resolvedTarget, isJson, isVerbose });
227
+ return;
228
+ }
229
+
230
+ const localOnlyCommand = getLocalOnlyCommand(command, subCommand);
231
+ if (localOnlyCommand && resolvedTarget.type === "remote") {
232
+ await handleError(remoteContextUnsupported(localOnlyCommand), {
233
+ target: resolvedTarget,
234
+ isJson,
235
+ isVerbose,
236
+ });
237
+ return;
271
238
  }
272
239
 
273
240
  const adapter = createTargetAdapter(resolvedTarget, { allowInsecure });
@@ -343,10 +310,16 @@ async function main() {
343
310
  isJson,
344
311
  target: resolvedTarget,
345
312
  allowInsecure,
313
+ adapter,
346
314
  });
347
315
  break;
348
316
  case "audit":
349
- await runAudit(subCommand, commandArgs, { isJson, dryRun: isDryRun });
317
+ await runAudit(subCommand, commandArgs, {
318
+ isJson,
319
+ dryRun: isDryRun,
320
+ target: resolvedTarget,
321
+ adapter,
322
+ });
350
323
  break;
351
324
  case "scan":
352
325
  await runScan(rawArgv.slice(1), { isJson });
@@ -362,15 +335,15 @@ async function main() {
362
335
  break;
363
336
  case "eval":
364
337
  if (subCommand === "promote") {
365
- await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
338
+ await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun, adapter });
366
339
  } else if (subCommand === "") {
367
- await runEval({ isJson });
340
+ await runEval({ isJson, adapter });
368
341
  } else {
369
342
  throw new Error(`usage: skillmux eval [promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]]`);
370
343
  }
371
344
  break;
372
345
  case "doctor":
373
- await runDoctor({ isJson });
346
+ await runDoctor({ isJson, target: resolvedTarget, adapter });
374
347
  break;
375
348
  case "which":
376
349
  throw new Error(
@@ -406,95 +379,7 @@ async function main() {
406
379
  }
407
380
  }
408
381
 
409
- async function handleContextCommand(
410
- sub: string,
411
- args: string[],
412
- ctx: { target: ResolvedTarget; isJson: boolean },
413
- ) {
414
- if (sub === "list") {
415
- const contexts = await listContexts();
416
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, contexts, () => {
417
- renderTargetBanner(ctx.target);
418
- renderTable(
419
- [
420
- { key: "name", header: "NAME" },
421
- { key: "server", header: "SERVER" },
422
- { key: "token_env", header: "TOKEN_ENV" },
423
- { key: "isDefault", header: "DEFAULT" },
424
- ],
425
- contexts.map((c) => ({
426
- ...c,
427
- token_env: c.token_env ?? "-",
428
- isDefault: c.isDefault ? "*" : "",
429
- })),
430
- );
431
- });
432
- return;
433
- }
434
-
435
- if (sub === "current") {
436
- const current = await getCurrentContext();
437
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, current, () => {
438
- renderTargetBanner(ctx.target);
439
- console.log(`Current context: ${current.name} (${current.server})`);
440
- });
441
- return;
442
- }
443
-
444
- if (sub === "add") {
445
- const name = args[0];
446
- let server: string | undefined;
447
- let tokenEnv: string | undefined;
448
- for (let i = 1; i < args.length; i++) {
449
- if (args[i] === "--server") server = args[++i];
450
- else if (args[i] === "--token-env") tokenEnv = args[++i];
451
- }
452
- if (!name || !server) {
453
- throw new Error(
454
- "usage: skillmux context add <name> --server <url> [--token-env <env_name>]",
455
- );
456
- }
457
- await addContext(name, { server, token_env: tokenEnv });
458
- emitSuccess(
459
- { isJson: ctx.isJson, target: ctx.target },
460
- { name, server, token_env: tokenEnv },
461
- () => {
462
- console.log(`Added context "${name}" -> ${server}`);
463
- },
464
- );
465
- return;
466
- }
467
-
468
- if (sub === "use") {
469
- const name = args[0];
470
- if (!name) throw new Error("usage: skillmux context use <name>");
471
- await useContext(name);
472
- emitSuccess(
473
- { isJson: ctx.isJson, target: ctx.target },
474
- { default_context: name },
475
- () => {
476
- console.log(`Switched default context to "${name}"`);
477
- },
478
- );
479
- return;
480
- }
481
-
482
- if (sub === "remove") {
483
- const name = args[0];
484
- if (!name) throw new Error("usage: skillmux context remove <name>");
485
- await removeContext(name);
486
- emitSuccess(
487
- { isJson: ctx.isJson, target: ctx.target },
488
- { removed: name },
489
- () => {
490
- console.log(`Removed context "${name}"`);
491
- },
492
- );
493
- return;
494
- }
495
382
 
496
- throw new Error("usage: skillmux context <add|list|current|use|remove>");
497
- }
498
383
 
499
384
  async function handleCompletionsCommand(shell: string) {
500
385
  if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
@@ -505,7 +390,7 @@ async function handleCompletionsCommand(shell: string) {
505
390
 
506
391
  async function handleError(
507
392
  err: any,
508
- opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean },
393
+ opts: { target: ResolvedContext; isJson: boolean; isVerbose: boolean },
509
394
  ) {
510
395
  const code = mapExitCode(err);
511
396
  process.exitCode = code;
@@ -536,11 +421,13 @@ async function handleError(
536
421
  console.log(JSON.stringify(env));
537
422
  } else {
538
423
  console.error(
539
- msg.startsWith("usage:") ||
540
- msg.startsWith("Unknown") ||
541
- msg.startsWith("error:")
542
- ? msg
543
- : `error: ${msg}`,
424
+ red(
425
+ msg.startsWith("usage:") ||
426
+ msg.startsWith("Unknown") ||
427
+ msg.startsWith("error:")
428
+ ? msg
429
+ : `error: ${msg}`,
430
+ ),
544
431
  );
545
432
  if (opts.isVerbose && err instanceof Error && err.stack) {
546
433
  console.error(redact(err.stack));
@@ -548,6 +435,163 @@ async function handleError(
548
435
  }
549
436
  }
550
437
 
438
+ const COMMAND_HELP: Record<string, string> = {
439
+ context: `context: manage named CLI contexts for remote administration
440
+
441
+ usage:
442
+ skillmux context list
443
+ skillmux context current
444
+ skillmux context add <name> --server <url> [--token-env <env_name>]
445
+ skillmux context use <name>
446
+ skillmux context remove <name>`,
447
+
448
+ config: `config: inspect or update server/machine configuration
449
+
450
+ usage:
451
+ skillmux config init --vault <path> --yes
452
+ skillmux config show [--sources]
453
+ skillmux config get <key>
454
+ skillmux config set <key> <value> [--dry-run]
455
+ skillmux config validate
456
+ skillmux config diff
457
+ skillmux config status
458
+
459
+ Accepts --context <name> / --server <url> to target a remote deployment.`,
460
+
461
+ completions: `completions: generate a shell completion script
462
+
463
+ usage:
464
+ skillmux completions <bash|zsh|fish>`,
465
+
466
+ serve: `serve: start the MCP server
467
+
468
+ usage:
469
+ skillmux serve [--transport stdio|http] [--port <port>] [--stats-port <port>]
470
+
471
+ --transport defaults to stdio. --stats-port exposes GET /health and GET /stats
472
+ alongside a stdio transport without opening the full HTTP surface.`,
473
+
474
+ index: `index: rebuild the local retrieval index and backfill embeddings
475
+
476
+ usage:
477
+ skillmux index`,
478
+
479
+ sync: `sync: apply the manifest to native client target directories
480
+
481
+ usage:
482
+ skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]`,
483
+
484
+ init: `init: guided setup for native skill management
485
+
486
+ usage:
487
+ skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
488
+ [--vault <path>] [--core <skill_id>...]
489
+ [--migrate-full-vault] [--no-instructions] [--no-sync]
490
+ [--interactive|--yes|--dry-run] [--json]
491
+
492
+ clients: claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
493
+ antigravity, goose, hermes, skillmux-mcp
494
+ targets: agent-skills, claude-code, codex, custom`,
495
+
496
+ project: `project: manage project-scoped skill pins and sync groups
497
+
498
+ usage:
499
+ skillmux project init [path] [--name <group>] [--skill <skill_id>...]
500
+ [--client <name>...] [--target <name>...] [--no-sync]
501
+ [--interactive|--yes|--dry-run] [--json]
502
+ skillmux project list
503
+ skillmux project show <group>
504
+ skillmux project add-path <group> [path] --yes
505
+ skillmux project remove-path <group> [path] --yes
506
+ skillmux project pin <group> <skill_id>... --yes
507
+ skillmux project unpin <group> <skill_id>... --yes
508
+ skillmux project attach <group> (--client <id>... | --target <name>...) --yes
509
+ skillmux project detach <group> (--client <id>... | --target <name>...) --yes`,
510
+
511
+ target: `target: manage native sync target directories
512
+
513
+ usage:
514
+ skillmux target list
515
+ skillmux target show <name>
516
+ skillmux target add <name> --dir <dir> --yes
517
+ skillmux target remove <name> --yes`,
518
+
519
+ core: `core: pin or unpin core-tier skills
520
+
521
+ usage:
522
+ skillmux core pin <skill_id>... --yes
523
+ skillmux core unpin <skill_id>... --yes`,
524
+
525
+ report: `report: show routing/fetch-outcome audit statistics
526
+
527
+ usage:
528
+ skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]`,
529
+
530
+ audit: `audit: prune the audit database
531
+
532
+ usage:
533
+ skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
534
+
535
+ Accepts --context <name> / --server <url> to prune a remote deployment's audit db.`,
536
+
537
+ scan: `scan: check the vault for install-time or integrity issues
538
+
539
+ usage:
540
+ skillmux scan [path] [--format text|json] [--fail-on low|medium|high] [--json]`,
541
+
542
+ install: `install: install a skill from a git source
543
+
544
+ usage:
545
+ skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]`,
546
+
547
+ outdated: `outdated: list installed skills with a newer upstream version
548
+
549
+ usage:
550
+ skillmux outdated [--allow-local-source] [--json]`,
551
+
552
+ update: `update: update one or all skills to their latest source version
553
+
554
+ usage:
555
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]`,
556
+
557
+ eval: `eval: run retrieval evaluation against the holdout set
558
+
559
+ usage:
560
+ skillmux eval [--json]
561
+ skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
562
+
563
+ Accepts --context <name> / --server <url> to evaluate a remote deployment.`,
564
+
565
+ doctor: `doctor: check server/environment readiness
566
+
567
+ usage:
568
+ skillmux doctor [--json]
569
+
570
+ Accepts --context <name> / --server <url> to check a remote deployment.`,
571
+
572
+ skill: `skill: inspect local vault skill resolution
573
+
574
+ usage:
575
+ skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)`,
576
+
577
+ "local-vault": `local-vault: register an additional local vault checkout
578
+
579
+ usage:
580
+ skillmux local-vault init <path> --yes`,
581
+
582
+ models: `models: manage local embedding model downloads
583
+
584
+ usage:
585
+ skillmux models download`,
586
+ };
587
+
588
+ function printCommandHelp(command: string): boolean {
589
+ const help = COMMAND_HELP[command];
590
+ if (!help) return false;
591
+ console.log(help);
592
+ return true;
593
+ }
594
+
551
595
  function printHelp(): void {
552
596
  if (process.env.RUNNING_IN_DOCKER === "true") {
553
597
  console.log(`Skillmux server image
@@ -580,7 +624,7 @@ Setup:
580
624
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
581
625
  skillmux target <list|show|add|remove>
582
626
  skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
583
- skillmux skill which <skill_id>
627
+ skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
584
628
 
585
629
  Init clients:
586
630
  claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
@@ -598,7 +642,9 @@ Operations:
598
642
 
599
643
  Commands:
600
644
  serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
601
- eval, doctor, skill, local-vault, config, models, context, completions`);
645
+ eval, doctor, skill, local-vault, config, models, context, completions
646
+
647
+ Run "skillmux <command> --help" for a command's full usage.`);
602
648
  }
603
649
 
604
650
  // ---------------------------------------------------------------------------
@@ -649,9 +695,7 @@ async function runIndex(): Promise<void> {
649
695
  const config = await loadConfig();
650
696
  configure({ config, clients: createClients(config) });
651
697
  const report = await rebuildIndex((skillId, error) => {
652
- console.error(
653
- `warning: keeping previous index entry for ${skillId}: ${error}`,
654
- );
698
+ warn(`keeping previous index entry for ${skillId}: ${error}`);
655
699
  });
656
700
  const retainedNote =
657
701
  report.retained.length > 0
@@ -669,12 +713,14 @@ async function runIndex(): Promise<void> {
669
713
  }
670
714
  }
671
715
 
672
- async function runEval(options: { isJson: boolean }): Promise<void> {
716
+ async function runEval(options: { isJson: boolean; adapter: TargetAdapter }): Promise<void> {
673
717
  const config = await loadConfig();
674
718
  configure({ config, clients: createClients(config) });
675
719
 
676
- const report = await evalVault().catch((error: unknown) => {
677
- throw new Error(`eval requires local embeddings: ${String(error)}`);
720
+ const report = await options.adapter.evalRun().catch((error: unknown) => {
721
+ throw new Error(
722
+ `eval requires an embeddings client (local model or a configured remote endpoint): ${String(error)}`,
723
+ );
678
724
  });
679
725
  emitSuccess({ isJson: options.isJson }, report, () => {
680
726
  console.log(`holdout queries: ${report.queries}`);
@@ -691,1085 +737,6 @@ async function runEval(options: { isJson: boolean }): Promise<void> {
691
737
  });
692
738
  }
693
739
 
694
- async function runDoctor(options: { isJson: boolean }): Promise<void> {
695
- const effective = await getEffectiveConfig(resolveConfigPath());
696
- const report = await diagnose(effective.effective, process.env, effective.sources);
697
- emitSuccess({ isJson: options.isJson }, report, () => {
698
- console.log(`version: ${report.version}`);
699
- console.log(`runtime: ${report.runtime}`);
700
- console.log(`image variant: ${report.image_variant ?? "none"}`);
701
- console.log(`vault path: ${report.vault_path}`);
702
- console.log(`state directory: ${report.state_dir}`);
703
- console.log(`inference mode: ${report.mode}`);
704
- console.log(`routing capability: ${report.capability}`);
705
- console.log(`retrieval capability: ${report.retrieval_capability}`);
706
- for (const check of report.checks)
707
- console.log(
708
- `${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
709
- );
710
- });
711
- if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
712
- }
713
-
714
- async function runSkill(subCommand: string, args: string[]): Promise<void> {
715
- if (subCommand !== "which") throw new Error("usage: skillmux skill <which>");
716
- await runWhich(args);
717
- }
718
-
719
- async function runWhich(args: string[]): Promise<void> {
720
- const skillId = args[0];
721
- if (!skillId) throw new Error("usage: skillmux skill which <skill_id>");
722
- const config = await loadConfig();
723
- const vaultPath = expandHome(config.vault_path);
724
- const localVaultPaths = config.local_vault_paths.map(expandHome);
725
- const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
726
- (root) => existsSync(join(root, skillId, "SKILL.md")),
727
- );
728
- if (roots.length === 0) {
729
- console.log(`${skillId}: not found in vault_path or local_vault_paths`);
730
- process.exitCode = 1;
731
- return;
732
- }
733
- console.log(`${skillId}: serving from ${roots[0]}`);
734
- for (const shadowedRoot of roots.slice(1))
735
- console.log(` shadows: ${shadowedRoot}`);
736
- }
737
-
738
- async function runLocalVaultInit(
739
- args: string[],
740
- options: { isJson: boolean; dryRun: boolean },
741
- ): Promise<void> {
742
- const path = args[0];
743
- if (!path) throw new Error("usage: skillmux local-vault init <path> --yes");
744
- const expanded = expandHome(path);
745
- const config = await loadConfig();
746
- const localVaultPaths = config.local_vault_paths.map(expandHome);
747
- if (!localVaultPaths.includes(expanded)) {
748
- throw new Error(
749
- `"${path}" is not one of the configured local_vault_paths — add it to config.toml first`,
750
- );
751
- }
752
- if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
753
- const markerPath = join(expanded, ".skillmux");
754
- if (options.dryRun) {
755
- emitSuccess(
756
- { isJson: options.isJson },
757
- {
758
- marker_path: markerPath,
759
- vault_path: expandHome(config.vault_path),
760
- },
761
- () =>
762
- console.log(
763
- `local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
764
- ),
765
- );
766
- return;
767
- }
768
- if (
769
- !(await confirmIfNeeded({
770
- confirmed: args.includes("--yes"),
771
- isJson: options.isJson,
772
- prompt: `Mark ${expanded} as a local_vault (role: local_vault, vault_path: ${expandHome(config.vault_path)})?`,
773
- nonInteractiveError:
774
- "skillmux local-vault init requires --yes when run non-interactively",
775
- }))
776
- )
777
- return;
778
- writeLocalVaultMarker(expanded, expandHome(config.vault_path));
779
- emitSuccess(
780
- { isJson: options.isJson },
781
- {
782
- marker_path: markerPath,
783
- vault_path: expandHome(config.vault_path),
784
- },
785
- () =>
786
- console.log(
787
- `wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
788
- ),
789
- );
790
- }
791
-
792
- async function runModelDownload(options: { isJson: boolean }): Promise<void> {
793
- const cacheDir = await downloadLocalModels(await loadConfig());
794
- emitSuccess({ isJson: options.isJson }, { cache_dir: cacheDir }, () =>
795
- console.log(`models ready in ${cacheDir}`),
796
- );
797
- }
798
-
799
- function parseSyncArgs(args: string[]): {
800
- dryRun: boolean;
801
- restoreMonolith: boolean;
802
- installHook: boolean;
803
- yes: boolean;
804
- } {
805
- let dryRun = false;
806
- let restoreMonolith = false;
807
- let installHook = false;
808
- let yes = false;
809
- for (const arg of args) {
810
- if (arg === "--dry-run") dryRun = true;
811
- else if (arg === "--restore-monolith") restoreMonolith = true;
812
- else if (arg === "--install-hook") installHook = true;
813
- else if (arg === "--yes") yes = true;
814
- else throw new Error(`unknown sync option: ${arg}`);
815
- }
816
- return { dryRun, restoreMonolith, installHook, yes };
817
- }
818
-
819
- /**
820
- * A target directory that doesn't exist yet is about to be created by `sync`.
821
- * `manifest.targets[*].dir` is vault content — readable and writable by whatever
822
- * populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
823
- * `sync` can run unattended via the `--install-hook` post-merge hook. Without this
824
- * gate, a tampered manifest naming a brand-new path gets that directory silently
825
- * created (and populated with symlinks) the next time anyone pulls. Creation for
826
- * an as-yet-unseen directory therefore requires either `--yes` or an interactive
827
- * confirmation; once the directory exists, later syncs never hit this path again.
828
- */
829
- async function confirmNewSyncTarget(label: string, dir: string, yes: boolean): Promise<boolean> {
830
- if (yes) return true;
831
- if (!isInteractive()) {
832
- console.log(
833
- `${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
834
- );
835
- return false;
836
- }
837
- return confirmAction(`${label}: create new target directory ${dir}?`);
838
- }
839
-
840
- async function runSync(args: string[]): Promise<void> {
841
- const { dryRun, restoreMonolith, installHook, yes } = parseSyncArgs(args);
842
- const config = await loadConfig();
843
- const vaultPath = expandHome(config.vault_path);
844
-
845
- if (installHook) {
846
- const result = installPostMergeHook(vaultPath);
847
- console.log(
848
- result.installed
849
- ? "installed post-merge hook"
850
- : "post-merge hook already installed",
851
- );
852
- }
853
-
854
- const manifestPath = resolveManifestPath(vaultPath);
855
- if (!manifestPath) {
856
- console.log("no skillmux.toml found at vault root — nothing to sync");
857
- return;
858
- }
859
-
860
- const manifest = parseManifest(await Bun.file(manifestPath).text());
861
- const localVaultPaths = config.local_vault_paths.map(expandHome);
862
- const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
863
- for (const note of notes) console.log(`note: ${note}`);
864
-
865
- const currentHost = hostname();
866
- for (const [targetName, target] of Object.entries(manifest.targets)) {
867
- if (target.host !== undefined && target.host !== currentHost) {
868
- console.log(
869
- `${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
870
- );
871
- continue;
872
- }
873
- const targetDir = expandHome(target.dir);
874
-
875
- if (restoreMonolith) {
876
- const result = restoreMonolithTarget(targetDir, vaultPath);
877
- console.log(
878
- result.restored
879
- ? `${targetName}: restored to a vault symlink`
880
- : `${targetName}: not owned by skillmux, left untouched`,
881
- );
882
- continue;
883
- }
884
-
885
- if (!dryRun && !existsSync(targetDir)) {
886
- const approved = await confirmNewSyncTarget(targetName, targetDir, yes);
887
- if (!approved) {
888
- if (isInteractive()) {
889
- console.log(`${targetName}: skipped — creating ${targetDir} was not approved`);
890
- }
891
- continue;
892
- }
893
- }
894
-
895
- const suffix = dryRun ? " (dry-run)" : "";
896
- const result = syncTarget(
897
- {
898
- vaultPath,
899
- targetDir,
900
- targetName,
901
- coreSkillIds: manifest.core.skills,
902
- localVaultPaths,
903
- },
904
- { dryRun },
905
- );
906
- console.log(
907
- `${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
908
- );
909
- if (result.skipped.length > 0) {
910
- console.log(
911
- ` warning: refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`,
912
- );
913
- }
914
-
915
- if (target.project_groups.length > 0) {
916
- const allGroups = manifest.project ?? {};
917
- const projectGroups: Record<string, ProjectGroupInput> = {};
918
- for (const groupName of target.project_groups) {
919
- const group = allGroups[groupName]!;
920
- const approvedPaths: string[] = [];
921
- for (const path of group.paths) {
922
- // Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
923
- // never prompt for a project path it would silently skip anyway.
924
- if (!existsSync(path)) continue;
925
- const pinDir = resolveProjectPinDir(targetDir, path);
926
- if (dryRun || existsSync(pinDir)) {
927
- approvedPaths.push(path);
928
- continue;
929
- }
930
- const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes);
931
- if (approved) approvedPaths.push(path);
932
- }
933
- projectGroups[groupName] = { ...group, paths: approvedPaths };
934
- }
935
- const projectResults = syncProjectTargets(
936
- { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
937
- { dryRun },
938
- );
939
- for (const projectResult of projectResults) {
940
- console.log(
941
- ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
942
- );
943
- if (projectResult.skipped.length > 0) {
944
- console.log(
945
- ` warning: refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
946
- );
947
- }
948
- }
949
- }
950
- }
951
- }
952
-
953
- function parseInitArgs(args: string[]): {
954
- targets: string[];
955
- clients: string[];
956
- coreSkillIds: string[];
957
- customPath?: string;
958
- migrateFullVault: boolean;
959
- skipInstructions: boolean;
960
- sync: boolean;
961
- vaultPath?: string;
962
- yes: boolean;
963
- } {
964
- const targets: string[] = [];
965
- const clients: string[] = [];
966
- const coreSkillIds: string[] = [];
967
- let customPath: string | undefined;
968
- let migrateFullVault = false;
969
- let skipInstructions = false;
970
- let sync = true;
971
- let vaultPath: string | undefined;
972
- let yes = false;
973
- for (let i = 0; i < args.length; i++) {
974
- const option = args[i];
975
- if (option === "--target") {
976
- const value = args[i + 1];
977
- if (!value) throw new Error("--target requires a name");
978
- targets.push(value);
979
- i++;
980
- } else if (option === "--client") {
981
- const value = args[i + 1];
982
- if (!value) throw new Error("--client requires a name");
983
- clients.push(value);
984
- i++;
985
- } else if (option === "--vault") {
986
- const value = args[i + 1];
987
- if (!value) throw new Error("--vault requires a path");
988
- vaultPath = value;
989
- i++;
990
- } else if (option === "--dir") {
991
- const value = args[i + 1];
992
- if (!value) throw new Error("--dir requires a directory");
993
- customPath = value;
994
- i++;
995
- } else if (option === "--core") {
996
- const value = args[i + 1];
997
- if (!value) throw new Error("--core requires a skill_id");
998
- coreSkillIds.push(value);
999
- i++;
1000
- } else if (
1001
- option === "--dry-run" ||
1002
- option === "--json" ||
1003
- option === "--interactive"
1004
- ) {
1005
- continue;
1006
- } else if (option === "--migrate-full-vault") {
1007
- migrateFullVault = true;
1008
- } else if (option === "--no-instructions") {
1009
- skipInstructions = true;
1010
- } else if (option === "--no-sync") {
1011
- sync = false;
1012
- } else if (option === "--yes") {
1013
- yes = true;
1014
- } else {
1015
- throw new Error(`unknown init option: ${option}`);
1016
- }
1017
- }
1018
- return {
1019
- targets,
1020
- clients,
1021
- coreSkillIds,
1022
- customPath,
1023
- migrateFullVault,
1024
- skipInstructions,
1025
- sync,
1026
- vaultPath,
1027
- yes,
1028
- };
1029
- }
1030
-
1031
- async function runInit(
1032
- args: string[],
1033
- options: { isJson: boolean; dryRun: boolean },
1034
- ): Promise<void> {
1035
- const {
1036
- targets: explicitTargets,
1037
- clients: requestedClients,
1038
- coreSkillIds,
1039
- customPath,
1040
- migrateFullVault,
1041
- skipInstructions,
1042
- sync,
1043
- vaultPath: requestedVaultPath,
1044
- yes,
1045
- } = parseInitArgs(args);
1046
- const guided = shouldUseWizard(args, {
1047
- interactive: isInteractive(),
1048
- json: options.isJson,
1049
- dryRun: options.dryRun,
1050
- });
1051
- migrateLegacyPaths();
1052
- const configPath = resolveConfigPath();
1053
- let configPlan: ConfigInitPlan | undefined;
1054
- let vaultPath: string;
1055
- if (!existsSync(configPath)) {
1056
- const bootstrapVaultPath =
1057
- requestedVaultPath ??
1058
- (!options.isJson && isInteractive() ? "~/skills" : undefined);
1059
- if (!bootstrapVaultPath) {
1060
- throw new Error(
1061
- `machine config does not exist: ${configPath}; re-run with --vault <path>`,
1062
- );
1063
- }
1064
- configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
1065
- vaultPath = configPlan.vaultPath;
1066
- if (!options.isJson) {
1067
- console.log(`config create: ${configPath}`);
1068
- }
1069
- } else {
1070
- const config = await loadConfig();
1071
- vaultPath = expandHome(config.vault_path);
1072
- if (requestedVaultPath && expandHome(requestedVaultPath) !== vaultPath) {
1073
- throw new Error(
1074
- `machine config already uses vault_path ${vaultPath}; --vault does not overwrite existing config`,
1075
- );
1076
- }
1077
- }
1078
-
1079
- const vaultHealth = inspectVault(vaultPath);
1080
- if (!vaultHealth.ok) {
1081
- throw new Error(vaultHealth.message);
1082
- }
1083
-
1084
- let selectedClients = requestedClients;
1085
- if (guided) {
1086
- const detected = detectInstalledClients({
1087
- codexHome: process.env.CODEX_HOME
1088
- ? expandHome(process.env.CODEX_HOME)
1089
- : undefined,
1090
- });
1091
- const evidence = new Map(
1092
- detected.map((item) => [item.client, item.evidence]),
1093
- );
1094
- selectedClients = await promptMultiSelect(
1095
- "Which clients do you use?",
1096
- SUPPORTED_CLIENT_IDS.map((client) => ({
1097
- value: client,
1098
- label: client,
1099
- detail: evidence.has(client)
1100
- ? `detected: ${evidence.get(client)}`
1101
- : undefined,
1102
- selected: evidence.has(client) || requestedClients.includes(client),
1103
- })),
1104
- );
1105
- }
1106
- let selectedCoreSkillIds = coreSkillIds;
1107
- if (guided) {
1108
- selectedCoreSkillIds = parseCommaList(
1109
- await promptText(
1110
- "Core skill IDs to add, comma-separated",
1111
- coreSkillIds.join(","),
1112
- ),
1113
- );
1114
- }
1115
-
1116
- const clientPlan = planClientSurfaces(selectedClients, {
1117
- codexHome: process.env.CODEX_HOME
1118
- ? expandHome(process.env.CODEX_HOME)
1119
- : undefined,
1120
- });
1121
- const instructionPlan = planInstructionSetup(
1122
- skipInstructions ? [] : clientPlan.clients.map((client) => client.id),
1123
- {
1124
- codexHome: process.env.CODEX_HOME
1125
- ? expandHome(process.env.CODEX_HOME)
1126
- : undefined,
1127
- },
1128
- );
1129
- const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
1130
- for (const change of instructionPlan.changes) {
1131
- for (const client of change.clients) {
1132
- instructionReadiness[client] = {
1133
- status: change.status === "unchanged" ? "ready" : "planned",
1134
- detail: change.path,
1135
- };
1136
- }
1137
- }
1138
- for (const manual of instructionPlan.manual) {
1139
- instructionReadiness[manual.client] = {
1140
- status: "manual",
1141
- detail: manual.reason,
1142
- };
1143
- }
1144
- const builtInNames = new Set([
1145
- "agent-skills",
1146
- "claude-code",
1147
- "codex",
1148
- "custom",
1149
- "agents",
1150
- "claude",
1151
- ]);
1152
- const explicitSurfaceTargets = explicitTargets
1153
- .filter((name) => builtInNames.has(name))
1154
- .map((name) =>
1155
- resolveBuiltInTarget(name, {
1156
- codexHome: process.env.CODEX_HOME
1157
- ? expandHome(process.env.CODEX_HOME)
1158
- : undefined,
1159
- customPath: customPath ? expandHome(customPath) : undefined,
1160
- }),
1161
- );
1162
- if (customPath && !explicitTargets.includes("custom")) {
1163
- throw new Error("--dir may only be used with --target custom");
1164
- }
1165
- for (const target of explicitSurfaceTargets) {
1166
- if (target.warning) console.error(`warning: ${target.warning}`);
1167
- }
1168
- const targetByPath = new Map(
1169
- explicitSurfaceTargets.map(
1170
- (target) => [target.path, target.targetName] as const,
1171
- ),
1172
- );
1173
- const existingManifestPath = resolveManifestPath(vaultPath);
1174
- const existingManifest = existingManifestPath
1175
- ? parseManifest(await Bun.file(existingManifestPath).text())
1176
- : undefined;
1177
- for (const surface of clientPlan.surfaces) {
1178
- if (!targetByPath.has(surface.path)) {
1179
- targetByPath.set(
1180
- surface.path,
1181
- existingManifest
1182
- ? (configuredTargetForSurface(existingManifest, surface) ??
1183
- surface.targetName)
1184
- : surface.targetName,
1185
- );
1186
- }
1187
- }
1188
- const candidatePaths = [
1189
- ...new Set([
1190
- ...surfaceCandidates().map(expandHome),
1191
- ...targetByPath.keys(),
1192
- ]),
1193
- ];
1194
- const candidates = detectSurfaces(candidatePaths, vaultPath);
1195
- if (!options.isJson) {
1196
- for (const candidate of candidates) {
1197
- const name =
1198
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1199
- if (candidate.state === "missing") {
1200
- console.log(`${name} (${candidate.path}): not found`);
1201
- continue;
1202
- }
1203
- if (candidate.state === "broken-symlink") {
1204
- console.log(`${name} (${candidate.path}): broken symlink`);
1205
- continue;
1206
- }
1207
- if (candidate.state === "full-vault") {
1208
- console.log(
1209
- `${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`,
1210
- );
1211
- continue;
1212
- }
1213
- if (candidate.state === "external-symlink") {
1214
- console.log(
1215
- `${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`,
1216
- );
1217
- continue;
1218
- }
1219
- if (candidate.state === "unsupported") {
1220
- console.log(
1221
- `${name} (${candidate.path}): unsupported filesystem entry`,
1222
- );
1223
- continue;
1224
- }
1225
- const kind = "real dir";
1226
- const marked = candidate.alreadyMarked
1227
- ? ", already skillmux-managed"
1228
- : "";
1229
- console.log(
1230
- `${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`,
1231
- );
1232
- }
1233
- for (const readiness of assessClientReadiness(
1234
- clientPlan,
1235
- instructionReadiness,
1236
- )) {
1237
- console.log(`\n${readiness.client} readiness:`);
1238
- console.log(
1239
- ` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`,
1240
- );
1241
- console.log(
1242
- ` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`,
1243
- );
1244
- console.log(
1245
- ` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`,
1246
- );
1247
- }
1248
- for (const change of instructionPlan.changes) {
1249
- console.log(
1250
- `instructions ${change.status}: ${change.path} (${change.clients.join(", ")})`,
1251
- );
1252
- }
1253
- for (const manual of instructionPlan.manual) {
1254
- console.log(`instructions manual: ${manual.client} — ${manual.reason}`);
1255
- }
1256
- }
1257
-
1258
- const requestedTargets = [
1259
- ...new Set([
1260
- ...explicitTargets.filter((name) => !builtInNames.has(name)),
1261
- ...targetByPath.values(),
1262
- ]),
1263
- ];
1264
- const hasInstructionWrites = instructionPlan.changes.some(
1265
- (change) => change.status !== "unchanged",
1266
- );
1267
- const hasConfigWrite = configPlan?.action === "create";
1268
- const hasChanges = !(
1269
- requestedTargets.length === 0 &&
1270
- !hasInstructionWrites &&
1271
- selectedCoreSkillIds.length === 0 &&
1272
- !hasConfigWrite
1273
- );
1274
-
1275
- const byName = new Map(
1276
- candidates
1277
- .filter(
1278
- (candidate) =>
1279
- candidate.deliveryMode === "managed-pins" ||
1280
- (migrateFullVault && candidate.state === "full-vault"),
1281
- )
1282
- .map(
1283
- (candidate) =>
1284
- [
1285
- targetByPath.get(candidate.path) ??
1286
- deriveTargetName(candidate.path),
1287
- candidate,
1288
- ] as const,
1289
- ),
1290
- );
1291
- const allCandidatesByName = new Map(
1292
- candidates.map(
1293
- (candidate) =>
1294
- [
1295
- targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1296
- candidate,
1297
- ] as const,
1298
- ),
1299
- );
1300
- for (const name of requestedTargets) {
1301
- if (!byName.has(name)) {
1302
- if (allCandidatesByName.get(name)?.state === "full-vault") {
1303
- throw new Error(
1304
- `target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
1305
- );
1306
- }
1307
- throw new Error(
1308
- `unknown --target "${name}": not among detected surfaces`,
1309
- );
1310
- }
1311
- }
1312
-
1313
- const confirmedTargets = requestedTargets.map((name) => {
1314
- const candidate = byName.get(name)!;
1315
- return {
1316
- name,
1317
- dir: candidate.path,
1318
- ...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
1319
- };
1320
- });
1321
- const plannedManifest = planInitManifest(
1322
- vaultPath,
1323
- confirmedTargets,
1324
- selectedCoreSkillIds,
1325
- );
1326
- const serializedPlan = {
1327
- vault_path: vaultPath,
1328
- config: configPlan
1329
- ? { path: configPlan.configPath, action: configPlan.action }
1330
- : { path: configPath, action: "preserve" },
1331
- clients: clientPlan.clients.map((client) => client.id),
1332
- targets: confirmedTargets,
1333
- core: plannedManifest.core.skills,
1334
- instructions: instructionPlan.changes.map(({ path, clients, status }) => ({
1335
- path,
1336
- clients,
1337
- status,
1338
- })),
1339
- manual: instructionPlan.manual,
1340
- };
1341
- if (!hasChanges) {
1342
- if (options.isJson) {
1343
- console.log(
1344
- JSON.stringify({
1345
- schema_version: 1,
1346
- ok: true,
1347
- command: "init",
1348
- phase: "plan",
1349
- dry_run: options.dryRun,
1350
- applied: false,
1351
- plan: serializedPlan,
1352
- }),
1353
- );
1354
- } else {
1355
- console.log("\nno managed-pins surface selected — nothing written.");
1356
- }
1357
- return;
1358
- }
1359
- if (!options.isJson) {
1360
- for (const target of confirmedTargets.filter(
1361
- (target) => target.migrateFullVault,
1362
- )) {
1363
- console.log(
1364
- `full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
1365
- `${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
1366
- );
1367
- }
1368
- }
1369
- if (options.dryRun) {
1370
- if (options.isJson) {
1371
- console.log(
1372
- JSON.stringify({
1373
- schema_version: 1,
1374
- ok: true,
1375
- command: "init",
1376
- phase: "plan",
1377
- dry_run: true,
1378
- applied: false,
1379
- plan: serializedPlan,
1380
- }),
1381
- );
1382
- } else {
1383
- console.log(
1384
- `\ndry-run: ${confirmedTargets.length} target(s), ` +
1385
- `${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
1386
- `core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
1387
- );
1388
- }
1389
- return;
1390
- }
1391
-
1392
- if (!yes) {
1393
- if (!options.isJson && isInteractive()) {
1394
- if (guided) {
1395
- console.log("\nReview");
1396
- console.log(` clients: ${selectedClients.join(", ") || "(none)"}`);
1397
- console.log(
1398
- ` targets: ${confirmedTargets.map((target) => `${target.name} -> ${target.dir}`).join(", ") || "(none)"}`,
1399
- );
1400
- console.log(
1401
- ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
1402
- );
1403
- console.log(
1404
- ` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`,
1405
- );
1406
- console.log(` sync: ${sync ? "yes" : "no"}`);
1407
- if (!(await confirmAction("Apply this setup plan?"))) {
1408
- console.log("init cancelled");
1409
- return;
1410
- }
1411
- } else {
1412
- const prompts = [
1413
- ...confirmedTargets.map(
1414
- (target) => `Adopt ${target.name} at ${target.dir}?`,
1415
- ),
1416
- ...instructionPlan.changes
1417
- .filter((change) => change.status !== "unchanged")
1418
- .map(
1419
- (change) => `${change.status} instruction file ${change.path}?`,
1420
- ),
1421
- ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1422
- ...(selectedCoreSkillIds.length > 0
1423
- ? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
1424
- : []),
1425
- ];
1426
- for (const prompt of prompts) {
1427
- if (!(await confirmAction(prompt))) {
1428
- console.log("init cancelled; nothing written");
1429
- return;
1430
- }
1431
- }
1432
- }
1433
- } else {
1434
- throw new Error(
1435
- "skillmux init requires --yes before applying target, instruction, or core changes non-interactively",
1436
- );
1437
- }
1438
- }
1439
-
1440
- let configCreated = false;
1441
- let instructionsApplied = false;
1442
- const applyAdditional = () => {
1443
- try {
1444
- if (configPlan?.action === "create") {
1445
- configCreated = applyConfigInit(configPlan) === "created";
1446
- }
1447
- if (hasInstructionWrites) {
1448
- applyInstructionPlan(instructionPlan);
1449
- instructionsApplied = true;
1450
- }
1451
- } catch (error) {
1452
- if (configCreated && configPlan) rollbackConfigInit(configPlan);
1453
- configCreated = false;
1454
- throw error;
1455
- }
1456
- };
1457
- const rollbackAdditional = () => {
1458
- if (instructionsApplied) rollbackInstructionPlan(instructionPlan);
1459
- if (configCreated && configPlan) rollbackConfigInit(configPlan);
1460
- };
1461
-
1462
- if (confirmedTargets.length === 0 && selectedCoreSkillIds.length === 0) {
1463
- applyAdditional();
1464
- } else {
1465
- applyInit(
1466
- vaultPath,
1467
- confirmedTargets,
1468
- hasInstructionWrites || hasConfigWrite
1469
- ? {
1470
- apply: applyAdditional,
1471
- rollback: rollbackAdditional,
1472
- }
1473
- : undefined,
1474
- selectedCoreSkillIds,
1475
- );
1476
- }
1477
-
1478
- if (options.isJson) {
1479
- console.log(
1480
- JSON.stringify({
1481
- schema_version: 1,
1482
- ok: true,
1483
- command: "init",
1484
- phase: "result",
1485
- dry_run: false,
1486
- applied: true,
1487
- plan: serializedPlan,
1488
- result: {
1489
- config_created: configCreated,
1490
- targets_adopted: confirmedTargets.map((target) => target.name),
1491
- instructions_changed: instructionPlan.changes
1492
- .filter((change) => change.status !== "unchanged")
1493
- .map((change) => change.path),
1494
- core: plannedManifest.core.skills,
1495
- },
1496
- }),
1497
- );
1498
- return;
1499
- }
1500
- if (configCreated) console.log(`created ${configPath}`);
1501
- if (confirmedTargets.length > 0) {
1502
- console.log(
1503
- `\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`,
1504
- );
1505
- } else if (selectedCoreSkillIds.length > 0) {
1506
- console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
1507
- }
1508
- if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
1509
- console.log("next: skillmux core pin <skill_id> --yes");
1510
- }
1511
- if (confirmedTargets.length > 0) console.log("next: skillmux sync");
1512
- if (
1513
- selectedClients.length === 0 ||
1514
- selectedClients.includes("skillmux-mcp")
1515
- ) {
1516
- console.log(`\n${printLastMile()}`);
1517
- }
1518
- // Reaching this point already required approval above (--yes, or an accepted
1519
- // confirmAction naming these exact targets/dirs) — that approval covers whatever
1520
- // new target directories this init just adopted, so runSync's own new-target
1521
- // confirmation gate would just be a redundant (and non-interactively,
1522
- // silently-skipping) re-ask.
1523
- if (guided && sync && confirmedTargets.length > 0) await runSync(["--yes"]);
1524
- }
1525
-
1526
- function parseReportArgs(args: string[]): {
1527
- db?: string;
1528
- since?: string;
1529
- } {
1530
- let db: string | undefined;
1531
- let since: string | undefined;
1532
- for (let i = 0; i < args.length; i++) {
1533
- const option = args[i];
1534
- const value = args[i + 1];
1535
- if (option === "--db") {
1536
- if (!value) throw new Error("--db requires a path");
1537
- db = value;
1538
- i++;
1539
- } else if (option === "--since") {
1540
- if (!value) throw new Error("--since requires a window");
1541
- since = value;
1542
- i++;
1543
- } else if (option === "--json") {
1544
- // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1545
- } else if (option === "--server" || option === "--context") {
1546
- // handled globally by main()'s resolveTarget(); recognized here so it isn't rejected
1547
- i++;
1548
- } else if (option === "--allow-insecure") {
1549
- // handled globally by main()'s allowInsecure flag; recognized here so it isn't rejected
1550
- } else {
1551
- throw new Error(`unknown report option: ${option}`);
1552
- }
1553
- }
1554
- return { db, since };
1555
- }
1556
-
1557
- async function runReport(
1558
- args: string[],
1559
- options: { isJson: boolean; target: ResolvedTarget; allowInsecure: boolean },
1560
- ): Promise<void> {
1561
- const { db: dbPath, since } = parseReportArgs(args);
1562
- if (!since)
1563
- throw new Error(
1564
- "usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
1565
- );
1566
- if (dbPath && options.target.type === "remote")
1567
- throw new Error("--db and --context/--server are mutually exclusive");
1568
-
1569
- if (options.target.type === "remote") {
1570
- const { server, token_env } = options.target;
1571
- const url = new URL(`${server.replace(/\/$/, "")}/stats`);
1572
- url.searchParams.set("since", since);
1573
- if (
1574
- url.protocol === "http:" &&
1575
- !isLoopbackHost(url.hostname) &&
1576
- !options.allowInsecure
1577
- ) {
1578
- throw new Error(
1579
- `Plaintext HTTP report target not allowed for non-loopback server "${server}". Pass --allow-insecure to bypass.`,
1580
- );
1581
- }
1582
- const headers: Record<string, string> = {};
1583
- if (token_env) {
1584
- const token = process.env[token_env];
1585
- if (token) headers.Authorization = `Bearer ${token}`;
1586
- }
1587
- const res = await fetch(url, { headers });
1588
- if (!res.ok)
1589
- throw new Error(
1590
- `skillmux report --server failed: ${res.status} ${await res.text()}`,
1591
- );
1592
- const stats = (await res.json()) as StatsResponse;
1593
- emitSuccess({ isJson: options.isJson }, stats, () =>
1594
- console.log(renderStatsText(stats)),
1595
- );
1596
- return;
1597
- }
1598
-
1599
- const db = dbPath
1600
- ? new Database(dbPath, { readonly: true })
1601
- : openAudit(expandHome((await loadConfig()).state_dir));
1602
- const stats = getStats(db, since);
1603
- emitSuccess({ isJson: options.isJson }, stats, () =>
1604
- console.log(renderStatsText(stats)),
1605
- );
1606
- db.close();
1607
- }
1608
-
1609
- function parseScanArgs(args: string[]): {
1610
- path?: string;
1611
- format: "text" | "json";
1612
- failOn?: ScanSeverity;
1613
- } {
1614
- let path: string | undefined;
1615
- let format: "text" | "json" = "text";
1616
- let failOn: ScanSeverity | undefined;
1617
- for (let i = 0; i < args.length; i++) {
1618
- const option = args[i];
1619
- if (option === "--format") {
1620
- const value = args[++i];
1621
- if (value !== "text" && value !== "json")
1622
- throw new Error("--format must be text or json");
1623
- format = value;
1624
- } else if (option === "--fail-on") {
1625
- const value = args[++i];
1626
- if (value !== "low" && value !== "medium" && value !== "high") {
1627
- throw new Error("--fail-on must be low, medium, or high");
1628
- }
1629
- failOn = value;
1630
- } else if (option === "--json") {
1631
- // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1632
- } else if (option?.startsWith("--")) {
1633
- throw new Error(`unknown scan option: ${option}`);
1634
- } else if (path !== undefined) {
1635
- throw new Error("skillmux scan accepts at most one <path> argument");
1636
- } else {
1637
- path = option;
1638
- }
1639
- }
1640
- return { path, format, failOn };
1641
- }
1642
-
1643
- async function runScan(
1644
- args: string[],
1645
- options: { isJson: boolean },
1646
- ): Promise<void> {
1647
- const { path, format, failOn } = parseScanArgs(args);
1648
- const rootPath = path
1649
- ? expandHome(path)
1650
- : expandHome((await loadConfig()).vault_path);
1651
- const result = await scanPath(rootPath);
1652
- emitSuccess({ isJson: options.isJson }, result, () => {
1653
- console.log(
1654
- format === "json" ? renderScanJson(result) : renderScanText(result),
1655
- );
1656
- });
1657
- process.exitCode = scanExitCode(result.findings, failOn);
1658
- }
1659
-
1660
- function parseInstallArgs(args: string[]): {
1661
- repo?: string;
1662
- force: boolean;
1663
- dryRun: boolean;
1664
- failOn?: ScanSeverity;
1665
- allowLocalSource: boolean;
1666
- } {
1667
- let repo: string | undefined;
1668
- let force = false;
1669
- let dryRun = false;
1670
- let failOn: ScanSeverity | undefined;
1671
- let allowLocalSource = false;
1672
- for (let i = 0; i < args.length; i++) {
1673
- const option = args[i];
1674
- if (option === "--force") force = true;
1675
- else if (option === "--dry-run") dryRun = true;
1676
- else if (option === "--allow-local-source") allowLocalSource = true;
1677
- else if (option === "--fail-on") {
1678
- const value = args[++i];
1679
- if (value !== "low" && value !== "medium" && value !== "high") {
1680
- throw new Error("--fail-on must be low, medium, or high");
1681
- }
1682
- failOn = value;
1683
- } else if (option === "--json" || option === "--verbose") {
1684
- // handled globally by main()'s isJson/isVerbose flags; recognized here so they aren't rejected
1685
- } else if (option?.startsWith("--")) {
1686
- throw new Error(`unknown install option: ${option}`);
1687
- } else if (repo !== undefined) {
1688
- throw new Error("skillmux install accepts at most one <repo> argument");
1689
- } else {
1690
- repo = option;
1691
- }
1692
- }
1693
- return { repo, force, dryRun, failOn, allowLocalSource };
1694
- }
1695
-
1696
- async function runInstall(
1697
- args: string[],
1698
- options: { isJson: boolean },
1699
- ): Promise<void> {
1700
- const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
1701
- if (!repo) {
1702
- throw new Error(
1703
- "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]",
1704
- );
1705
- }
1706
-
1707
- const source = resolveRepoSource(repo);
1708
- if (!allowLocalSource && isLocalFileUrl(source.url)) {
1709
- throw new Error(
1710
- `"${repo}" is a local (file://) source — pass --allow-local-source to install from it`,
1711
- );
1712
- }
1713
- const config = await loadConfig();
1714
- assertHostAllowed(source.url, config.egress?.allowed_hosts);
1715
- const cloneDir = await cloneToTemp(source.url);
1716
- try {
1717
- const resolved = resolveSkillDir(
1718
- cloneDir,
1719
- deriveRepoName(source.url),
1720
- source.skillPath,
1721
- );
1722
- const { findings } = await validateSkillCandidate(
1723
- resolved.skillId,
1724
- resolved.dir,
1725
- );
1726
- if (!options.isJson) console.log(renderScanText({ scanned: 1, findings }));
1727
-
1728
- if (scanExitCode(findings, failOn) !== 0) {
1729
- process.exitCode = 1;
1730
- console.error(
1731
- `aborting install: a finding met the --fail-on ${failOn} threshold`,
1732
- );
1733
- return;
1734
- }
1735
-
1736
- const vaultPath = expandHome(config.vault_path);
1737
- if (dryRun) {
1738
- const plannedPath = join(vaultPath, resolved.skillId);
1739
- emitSuccess(
1740
- { isJson: options.isJson },
1741
- { skill_id: resolved.skillId, would_install_at: plannedPath },
1742
- () =>
1743
- console.log(
1744
- `dry-run: would install "${resolved.skillId}" into ${plannedPath}`,
1745
- ),
1746
- );
1747
- return;
1748
- }
1749
-
1750
- const commit = resolveCloneCommit(cloneDir);
1751
- const targetDir = installIntoVault(
1752
- vaultPath,
1753
- resolved.skillId,
1754
- resolved.dir,
1755
- force,
1756
- );
1757
- writeSkillOrigin(targetDir, {
1758
- source_url: source.url,
1759
- skill_path: source.skillPath,
1760
- commit,
1761
- installed_at: new Date().toISOString(),
1762
- content_hash: hashSkillContent(targetDir),
1763
- });
1764
- emitSuccess(
1765
- { isJson: options.isJson },
1766
- { skill_id: resolved.skillId, installed_at: targetDir },
1767
- () => console.log(`installed "${resolved.skillId}" into ${targetDir}`),
1768
- );
1769
- } finally {
1770
- rmSync(cloneDir, { recursive: true, force: true });
1771
- }
1772
- }
1773
740
 
1774
741
  if (import.meta.main) {
1775
742
  await main();