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