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