@klhapp/skillmux 1.12.0 → 1.13.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 (103) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +18 -9
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +14 -4
  6. package/docs/concepts.md +2 -2
  7. package/docs/configuration.md +18 -3
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/troubleshooting.md +18 -2
  39. package/package.json +15 -8
  40. package/src/adapters.ts +0 -438
  41. package/src/audit.ts +0 -21
  42. package/src/cli.ts +0 -928
  43. package/src/clients.ts +0 -368
  44. package/src/commands/audit.ts +0 -85
  45. package/src/commands/config.ts +0 -224
  46. package/src/commands/context.ts +0 -104
  47. package/src/commands/core.ts +0 -56
  48. package/src/commands/doctor.ts +0 -97
  49. package/src/commands/eval.ts +0 -88
  50. package/src/commands/init.ts +0 -672
  51. package/src/commands/install.ts +0 -155
  52. package/src/commands/local-vault.ts +0 -60
  53. package/src/commands/models.ts +0 -10
  54. package/src/commands/outdated.ts +0 -115
  55. package/src/commands/project.ts +0 -580
  56. package/src/commands/report.ts +0 -66
  57. package/src/commands/scan.ts +0 -71
  58. package/src/commands/shared.ts +0 -38
  59. package/src/commands/skill.ts +0 -33
  60. package/src/commands/sync.ts +0 -233
  61. package/src/commands/target.ts +0 -225
  62. package/src/commands/update.ts +0 -277
  63. package/src/completions.ts +0 -196
  64. package/src/concurrency-limiter.ts +0 -61
  65. package/src/config-service.ts +0 -398
  66. package/src/config-watcher.ts +0 -253
  67. package/src/config.ts +0 -583
  68. package/src/context.ts +0 -188
  69. package/src/db-audit.ts +0 -286
  70. package/src/db-index.ts +0 -238
  71. package/src/db.ts +0 -3
  72. package/src/deployment.ts +0 -39
  73. package/src/doctor.ts +0 -205
  74. package/src/eval.ts +0 -302
  75. package/src/global-flags.ts +0 -46
  76. package/src/init-agents.ts +0 -329
  77. package/src/init-instructions.ts +0 -192
  78. package/src/init.ts +0 -390
  79. package/src/install.ts +0 -232
  80. package/src/lifecycle.ts +0 -51
  81. package/src/logger.ts +0 -26
  82. package/src/manifest.ts +0 -359
  83. package/src/mcp-registration.ts +0 -89
  84. package/src/metrics.ts +0 -121
  85. package/src/models.ts +0 -20
  86. package/src/output.ts +0 -214
  87. package/src/project-setup.ts +0 -36
  88. package/src/prompts.ts +0 -124
  89. package/src/provenance.ts +0 -99
  90. package/src/rate-limiter.ts +0 -137
  91. package/src/readiness.ts +0 -30
  92. package/src/redact.ts +0 -52
  93. package/src/router-core.ts +0 -666
  94. package/src/rrf.ts +0 -31
  95. package/src/scan.ts +0 -300
  96. package/src/server.ts +0 -930
  97. package/src/setup.ts +0 -145
  98. package/src/snapshot.ts +0 -135
  99. package/src/stats.ts +0 -340
  100. package/src/sync.ts +0 -482
  101. package/src/toml-writer.ts +0 -51
  102. package/src/types.ts +0 -212
  103. package/src/vault.ts +0 -207
package/src/cli.ts DELETED
@@ -1,928 +0,0 @@
1
- #!/usr/bin/env bun
2
- import packageJson from "../package.json" with { type: "json" };
3
- import { lstatSync, mkdirSync } from "node:fs";
4
-
5
- import { createClients } from "./clients";
6
- import { loadConfig } from "./config";
7
- import { openAudit } from "./db";
8
- import { getEffectiveConfig } from "./config-service";
9
- import { buildRedactor } from "./redact";
10
- import { evalVault } from "./eval";
11
- import { runOutdated } from "./commands/outdated";
12
- import { runUpdate } from "./commands/update";
13
- import { serializeManifest } from "./manifest";
14
-
15
- import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
16
- import { type StatsResponse } from "./stats";
17
- import { scanVault } from "./vault";
18
-
19
- import { resolveContext, type ResolvedContext } from "./context";
20
- import { createContextAdapter, isLoopbackHost, type ContextAdapter } from "./adapters";
21
- import {
22
- emitSuccess,
23
- CliError,
24
- formatJsonEnvelope,
25
- mapExitCode,
26
- red,
27
- suggestCorrection,
28
- warn,
29
- } from "./output";
30
- import { generateCompletions, type ShellType } from "./completions";
31
- import { SUPPORTED_AGENT_IDS } from "./init-agents";
32
- import { runAudit } from "./commands/audit";
33
- import { handleConfigCommand } from "./commands/config";
34
- import { handleContextCommand } from "./commands/context";
35
- import { runDoctor } from "./commands/doctor";
36
- import { runEvalPromote } from "./commands/eval";
37
- import { runInstall } from "./commands/install";
38
- import { runCore } from "./commands/core";
39
- import { runLocalVaultInit } from "./commands/local-vault";
40
- import { runModelDownload } from "./commands/models";
41
- import { runProject } from "./commands/project";
42
- import { runReport } from "./commands/report";
43
- import { runScan } from "./commands/scan";
44
- import { runSkill } from "./commands/skill";
45
- import { runTarget } from "./commands/target";
46
- import { runSync } from "./commands/sync";
47
- import { runInit } from "./commands/init";
48
-
49
- export const KNOWN_COMMANDS = [
50
- "context",
51
- "config",
52
- "completions",
53
- "serve",
54
- "index",
55
- "sync",
56
- "init",
57
- "project",
58
- "target",
59
- "core",
60
- "report",
61
- "audit",
62
- "scan",
63
- "install",
64
- "outdated",
65
- "update",
66
- "eval",
67
- "doctor",
68
- "models",
69
- "skill",
70
- "local-vault",
71
- ];
72
-
73
- /**
74
- * Declared context support for every command in KNOWN_COMMANDS — the single
75
- * source of truth getLocalOnlyCommand() enforces against. A command missing
76
- * from here, or misclassified, is a bug: see tests/cli-context-support.test.ts,
77
- * which fails the build rather than letting a command silently drift out of
78
- * sync the way `config init` did (it was never rejected for a remote context,
79
- * nor actually remote-capable — it just silently ran local logic that choked
80
- * on an unrecognized --context/--server flag with a confusing error).
81
- *
82
- * - "local-only": operates on this machine's vault/filesystem/agents only;
83
- * a remote context is rejected outright.
84
- * - "remote-capable": routed through ContextAdapter — same command, backed by
85
- * LocalAdapter or RemoteAdapter depending on the resolved context.
86
- * - "context-agnostic": the resolved context isn't used to decide behavior at
87
- * all (context management is inherently local; completions never touch
88
- * vault/server state).
89
- *
90
- * Subcommand-level exceptions within an otherwise-classified command (e.g.
91
- * `config init`, which bootstraps *this machine's* config file and so is
92
- * local-only despite `config` overall being remote-capable) are handled in
93
- * getLocalOnlyCommand() itself, not in this top-level map.
94
- */
95
- export type CommandContextSupport = "local-only" | "remote-capable" | "context-agnostic";
96
-
97
- export const COMMAND_CONTEXT_SUPPORT: Record<string, CommandContextSupport> = {
98
- context: "context-agnostic",
99
- config: "remote-capable",
100
- completions: "context-agnostic",
101
- serve: "local-only",
102
- index: "local-only",
103
- sync: "local-only",
104
- init: "local-only",
105
- project: "local-only",
106
- target: "local-only",
107
- core: "local-only",
108
- report: "remote-capable",
109
- audit: "remote-capable",
110
- scan: "local-only",
111
- install: "local-only",
112
- outdated: "local-only",
113
- update: "local-only",
114
- eval: "remote-capable",
115
- doctor: "remote-capable",
116
- models: "local-only",
117
- skill: "local-only",
118
- "local-vault": "local-only",
119
- };
120
-
121
- const LOCAL_ONLY_COMMANDS = new Set(
122
- Object.entries(COMMAND_CONTEXT_SUPPORT)
123
- .filter(([, support]) => support === "local-only")
124
- .map(([command]) => command),
125
- );
126
-
127
- export function getLocalOnlyCommand(command: string, subCommand: string): string | null {
128
- if (command === "skill" && (subCommand === "which" || !subCommand)) {
129
- return "skill which";
130
- }
131
- if (command === "config" && subCommand === "init") {
132
- return "config init";
133
- }
134
- if (LOCAL_ONLY_COMMANDS.has(command)) {
135
- return command;
136
- }
137
- return null;
138
- }
139
-
140
- /**
141
- * Why a local-only command can't take a remote context, keyed by the exact
142
- * string getLocalOnlyCommand() returns. Drives the guidance sentence
143
- * remoteContextUnsupported() appends, so the rejection points somewhere
144
- * useful instead of just saying no.
145
- */
146
- type LocalOnlyReason = "vault-content" | "native-delivery" | "local-runtime" | "local-config";
147
-
148
- const LOCAL_ONLY_REASON: Record<string, LocalOnlyReason> = {
149
- install: "vault-content",
150
- update: "vault-content",
151
- outdated: "vault-content",
152
- scan: "vault-content",
153
- init: "native-delivery",
154
- sync: "native-delivery",
155
- target: "native-delivery",
156
- core: "native-delivery",
157
- project: "native-delivery",
158
- "local-vault": "native-delivery",
159
- "skill which": "native-delivery",
160
- serve: "local-runtime",
161
- models: "local-runtime",
162
- index: "local-runtime",
163
- "config init": "local-config",
164
- };
165
-
166
- const LOCAL_ONLY_GUIDANCE: Record<LocalOnlyReason, string> = {
167
- "vault-content":
168
- "To change a remote deployment's vault contents, update its git-backed source and redeploy or pull on that host — skillmux doesn't replicate vault checkouts over the network.",
169
- "native-delivery":
170
- "This manages skill delivery into agent directories on the machine you run it from; there's no remote equivalent — run it on the machine that owns those directories.",
171
- "local-runtime":
172
- "This operates on the local runtime process on the machine you run it from.",
173
- "local-config":
174
- "This bootstraps this machine's own config file. To inspect or change a remote deployment's configuration, use \"skillmux config show/set --context <name>\" instead.",
175
- };
176
-
177
- function remoteContextUnsupported(rejectedCommand: string): CliError {
178
- const reason = LOCAL_ONLY_REASON[rejectedCommand];
179
- const guidance = reason ? ` ${LOCAL_ONLY_GUIDANCE[reason]}` : "";
180
- return new CliError(
181
- `\`${rejectedCommand}\` operates on the local vault only; --context/--server isn't supported here.${guidance}`,
182
- 2,
183
- "REMOTE_CONTEXT_UNSUPPORTED",
184
- {
185
- rejected_command: rejectedCommand,
186
- ...(reason ? { reason } : {}),
187
- },
188
- );
189
- }
190
-
191
- function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
192
- if (
193
- [
194
- "init",
195
- "sync",
196
- "install",
197
- "outdated",
198
- "update",
199
- "project",
200
- "target",
201
- "core",
202
- "local-vault",
203
- "models",
204
- "context",
205
- ].includes(command)
206
- ) {
207
- return true;
208
- }
209
-
210
- // eval promote only touches the mounted state_dir, unlike bare `eval`
211
- // (vault ranking evaluation), which needs an embeddings client and the vault.
212
- if (command === "eval" && subCommand !== "promote") return true;
213
-
214
- return command === "config" && ["init", "set"].includes(subCommand);
215
- }
216
-
217
- function containerCommandUnsupported(command: string, subCommand: string): CliError {
218
- const rejectedCommand = [command, subCommand].filter(Boolean).join(" ");
219
- const recommendedHostCommand = `skillmux ${rejectedCommand}`;
220
- const guide = "docs/deployment.md";
221
- const documentation =
222
- "https://github.com/klhq/skillmux/blob/main/docs/deployment.md#container-command-contract";
223
- return new CliError(
224
- `\`skillmux ${rejectedCommand}\` manages host agent directories and cannot run in the Skillmux server image.\n\n` +
225
- "Install the host CLI:\n" +
226
- " bun add -g @klhapp/skillmux\n\n" +
227
- "Then run:\n" +
228
- ` ${recommendedHostCommand}\n\n` +
229
- `See ${guide} for server deployment examples.`,
230
- 2,
231
- "CONTAINER_COMMAND_UNSUPPORTED",
232
- {
233
- // `command` remains for automation written against the first Docker
234
- // boundary release. `rejected_command` is the explicit contract name.
235
- command: rejectedCommand,
236
- rejected_command: rejectedCommand,
237
- recommended_host_command: recommendedHostCommand,
238
- guide,
239
- documentation,
240
- },
241
- );
242
- }
243
-
244
- async function main() {
245
- const rawArgv = Bun.argv.slice(2);
246
-
247
- let isJson = process.env.SKILLMUX_JSON === "true";
248
- let allowInsecure = false;
249
- let isVerbose = false;
250
- let flagContext: string | undefined;
251
- let flagServer: string | undefined;
252
- let isDryRun = false;
253
- // Global flags do not form part of the command identity reported to users.
254
- // In particular, `init --json` should recommend `skillmux init`, not a
255
- // redundant JSON-only host command.
256
- const subCommand = rawArgv[1]?.startsWith("-") ? "" : rawArgv[1] ?? "";
257
- const commandArgs = rawArgv.slice(2);
258
-
259
- const command = rawArgv[0];
260
- if (command === "--version" || command === "-V") {
261
- console.log(packageJson.version);
262
- return;
263
- }
264
-
265
- if (!command || command === "--help" || command === "-h") {
266
- printHelp();
267
- return;
268
- }
269
-
270
- // Parse global flags for context/config
271
- for (let i = 0; i < rawArgv.length; i++) {
272
- const arg = rawArgv[i];
273
- if (arg === "--json") isJson = true;
274
- else if (arg === "--allow-insecure") allowInsecure = true;
275
- else if (arg === "--verbose") isVerbose = true;
276
- else if (arg === "--dry-run") isDryRun = true;
277
- else if (arg === "--context") flagContext = rawArgv[++i];
278
- else if (arg === "--server") flagServer = rawArgv[++i];
279
- }
280
-
281
- let resolvedContext: ResolvedContext = { type: "local", name: "local" };
282
-
283
- if (
284
- process.env.RUNNING_IN_DOCKER === "true" &&
285
- isDockerHostManagementCommand(command, subCommand)
286
- ) {
287
- await handleError(containerCommandUnsupported(command, subCommand), {
288
- context: resolvedContext,
289
- isJson,
290
- isVerbose,
291
- });
292
- return;
293
- }
294
-
295
- if (
296
- (rawArgv.includes("--help") || rawArgv.includes("-h")) &&
297
- printCommandHelp(command)
298
- ) {
299
- return;
300
- }
301
-
302
- try {
303
- resolvedContext = await resolveContext({
304
- context: flagContext,
305
- server: flagServer,
306
- });
307
- } catch (err: any) {
308
- await handleError(err, { context: resolvedContext, isJson, isVerbose });
309
- return;
310
- }
311
-
312
- const localOnlyCommand = getLocalOnlyCommand(command, subCommand);
313
- if (localOnlyCommand && resolvedContext.type === "remote") {
314
- await handleError(remoteContextUnsupported(localOnlyCommand), {
315
- context: resolvedContext,
316
- isJson,
317
- isVerbose,
318
- });
319
- return;
320
- }
321
-
322
- const adapter = createContextAdapter(resolvedContext, { allowInsecure });
323
-
324
- try {
325
- switch (command) {
326
- case "context":
327
- await handleContextCommand(subCommand, commandArgs, {
328
- context: resolvedContext,
329
- isJson,
330
- });
331
- break;
332
- case "config":
333
- await handleConfigCommand(adapter, subCommand, commandArgs, {
334
- context: resolvedContext,
335
- isJson,
336
- dryRun: isDryRun,
337
- });
338
- break;
339
- case "calibrate":
340
- throw new Error(
341
- 'skillmux calibrate was removed. Threshold calibration was removed; use "skillmux eval" for ranking evaluation.',
342
- );
343
- case "completions":
344
- await handleCompletionsCommand(subCommand);
345
- break;
346
- case "serve": {
347
- const { startServer } = await import("./server");
348
- const { transport, port, statsPort } = parseServeArgs(rawArgv.slice(1));
349
- const handle = await startServer({ transport, port, statsPort });
350
- let stopping = false;
351
- const shutdown = async () => {
352
- if (stopping) return;
353
- stopping = true;
354
- const timeout = setTimeout(() => process.exit(1), 10_000);
355
- timeout.unref();
356
- await handle.stop();
357
- clearTimeout(timeout);
358
- process.exit(0);
359
- };
360
- process.once("SIGTERM", shutdown);
361
- process.once("SIGINT", shutdown);
362
- if (transport === "stdio") {
363
- process.stdin.on("close", shutdown);
364
- process.stdin.on("end", shutdown);
365
- }
366
- break;
367
- }
368
- case "index":
369
- await runIndex();
370
- break;
371
- case "sync":
372
- await runSync(rawArgv.slice(1));
373
- break;
374
- case "init":
375
- await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
376
- break;
377
- case "project":
378
- await runProject(subCommand, commandArgs, {
379
- isJson,
380
- dryRun: isDryRun,
381
- sync: runSync,
382
- });
383
- break;
384
- case "target":
385
- await runTarget(subCommand, commandArgs, { isJson, dryRun: isDryRun });
386
- break;
387
- case "core":
388
- await runCore(subCommand, commandArgs, { isJson, dryRun: isDryRun });
389
- break;
390
- case "report":
391
- await runReport(rawArgv.slice(1), {
392
- isJson,
393
- context: resolvedContext,
394
- allowInsecure,
395
- adapter,
396
- });
397
- break;
398
- case "audit":
399
- await runAudit(subCommand, commandArgs, {
400
- isJson,
401
- dryRun: isDryRun,
402
- context: resolvedContext,
403
- adapter,
404
- });
405
- break;
406
- case "scan":
407
- await runScan(rawArgv.slice(1), { isJson });
408
- break;
409
- case "install":
410
- await runInstall(rawArgv.slice(1), { isJson });
411
- break;
412
- case "outdated":
413
- await runOutdated(rawArgv.slice(1), { isJson });
414
- break;
415
- case "update":
416
- await runUpdate(rawArgv.slice(1), { isJson });
417
- break;
418
- case "eval":
419
- if (subCommand === "promote") {
420
- await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun, adapter });
421
- } else if (subCommand === "") {
422
- await runEval({ isJson, adapter });
423
- } else {
424
- throw new Error(`usage: skillmux eval [promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]]`);
425
- }
426
- break;
427
- case "doctor":
428
- await runDoctor({
429
- isJson,
430
- context: resolvedContext,
431
- adapter,
432
- args: rawArgv.slice(1),
433
- });
434
- break;
435
- case "which":
436
- throw new Error(
437
- `skillmux which is removed - use "skillmux skill which ${subCommand || "<skill_id>"}" instead`,
438
- );
439
- case "skill":
440
- await runSkill(subCommand, commandArgs);
441
- break;
442
- case "manifest":
443
- throw new Error(
444
- `skillmux manifest is removed - use "skillmux core ${subCommand || "pin|unpin"}" for [core] skills, or "skillmux project ${subCommand || "pin|unpin"} <group>" for [project.*] skills`,
445
- );
446
- case "local-vault":
447
- if (subCommand !== "init")
448
- throw new Error("usage: skillmux local-vault init <path>");
449
- await runLocalVaultInit(commandArgs, { isJson, dryRun: isDryRun });
450
- break;
451
- case "models":
452
- if (subCommand !== "download")
453
- throw new Error("usage: skillmux models download");
454
- await runModelDownload({ isJson });
455
- break;
456
- default: {
457
- const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
458
- const msg = suggestion
459
- ? `Unknown command "${command}". Did you mean "${suggestion}"?`
460
- : `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|audit prune|scan|install|outdated|update|eval|doctor|skill which|local-vault init|config show|models download>`;
461
- throw new Error(msg);
462
- }
463
- }
464
- } catch (err: any) {
465
- await handleError(err, { context: resolvedContext, isJson, isVerbose });
466
- }
467
- }
468
-
469
-
470
-
471
- async function handleCompletionsCommand(shell: string) {
472
- if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
473
- throw new Error("usage: skillmux completions <bash|zsh|fish>");
474
- }
475
- console.log(generateCompletions(shell as ShellType));
476
- }
477
-
478
- async function handleError(
479
- err: any,
480
- opts: { context: ResolvedContext; isJson: boolean; isVerbose: boolean },
481
- ) {
482
- const code = mapExitCode(err);
483
- process.exitCode = code;
484
-
485
- const rawMsg = err instanceof Error ? err.message : String(err);
486
- // Best-effort: a broken config must not suppress the original error report,
487
- // so fall back to the URL-credential-only layer of buildRedactor(undefined)
488
- // rather than let a config-load failure mask the real failure.
489
- let redact: (text: string) => string;
490
- try {
491
- const { effective } = await getEffectiveConfig();
492
- redact = buildRedactor(effective);
493
- } catch {
494
- redact = buildRedactor(undefined);
495
- }
496
- const msg = redact(rawMsg);
497
-
498
- if (opts.isJson) {
499
- const env = formatJsonEnvelope({
500
- ok: false,
501
- context: opts.context,
502
- error: {
503
- code: err instanceof CliError ? err.code : `EXIT_${code}`,
504
- message: msg,
505
- details: err instanceof CliError ? err.details : undefined,
506
- },
507
- });
508
- console.log(JSON.stringify(env));
509
- } else {
510
- console.error(
511
- red(
512
- msg.startsWith("usage:") ||
513
- msg.startsWith("Unknown") ||
514
- msg.startsWith("error:")
515
- ? msg
516
- : `error: ${msg}`,
517
- ),
518
- );
519
- if (opts.isVerbose && err instanceof Error && err.stack) {
520
- console.error(redact(err.stack));
521
- }
522
- }
523
- }
524
-
525
- const COMMAND_HELP: Record<string, string> = {
526
- context: `context: manage named CLI contexts for remote administration
527
-
528
- usage:
529
- skillmux context list
530
- skillmux context current
531
- skillmux context add <name> --server <url> [--token-env <env_name>]
532
- skillmux context use <name>
533
- skillmux context remove <name>`,
534
-
535
- config: `config: inspect or update server/machine configuration
536
-
537
- usage:
538
- skillmux config init --vault <path> --yes
539
- skillmux config show [--sources]
540
- skillmux config get <key>
541
- skillmux config set <key> <value> [--dry-run]
542
- skillmux config validate
543
- skillmux config diff
544
- skillmux config status
545
-
546
- config init bootstraps this machine's config file from a populated vault. It
547
- is not a prerequisite for anything: "skillmux init --vault <path>" runs the
548
- same bootstrap when no config exists, so reach for config init only when you
549
- are setting up the config without the guided init.
550
-
551
- Accepts --context <name> / --server <url> to target a remote deployment.`,
552
-
553
- completions: `completions: generate a shell completion script
554
-
555
- usage:
556
- skillmux completions <bash|zsh|fish>`,
557
-
558
- serve: `serve: start the MCP server
559
-
560
- usage:
561
- skillmux serve [--transport stdio|http] [--port <port>] [--stats-port <port>]
562
-
563
- --transport defaults to stdio. --stats-port exposes GET /health and GET /stats
564
- alongside a stdio transport without opening the full HTTP surface.`,
565
-
566
- index: `index: rebuild the local retrieval index and backfill embeddings
567
-
568
- usage:
569
- skillmux index`,
570
-
571
- sync: `sync: apply the manifest to native agent target directories
572
-
573
- usage:
574
- skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]
575
-
576
- --dry-run prints what would change without writing. --yes approves creating
577
- a target directory that does not exist yet; without it, an unseen directory
578
- is skipped rather than created.
579
-
580
- --install-hook installs a git post-merge hook in the vault checkout so a
581
- "git pull" re-syncs the targets automatically.
582
-
583
- --restore-monolith undoes managed-pin delivery for a target: instead of
584
- individual pinned skills, the target directory is replaced by a single
585
- symlink to the whole vault. It refuses to touch a directory skillmux does
586
- not own, one carrying a local_vault marker, or one whose marker points at a
587
- different vault.`,
588
-
589
- init: `init: guided setup for native skill management
590
-
591
- usage:
592
- skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
593
- [--migrate-full-vault] [--show-mcp-setup] [--register-mcp]
594
- [--no-instructions] [--no-sync]
595
- [--interactive|--yes|--dry-run] [--json]
596
-
597
- agents: ${SUPPORTED_AGENT_IDS.join(", ")}
598
-
599
- Native pins and MCP are independent — skip both of the flags below for
600
- native-only setup, and init writes no instruction files (the managed
601
- block only teaches resolve_skill/fetch_skill, which are MCP tools).
602
- --show-mcp-setup prints the MCP registration snippet to copy in yourself,
603
- for any agent, and also writes the instruction block for every selected
604
- agent. --register-mcp instead runs that agent's own CLI to register
605
- skillmux automatically, but only for claude-code and codex (the only
606
- agents with a verified registration command), and writes the instruction
607
- block just for those; interactively, init asks about this only when
608
- you've selected one of those two. --no-instructions forces instruction
609
- writes off even when an MCP flag is set. A tool not in the agents list
610
- above isn't supported by init yet — add it to SUPPORTED_AGENT_IDS rather
611
- than guessing a directory. To adopt an arbitrary existing directory
612
- directly, use "skillmux target add <name> --dir <dir>" instead of init.`,
613
-
614
- project: `project: manage project-scoped skill pins and sync groups
615
-
616
- usage:
617
- skillmux project init [path] [--name <group>] [--skill <skill_id>...]
618
- [--agent <name>...] [--target <name>...] [--register-mcp]
619
- [--no-sync] [--interactive|--yes|--dry-run] [--json]
620
- skillmux project list
621
- skillmux project show <group>
622
- skillmux project add-path <group> [path] --yes
623
- skillmux project remove-path <group> [path] --yes
624
- skillmux project pin <group> <skill_id>... --yes
625
- skillmux project unpin <group> <skill_id>... --yes
626
- skillmux project attach <group> (--agent <id>... | --target <name>...) --yes
627
- skillmux project detach <group> (--agent <id>... | --target <name>...) --yes
628
-
629
- --agent and --target both name sync targets, and either may be repeated.
630
- --target <name> names a target directly, including a custom one created by
631
- "skillmux target add". --agent <id> is shorthand for "whatever target that
632
- agent maps to", resolved from the targets init already configured, so it
633
- fails if that agent was never set up or maps to no target at all (goose and
634
- hermes use full-vault delivery and have none). Several agents can share one
635
- target, so attaching two agents that map to the same directory attaches it
636
- once.
637
-
638
- --register-mcp is the project-local counterpart to "skillmux init
639
- --register-mcp": only for claude-code (the only agent whose own CLI has a
640
- project MCP scope — codex's mcp add has no scope flag, so it's always
641
- global). It runs "claude mcp add -s project" for this project directory,
642
- which writes a committed .mcp.json shared with your team, and writes a
643
- project-root CLAUDE.md with the resolve_skill/fetch_skill discovery
644
- paragraph — same reasoning as init: no instruction file is written unless
645
- MCP is actually being registered.`,
646
-
647
- target: `target: manage native sync target directories
648
-
649
- usage:
650
- skillmux target list
651
- skillmux target show <name>
652
- skillmux target add <name> [--dir <dir>] --yes
653
- skillmux target remove <name> --yes
654
- skillmux target rehome <name> --yes
655
- skillmux target migrate --yes
656
-
657
- --dir may be omitted when <name> is a built-in target with a deterministic
658
- path: agent-skills, claude-code, codex. Any other <name> requires --dir.
659
-
660
- A target is a directory, not a product. Several agents can map to the same
661
- one (opencode, github-copilot and windsurf all share agent-skills), which is
662
- why "skillmux project attach" accepts --agent as shorthand for the target
663
- that agent resolves to.`,
664
-
665
- core: `core: pin or unpin core-tier skills
666
-
667
- usage:
668
- skillmux core pin <skill_id>... --yes
669
- skillmux core unpin <skill_id>... --yes`,
670
-
671
- report: `report: show routing/fetch-outcome audit statistics
672
-
673
- usage:
674
- skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]`,
675
-
676
- audit: `audit: prune the audit database
677
-
678
- usage:
679
- skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
680
-
681
- Accepts --context <name> / --server <url> to prune a remote deployment's audit db.`,
682
-
683
- scan: `scan: check the vault for install-time or integrity issues
684
-
685
- usage:
686
- skillmux scan [path] [--fail-on low|medium|high|none] [--json]
687
-
688
- Scans [path], or the configured vault when omitted. Reporting only: it
689
- exits 0 whatever it finds unless --fail-on names a severity, which is why
690
- it has no default threshold while install and update default to high.
691
-
692
- --format text|json is deprecated: it emits JSON outside the standard
693
- envelope. Use --json instead; --format will be removed in a future 1.x
694
- release.`,
695
-
696
- install: `install: install a skill from a git source
697
-
698
- usage:
699
- skillmux install <repo>[/path] [--yes] [--force] [--fail-on low|medium|high|none] [--dry-run] [--allow-local-source] [--json]
700
-
701
- --yes approves writing the skill into the vault. Without it an interactive
702
- run asks first, and a non-interactive one (no TTY, or --json) fails rather
703
- than installing unattended, matching "skillmux update".
704
-
705
- The fetched skill is scanned before it is written to the vault. --fail-on
706
- sets the severity that aborts the install and defaults to high; pass
707
- --fail-on none to install despite findings. A lower threshold is stricter:
708
- low aborts on low, medium and high.
709
-
710
- --force overwrites a skill that already exists in the vault instead of
711
- refusing. --dry-run reports where the skill would land without writing.
712
- --allow-local-source permits a file:// or local path source, which is
713
- otherwise rejected.`,
714
-
715
- outdated: `outdated: list installed skills with a newer upstream version
716
-
717
- usage:
718
- skillmux outdated [--allow-local-source] [--json]
719
-
720
- Read-only: it reports what "skillmux update" would change and writes
721
- nothing. --allow-local-source includes skills installed from a local or
722
- file:// source, which are skipped by default because their upstream is a
723
- path on this machine rather than a shared remote.`,
724
-
725
- update: `update: update one or all skills to their latest source version
726
-
727
- usage:
728
- skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high|none] [--json]
729
-
730
- Updates every installed skill, or just <skill-id>. --yes is required to
731
- apply non-interactively. --dry-run prints the plan without writing.
732
-
733
- --fail-on works exactly as it does for install and defaults to high, so a
734
- skill whose new version carries a high-severity finding is skipped rather
735
- than updated; --fail-on none restores the old permissive behavior.
736
-
737
- --force updates a skill whose local content no longer matches the hash
738
- recorded at install time, which otherwise blocks the update to avoid
739
- discarding local edits. --allow-local-source permits local/file:// sources.`,
740
-
741
- eval: `eval: run retrieval evaluation against the holdout set
742
-
743
- usage:
744
- skillmux eval [--json]
745
- skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
746
-
747
- Accepts --context <name> / --server <url> to evaluate a remote deployment.`,
748
-
749
- doctor: `doctor: check server/environment readiness
750
-
751
- usage:
752
- skillmux doctor [--json]
753
-
754
- Accepts --context <name> / --server <url> to check a remote deployment.`,
755
-
756
- skill: `skill: inspect local vault skill resolution
757
-
758
- usage:
759
- skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)`,
760
-
761
- "local-vault": `local-vault: register an additional local vault checkout
762
-
763
- usage:
764
- skillmux local-vault init <path> --yes`,
765
-
766
- models: `models: manage local embedding model downloads
767
-
768
- usage:
769
- skillmux models download`,
770
- };
771
-
772
- function printCommandHelp(command: string): boolean {
773
- const help = COMMAND_HELP[command];
774
- if (!help) return false;
775
- console.log(help);
776
- return true;
777
- }
778
-
779
- function printHelp(): void {
780
- if (process.env.RUNNING_IN_DOCKER === "true") {
781
- console.log(`Skillmux server image
782
-
783
- Default:
784
- serve --transport http
785
-
786
- Supported commands:
787
- serve, index, doctor, report, audit prune, eval promote, scan, skill which
788
- config show|get|validate|diff|status
789
-
790
- Native skill management:
791
- Install the Skillmux CLI on the host for init, install, pinning, and sync.
792
-
793
- See docs/deployment.md for server deployment examples.`);
794
- return;
795
- }
796
-
797
- console.log(`usage: skillmux <command> [options]
798
-
799
- Setup:
800
- skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
801
- [--migrate-full-vault] [--show-mcp-setup] [--register-mcp]
802
- [--no-instructions] [--no-sync]
803
- [--interactive|--yes|--dry-run] [--json]
804
- skillmux project init [path] [--name <group>] [--skill <skill_id>...]
805
- [--agent <name>...] [--target <name>...] [--no-sync]
806
- [--interactive|--yes|--dry-run] [--json]
807
- skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
808
- skillmux target <list|show|add|remove|rehome|migrate> (a target is a directory sync writes into)
809
- skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
810
- skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
811
- skillmux config init --vault <path> --yes
812
- (bootstraps this machine's config on its own; not a
813
- prerequisite, since "skillmux init --vault" does the same)
814
-
815
- Init agents:
816
- ${SUPPORTED_AGENT_IDS.join(", ")}
817
- ("skillmux init --show-mcp-setup" also prints the MCP registration
818
- snippet, independent of which agents you select. A tool not in this
819
- list isn't supported by init yet — see "skillmux init --help".)
820
-
821
- Operations:
822
- skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]
823
- skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
824
- skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
825
- skillmux outdated [--allow-local-source] [--json]
826
- skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high|none] [--json]
827
-
828
- Commands:
829
- serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
830
- eval, doctor, skill, local-vault, config, models, context, completions
831
-
832
- Run "skillmux <command> --help" for a command's full usage.`);
833
- }
834
-
835
- // ---------------------------------------------------------------------------
836
- // Implementation of commands: serve, index, sync, init, report, scan, install, eval, doctor, models
837
- // ---------------------------------------------------------------------------
838
-
839
- type Transport = "stdio" | "http";
840
-
841
- function parseServeArgs(args: string[]): {
842
- transport: Transport;
843
- port?: number;
844
- statsPort?: number;
845
- } {
846
- let transport: Transport = "stdio";
847
- let port: number | undefined;
848
- let statsPort: number | undefined;
849
- for (let i = 0; i < args.length; i++) {
850
- const option = args[i];
851
- const value = args[i + 1];
852
- if (option === "--transport") {
853
- if (value !== "stdio" && value !== "http") {
854
- throw new Error("--transport must be stdio or http");
855
- }
856
- transport = value;
857
- i++;
858
- } else if (option === "--port") {
859
- const parsed = Number(value);
860
- if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
861
- throw new Error("--port must be an integer between 0 and 65535");
862
- }
863
- port = parsed;
864
- i++;
865
- } else if (option === "--stats-port") {
866
- const parsed = Number(value);
867
- if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
868
- throw new Error("--stats-port must be an integer between 0 and 65535");
869
- }
870
- statsPort = parsed;
871
- i++;
872
- } else {
873
- throw new Error(`unknown serve option: ${option}`);
874
- }
875
- }
876
- return { transport, port, statsPort };
877
- }
878
-
879
- async function runIndex(): Promise<void> {
880
- const config = await loadConfig();
881
- configure({ config, clients: createClients(config) });
882
- const report = await rebuildIndex((skillId, error) => {
883
- warn(`keeping previous index entry for ${skillId}: ${error}`);
884
- });
885
- const retainedNote =
886
- report.retained.length > 0
887
- ? ` (${report.retained.length} retained after parse errors)`
888
- : "";
889
- console.log(`indexed ${report.indexed} skills${retainedNote}`);
890
-
891
- try {
892
- const backfilled = await backfillEmbeddings();
893
- console.log(`embeddings: ${backfilled} backfilled`);
894
- } catch {
895
- console.log(
896
- "embeddings: skipped (endpoint unreachable; lexical-only recall until next index)",
897
- );
898
- }
899
- }
900
-
901
- async function runEval(options: { isJson: boolean; adapter: ContextAdapter }): Promise<void> {
902
- const config = await loadConfig();
903
- configure({ config, clients: createClients(config) });
904
-
905
- const report = await options.adapter.evalRun().catch((error: unknown) => {
906
- throw new Error(
907
- `eval requires an embeddings client (local model or a configured remote endpoint): ${String(error)}`,
908
- );
909
- });
910
- emitSuccess({ isJson: options.isJson }, report, () => {
911
- console.log(`holdout queries: ${report.queries}`);
912
- console.log(`judged queries: ${report.judged_queries}`);
913
- console.log(`unjudged queries: ${report.unjudged_queries}`);
914
- console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
915
- console.log(`lexical recall@10: ${report.lexical.recall_at_10.toFixed(3)}`);
916
- console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
917
- console.log(`lexical nDCG@10: ${report.lexical.ndcg_at_10.toFixed(3)}`);
918
- console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
919
- console.log(`hybrid recall@10: ${report.hybrid.recall_at_10.toFixed(3)}`);
920
- console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
921
- console.log(`hybrid nDCG@10: ${report.hybrid.ndcg_at_10.toFixed(3)}`);
922
- });
923
- }
924
-
925
-
926
- if (import.meta.main) {
927
- await main();
928
- }