@klhapp/skillmux 1.10.0 → 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.
package/src/cli.ts CHANGED
@@ -17,7 +17,7 @@ import { type StatsResponse } from "./stats";
17
17
  import { scanVault } from "./vault";
18
18
 
19
19
  import { resolveContext, type ResolvedContext } from "./context";
20
- import { createTargetAdapter, isLoopbackHost, type TargetAdapter } from "./adapters";
20
+ import { createContextAdapter, isLoopbackHost, type ContextAdapter } from "./adapters";
21
21
  import {
22
22
  emitSuccess,
23
23
  CliError,
@@ -28,6 +28,7 @@ import {
28
28
  warn,
29
29
  } from "./output";
30
30
  import { generateCompletions, type ShellType } from "./completions";
31
+ import { SUPPORTED_AGENT_IDS } from "./init-agents";
31
32
  import { runAudit } from "./commands/audit";
32
33
  import { handleConfigCommand } from "./commands/config";
33
34
  import { handleContextCommand } from "./commands/context";
@@ -45,7 +46,7 @@ import { runTarget } from "./commands/target";
45
46
  import { runSync } from "./commands/sync";
46
47
  import { runInit } from "./commands/init";
47
48
 
48
- const KNOWN_COMMANDS = [
49
+ export const KNOWN_COMMANDS = [
49
50
  "context",
50
51
  "config",
51
52
  "completions",
@@ -69,39 +70,120 @@ const KNOWN_COMMANDS = [
69
70
  "local-vault",
70
71
  ];
71
72
 
72
- const LOCAL_ONLY_COMMANDS = new Set([
73
- "install",
74
- "update",
75
- "outdated",
76
- "sync",
77
- "core",
78
- "project",
79
- "target",
80
- "local-vault",
81
- "index",
82
- "models",
83
- "scan",
84
- "init",
85
- "serve",
86
- ]);
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
+ };
87
120
 
88
- function getLocalOnlyCommand(command: string, subCommand: string): string | null {
89
- if (LOCAL_ONLY_COMMANDS.has(command)) {
90
- return command;
91
- }
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 {
92
128
  if (command === "skill" && (subCommand === "which" || !subCommand)) {
93
129
  return "skill which";
94
130
  }
131
+ if (command === "config" && subCommand === "init") {
132
+ return "config init";
133
+ }
134
+ if (LOCAL_ONLY_COMMANDS.has(command)) {
135
+ return command;
136
+ }
95
137
  return null;
96
138
  }
97
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
+
98
177
  function remoteContextUnsupported(rejectedCommand: string): CliError {
178
+ const reason = LOCAL_ONLY_REASON[rejectedCommand];
179
+ const guidance = reason ? ` ${LOCAL_ONLY_GUIDANCE[reason]}` : "";
99
180
  return new CliError(
100
- `\`${rejectedCommand}\` operates on the local vault only; --context/--server isn't supported here`,
181
+ `\`${rejectedCommand}\` operates on the local vault only; --context/--server isn't supported here.${guidance}`,
101
182
  2,
102
183
  "REMOTE_CONTEXT_UNSUPPORTED",
103
184
  {
104
185
  rejected_command: rejectedCommand,
186
+ ...(reason ? { reason } : {}),
105
187
  },
106
188
  );
107
189
  }
@@ -196,14 +278,14 @@ async function main() {
196
278
  else if (arg === "--server") flagServer = rawArgv[++i];
197
279
  }
198
280
 
199
- let resolvedTarget: ResolvedContext = { type: "local", name: "local" };
281
+ let resolvedContext: ResolvedContext = { type: "local", name: "local" };
200
282
 
201
283
  if (
202
284
  process.env.RUNNING_IN_DOCKER === "true" &&
203
285
  isDockerHostManagementCommand(command, subCommand)
204
286
  ) {
205
287
  await handleError(containerCommandUnsupported(command, subCommand), {
206
- target: resolvedTarget,
288
+ context: resolvedContext,
207
289
  isJson,
208
290
  isVerbose,
209
291
  });
@@ -218,38 +300,38 @@ async function main() {
218
300
  }
219
301
 
220
302
  try {
221
- resolvedTarget = await resolveContext({
303
+ resolvedContext = await resolveContext({
222
304
  context: flagContext,
223
305
  server: flagServer,
224
306
  });
225
307
  } catch (err: any) {
226
- await handleError(err, { target: resolvedTarget, isJson, isVerbose });
308
+ await handleError(err, { context: resolvedContext, isJson, isVerbose });
227
309
  return;
228
310
  }
229
311
 
230
312
  const localOnlyCommand = getLocalOnlyCommand(command, subCommand);
231
- if (localOnlyCommand && resolvedTarget.type === "remote") {
313
+ if (localOnlyCommand && resolvedContext.type === "remote") {
232
314
  await handleError(remoteContextUnsupported(localOnlyCommand), {
233
- target: resolvedTarget,
315
+ context: resolvedContext,
234
316
  isJson,
235
317
  isVerbose,
236
318
  });
237
319
  return;
238
320
  }
239
321
 
240
- const adapter = createTargetAdapter(resolvedTarget, { allowInsecure });
322
+ const adapter = createContextAdapter(resolvedContext, { allowInsecure });
241
323
 
242
324
  try {
243
325
  switch (command) {
244
326
  case "context":
245
327
  await handleContextCommand(subCommand, commandArgs, {
246
- target: resolvedTarget,
328
+ context: resolvedContext,
247
329
  isJson,
248
330
  });
249
331
  break;
250
332
  case "config":
251
333
  await handleConfigCommand(adapter, subCommand, commandArgs, {
252
- target: resolvedTarget,
334
+ context: resolvedContext,
253
335
  isJson,
254
336
  dryRun: isDryRun,
255
337
  });
@@ -308,7 +390,7 @@ async function main() {
308
390
  case "report":
309
391
  await runReport(rawArgv.slice(1), {
310
392
  isJson,
311
- target: resolvedTarget,
393
+ context: resolvedContext,
312
394
  allowInsecure,
313
395
  adapter,
314
396
  });
@@ -317,7 +399,7 @@ async function main() {
317
399
  await runAudit(subCommand, commandArgs, {
318
400
  isJson,
319
401
  dryRun: isDryRun,
320
- target: resolvedTarget,
402
+ context: resolvedContext,
321
403
  adapter,
322
404
  });
323
405
  break;
@@ -339,11 +421,16 @@ async function main() {
339
421
  } else if (subCommand === "") {
340
422
  await runEval({ isJson, adapter });
341
423
  } else {
342
- 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]]`);
343
425
  }
344
426
  break;
345
427
  case "doctor":
346
- await runDoctor({ isJson, target: resolvedTarget, adapter });
428
+ await runDoctor({
429
+ isJson,
430
+ context: resolvedContext,
431
+ adapter,
432
+ args: rawArgv.slice(1),
433
+ });
347
434
  break;
348
435
  case "which":
349
436
  throw new Error(
@@ -375,7 +462,7 @@ async function main() {
375
462
  }
376
463
  }
377
464
  } catch (err: any) {
378
- await handleError(err, { target: resolvedTarget, isJson, isVerbose });
465
+ await handleError(err, { context: resolvedContext, isJson, isVerbose });
379
466
  }
380
467
  }
381
468
 
@@ -390,7 +477,7 @@ async function handleCompletionsCommand(shell: string) {
390
477
 
391
478
  async function handleError(
392
479
  err: any,
393
- opts: { target: ResolvedContext; isJson: boolean; isVerbose: boolean },
480
+ opts: { context: ResolvedContext; isJson: boolean; isVerbose: boolean },
394
481
  ) {
395
482
  const code = mapExitCode(err);
396
483
  process.exitCode = code;
@@ -411,7 +498,7 @@ async function handleError(
411
498
  if (opts.isJson) {
412
499
  const env = formatJsonEnvelope({
413
500
  ok: false,
414
- target: opts.target,
501
+ context: opts.context,
415
502
  error: {
416
503
  code: err instanceof CliError ? err.code : `EXIT_${code}`,
417
504
  message: msg,
@@ -476,7 +563,7 @@ alongside a stdio transport without opening the full HTTP surface.`,
476
563
  usage:
477
564
  skillmux index`,
478
565
 
479
- sync: `sync: apply the manifest to native client target directories
566
+ sync: `sync: apply the manifest to native agent target directories
480
567
 
481
568
  usage:
482
569
  skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]`,
@@ -484,37 +571,62 @@ usage:
484
571
  init: `init: guided setup for native skill management
485
572
 
486
573
  usage:
487
- skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
488
- [--vault <path>] [--core <skill_id>...]
489
- [--migrate-full-vault] [--no-instructions] [--no-sync]
574
+ skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
575
+ [--migrate-full-vault] [--show-mcp-setup] [--register-mcp]
576
+ [--no-instructions] [--no-sync]
490
577
  [--interactive|--yes|--dry-run] [--json]
491
578
 
492
- clients: claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
493
- antigravity, goose, hermes, skillmux-mcp
494
- targets: agent-skills, claude-code, codex, custom`,
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.`,
495
595
 
496
596
  project: `project: manage project-scoped skill pins and sync groups
497
597
 
498
598
  usage:
499
599
  skillmux project init [path] [--name <group>] [--skill <skill_id>...]
500
- [--client <name>...] [--target <name>...] [--no-sync]
501
- [--interactive|--yes|--dry-run] [--json]
600
+ [--agent <name>...] [--target <name>...] [--register-mcp]
601
+ [--no-sync] [--interactive|--yes|--dry-run] [--json]
502
602
  skillmux project list
503
603
  skillmux project show <group>
504
604
  skillmux project add-path <group> [path] --yes
505
605
  skillmux project remove-path <group> [path] --yes
506
606
  skillmux project pin <group> <skill_id>... --yes
507
607
  skillmux project unpin <group> <skill_id>... --yes
508
- skillmux project attach <group> (--client <id>... | --target <name>...) --yes
509
- skillmux project detach <group> (--client <id>... | --target <name>...) --yes`,
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.`,
510
619
 
511
620
  target: `target: manage native sync target directories
512
621
 
513
622
  usage:
514
623
  skillmux target list
515
624
  skillmux target show <name>
516
- skillmux target add <name> --dir <dir> --yes
517
- skillmux target remove <name> --yes`,
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.`,
518
630
 
519
631
  core: `core: pin or unpin core-tier skills
520
632
 
@@ -558,7 +670,7 @@ usage:
558
670
 
559
671
  usage:
560
672
  skillmux eval [--json]
561
- skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
673
+ skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
562
674
 
563
675
  Accepts --context <name> / --server <url> to evaluate a remote deployment.`,
564
676
 
@@ -614,29 +726,27 @@ See docs/deployment.md for server deployment examples.`);
614
726
 
615
727
  Setup:
616
728
  skillmux config init --vault <path> --yes
617
- skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
618
- [--vault <path>] [--core <skill_id>...]
729
+ skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
619
730
  [--migrate-full-vault] [--no-instructions] [--no-sync]
620
731
  [--interactive|--yes|--dry-run] [--json]
621
732
  skillmux project init [path] [--name <group>] [--skill <skill_id>...]
622
- [--client <name>...] [--target <name>...] [--no-sync]
733
+ [--agent <name>...] [--target <name>...] [--no-sync]
623
734
  [--interactive|--yes|--dry-run] [--json]
624
735
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
625
736
  skillmux target <list|show|add|remove>
626
737
  skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
627
738
  skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
628
739
 
629
- Init clients:
630
- claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
631
- antigravity, goose, hermes, skillmux-mcp
632
-
633
- Init targets:
634
- 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".)
635
745
 
636
746
  Operations:
637
747
  skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]
638
748
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
639
- skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
749
+ skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
640
750
  skillmux outdated [--allow-local-source] [--json]
641
751
  skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
642
752
 
@@ -713,7 +823,7 @@ async function runIndex(): Promise<void> {
713
823
  }
714
824
  }
715
825
 
716
- async function runEval(options: { isJson: boolean; adapter: TargetAdapter }): Promise<void> {
826
+ async function runEval(options: { isJson: boolean; adapter: ContextAdapter }): Promise<void> {
717
827
  const config = await loadConfig();
718
828
  configure({ config, clients: createClients(config) });
719
829
 
@@ -1,13 +1,13 @@
1
1
  import { emitSuccess } from "../output";
2
2
  import { confirmIfNeeded } from "./shared";
3
- import type { TargetAdapter } from "../adapters";
3
+ import type { ContextAdapter } from "../adapters";
4
4
  import type { ResolvedContext } from "../context";
5
5
  import { isGlobalFlag, isGlobalFlagWithValue } from "../global-flags";
6
6
 
7
7
  export async function runAudit(
8
8
  subCommand: string,
9
9
  args: string[],
10
- options: { isJson: boolean; dryRun: boolean; target: ResolvedContext; adapter: TargetAdapter },
10
+ options: { isJson: boolean; dryRun: boolean; context: ResolvedContext; adapter: ContextAdapter },
11
11
  ): Promise<void> {
12
12
  if (subCommand !== "prune") {
13
13
  throw new Error("usage: skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]");
@@ -1,8 +1,8 @@
1
1
  import { expandHome, migrateLegacyPaths, resolveConfigPath } from "../config";
2
- import { type TargetAdapter } from "../adapters";
2
+ import { type ContextAdapter } from "../adapters";
3
3
  import { type ResolvedContext } from "../context";
4
4
  import { applyConfigInit, planConfigInit, type ConfigInitPlan } from "../setup";
5
- import { emitSuccess, isInteractive, renderTargetBanner } from "../output";
5
+ import { emitSuccess, isInteractive, renderContextBanner, unknownSubcommandError } from "../output";
6
6
  import { confirmAction } from "./shared";
7
7
  import { isGlobalFlag } from "../global-flags";
8
8
  function emitConfigInitOutcome(
@@ -38,10 +38,10 @@ function emitConfigInitOutcome(
38
38
  }
39
39
 
40
40
  export async function handleConfigCommand(
41
- adapter: TargetAdapter,
41
+ adapter: ContextAdapter,
42
42
  sub: string,
43
43
  args: string[],
44
- ctx: { target: ResolvedContext; isJson: boolean; dryRun: boolean },
44
+ ctx: { context: ResolvedContext; isJson: boolean; dryRun: boolean },
45
45
  ) {
46
46
  if (sub === "init") {
47
47
  let vaultPath: string | undefined;
@@ -127,8 +127,8 @@ export async function handleConfigCommand(
127
127
  if (sub === "show") {
128
128
  const withSources = args.includes("--sources");
129
129
  const data = await adapter.getConfigShow();
130
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
131
- renderTargetBanner(ctx.target);
130
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, data, () => {
131
+ renderContextBanner(ctx.context);
132
132
  if (withSources) {
133
133
  const policy =
134
134
  data.effective.config?.environment_overrides === false
@@ -151,7 +151,7 @@ export async function handleConfigCommand(
151
151
  if (!key) throw new Error("usage: skillmux config get <key>");
152
152
  const val = await adapter.getConfigGet(key);
153
153
  emitSuccess(
154
- { isJson: ctx.isJson, target: ctx.target },
154
+ { isJson: ctx.isJson, context: ctx.context },
155
155
  { key, value: val },
156
156
  () => {
157
157
  console.log(
@@ -164,7 +164,7 @@ export async function handleConfigCommand(
164
164
 
165
165
  if (sub === "validate") {
166
166
  const res = await adapter.configValidate();
167
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
167
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
168
168
  console.log(res.valid ? "configuration is valid" : "configuration is invalid");
169
169
  });
170
170
  return;
@@ -172,8 +172,8 @@ export async function handleConfigCommand(
172
172
 
173
173
  if (sub === "diff") {
174
174
  const res = await adapter.configDiff();
175
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
176
- renderTargetBanner(ctx.target);
175
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
176
+ renderContextBanner(ctx.context);
177
177
  console.log(JSON.stringify(res.diff, null, 2));
178
178
  });
179
179
  return;
@@ -186,8 +186,8 @@ export async function handleConfigCommand(
186
186
  throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
187
187
  }
188
188
  const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
189
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
190
- renderTargetBanner(ctx.target);
189
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
190
+ renderContextBanner(ctx.context);
191
191
  const prefix = ctx.dryRun ? "[dry-run] " : "";
192
192
  console.log(
193
193
  `${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`,
@@ -201,8 +201,8 @@ export async function handleConfigCommand(
201
201
 
202
202
  if (sub === "status") {
203
203
  const res = await adapter.configStatus();
204
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
205
- renderTargetBanner(ctx.target);
204
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
205
+ renderContextBanner(ctx.context);
206
206
  console.log(`runtime: ${res.runtime}`);
207
207
  console.log(`deployment runtime: ${res.deployment_runtime}`);
208
208
  console.log(`image variant: ${res.image_variant ?? "none"}`);
@@ -212,5 +212,13 @@ export async function handleConfigCommand(
212
212
  return;
213
213
  }
214
214
 
215
- throw new Error("usage: skillmux config show");
215
+ throw unknownSubcommandError("config", sub, [
216
+ "init",
217
+ "show",
218
+ "get",
219
+ "set",
220
+ "validate",
221
+ "diff",
222
+ "status",
223
+ ]);
216
224
  }
@@ -9,18 +9,19 @@ import {
9
9
  import {
10
10
  emitSuccess,
11
11
  renderTable,
12
- renderTargetBanner,
12
+ renderContextBanner,
13
+ unknownSubcommandError,
13
14
  } from "../output";
14
15
 
15
16
  export async function handleContextCommand(
16
17
  sub: string,
17
18
  args: string[],
18
- ctx: { target: ResolvedContext; isJson: boolean },
19
+ ctx: { context: ResolvedContext; isJson: boolean },
19
20
  ) {
20
21
  if (sub === "list") {
21
22
  const contexts = await listContexts();
22
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, contexts, () => {
23
- renderTargetBanner(ctx.target);
23
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, contexts, () => {
24
+ renderContextBanner(ctx.context);
24
25
  renderTable(
25
26
  [
26
27
  { key: "name", header: "NAME" },
@@ -40,8 +41,8 @@ export async function handleContextCommand(
40
41
 
41
42
  if (sub === "current") {
42
43
  const current = await getCurrentContext();
43
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, current, () => {
44
- renderTargetBanner(ctx.target);
44
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, current, () => {
45
+ renderContextBanner(ctx.context);
45
46
  console.log(`Current context: ${current.name} (${current.server})`);
46
47
  });
47
48
  return;
@@ -62,7 +63,7 @@ export async function handleContextCommand(
62
63
  }
63
64
  await addContext(name, { server, token_env: tokenEnv });
64
65
  emitSuccess(
65
- { isJson: ctx.isJson, target: ctx.target },
66
+ { isJson: ctx.isJson, context: ctx.context },
66
67
  { name, server, token_env: tokenEnv },
67
68
  () => {
68
69
  console.log(`Added context "${name}" -> ${server}`);
@@ -76,7 +77,7 @@ export async function handleContextCommand(
76
77
  if (!name) throw new Error("usage: skillmux context use <name>");
77
78
  await useContext(name);
78
79
  emitSuccess(
79
- { isJson: ctx.isJson, target: ctx.target },
80
+ { isJson: ctx.isJson, context: ctx.context },
80
81
  { default_context: name },
81
82
  () => {
82
83
  console.log(`Switched default context to "${name}"`);
@@ -90,7 +91,7 @@ export async function handleContextCommand(
90
91
  if (!name) throw new Error("usage: skillmux context remove <name>");
91
92
  await removeContext(name);
92
93
  emitSuccess(
93
- { isJson: ctx.isJson, target: ctx.target },
94
+ { isJson: ctx.isJson, context: ctx.context },
94
95
  { removed: name },
95
96
  () => {
96
97
  console.log(`Removed context "${name}"`);
@@ -99,5 +100,5 @@ export async function handleContextCommand(
99
100
  return;
100
101
  }
101
102
 
102
- throw new Error("usage: skillmux context <add|list|current|use|remove>");
103
+ throw unknownSubcommandError("context", sub, ["add", "list", "current", "use", "remove"]);
103
104
  }
@@ -1,6 +1,6 @@
1
1
  import { expandHome } from "../config";
2
2
  import { pinCore, unpinCore, validateManifest, writeManifestAtomic } from "../manifest";
3
- import { emitSuccess } from "../output";
3
+ import { emitSuccess, unknownSubcommandError } from "../output";
4
4
  import { confirmIfNeeded, loadManifestContext } from "./shared";
5
5
  export async function runCore(
6
6
  subCommand: string,
@@ -8,7 +8,7 @@ export async function runCore(
8
8
  options: { isJson: boolean; dryRun: boolean },
9
9
  ): Promise<void> {
10
10
  if (subCommand !== "pin" && subCommand !== "unpin") {
11
- throw new Error("usage: skillmux core <pin|unpin>");
11
+ throw unknownSubcommandError("core", subCommand, ["pin", "unpin"]);
12
12
  }
13
13
  const skillIds = args.filter((arg) => !arg.startsWith("-"));
14
14
  if (skillIds.length === 0) {