@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.
@@ -0,0 +1,329 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const SUPPORTED_AGENT_IDS = [
6
+ "claude-code",
7
+ "codex",
8
+ "opencode",
9
+ "github-copilot",
10
+ "windsurf",
11
+ "antigravity",
12
+ "goose",
13
+ "hermes",
14
+ ] as const;
15
+
16
+ export type AgentId = (typeof SUPPORTED_AGENT_IDS)[number];
17
+ export type DeliveryMode = "managed-pins" | "full-vault";
18
+
19
+ export interface DetectedAgent {
20
+ agent: AgentId;
21
+ evidence: string;
22
+ }
23
+
24
+ export type McpRegistrationScope = "user" | "project";
25
+
26
+ export interface McpRegistrationCommand {
27
+ command: string;
28
+ /** Returns undefined when this agent's CLI has no way to register at the requested scope. */
29
+ buildArgs: (scope: McpRegistrationScope) => string[] | undefined;
30
+ }
31
+
32
+ interface AgentPathOptions {
33
+ home: string;
34
+ codexHome: string;
35
+ }
36
+
37
+ interface AgentInstructionOptions extends AgentPathOptions {
38
+ claudeConfigDir: string;
39
+ }
40
+
41
+ /**
42
+ * The single source of truth for what each agent supports. Every other
43
+ * module (detection, skill-surface planning, readiness reporting, MCP
44
+ * registration, instruction files, shell completions, CLI help text) reads
45
+ * from this record instead of keeping its own copy — see git history for
46
+ * what it looked like before consolidation (agent support was duplicated
47
+ * across 6+ files and could silently drift, e.g. shell completions offering
48
+ * goose/hermes for `project init` when they can't actually be attached).
49
+ */
50
+ interface AgentDefinition {
51
+ id: AgentId;
52
+ surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
53
+ deliveryMode: DeliveryMode;
54
+ /** Filesystem evidence used by guided-mode agent detection. */
55
+ detectionPath?: (options: AgentPathOptions) => string;
56
+ /** Bespoke readiness message for full-vault agents with no managed-pins surface. */
57
+ manualSkillSurfaceMessage?: string;
58
+ instructions?: {
59
+ global?: (options: AgentInstructionOptions) => string;
60
+ project?: (projectRoot: string) => string;
61
+ };
62
+ /**
63
+ * Only set for agents whose own CLI's exact registration command was
64
+ * verified against its own --help, not guessed from docs. Every other
65
+ * agent falls back to printing the registration snippet (see
66
+ * printLastMile in init.ts) rather than a guessed command.
67
+ */
68
+ mcpRegistration?: McpRegistrationCommand;
69
+ }
70
+
71
+ export interface PlannedAgentSurface {
72
+ id: string;
73
+ targetName: string;
74
+ path: string;
75
+ deliveryMode: "managed-pins";
76
+ agents: AgentId[];
77
+ }
78
+
79
+ export interface AgentSurfacePlan {
80
+ agents: AgentDefinition[];
81
+ surfaces: PlannedAgentSurface[];
82
+ }
83
+
84
+ type ReadinessStatus = "ready" | "planned" | "manual" | "not-applicable";
85
+
86
+ export interface ReadinessAxis {
87
+ status: ReadinessStatus;
88
+ detail: string;
89
+ }
90
+
91
+ export interface AgentReadiness {
92
+ agent: AgentId;
93
+ skillSurface: ReadinessAxis;
94
+ mcpRegistration: ReadinessAxis;
95
+ instructionSetup: ReadinessAxis;
96
+ }
97
+
98
+ export interface ResolvedBuiltInTarget {
99
+ targetName: string;
100
+ path: string;
101
+ }
102
+
103
+ /** Target names whose install directory is deterministic — --dir is optional for these. */
104
+ export const BUILT_IN_TARGET_NAMES = new Set(["agent-skills", "claude-code", "codex"]);
105
+
106
+ const AGENTS: Record<AgentId, AgentDefinition> = {
107
+ "claude-code": {
108
+ id: "claude-code",
109
+ surfaceId: "claude-code",
110
+ deliveryMode: "managed-pins",
111
+ detectionPath: ({ home }) => join(home, ".claude"),
112
+ instructions: {
113
+ global: ({ claudeConfigDir }) => join(claudeConfigDir, "CLAUDE.md"),
114
+ project: (projectRoot) => join(projectRoot, "CLAUDE.md"),
115
+ },
116
+ mcpRegistration: {
117
+ command: "claude",
118
+ // claude mcp add --scope: local (default, unshared), project (writes a
119
+ // committed .mcp.json), or user (global). "project" is the only shared
120
+ // option, so that's what a project-scoped registration means here.
121
+ buildArgs: (scope) => [
122
+ "mcp",
123
+ "add",
124
+ "-s",
125
+ scope === "project" ? "project" : "user",
126
+ "skillmux",
127
+ "--",
128
+ "skillmux",
129
+ "serve",
130
+ ],
131
+ },
132
+ },
133
+ codex: {
134
+ id: "codex",
135
+ surfaceId: "codex",
136
+ deliveryMode: "managed-pins",
137
+ detectionPath: ({ codexHome }) => codexHome,
138
+ instructions: {
139
+ global: ({ codexHome }) => join(codexHome, "AGENTS.md"),
140
+ },
141
+ mcpRegistration: {
142
+ command: "codex",
143
+ // codex mcp add has no --scope flag at all — it always writes to the
144
+ // global ~/.codex/config.toml, so project scope is not representable.
145
+ buildArgs: (scope) =>
146
+ scope === "project"
147
+ ? undefined
148
+ : ["mcp", "add", "skillmux", "--", "skillmux", "serve"],
149
+ },
150
+ },
151
+ opencode: {
152
+ id: "opencode",
153
+ surfaceId: "agent-skills",
154
+ deliveryMode: "managed-pins",
155
+ detectionPath: ({ home }) => join(home, ".config", "opencode"),
156
+ instructions: {
157
+ global: ({ home }) => join(home, ".config", "opencode", "AGENTS.md"),
158
+ },
159
+ },
160
+ "github-copilot": {
161
+ id: "github-copilot",
162
+ surfaceId: "agent-skills",
163
+ deliveryMode: "managed-pins",
164
+ detectionPath: ({ home }) => join(home, ".config", "github-copilot"),
165
+ },
166
+ windsurf: {
167
+ id: "windsurf",
168
+ surfaceId: "agent-skills",
169
+ deliveryMode: "managed-pins",
170
+ detectionPath: ({ home }) => join(home, ".codeium", "windsurf"),
171
+ },
172
+ antigravity: {
173
+ id: "antigravity",
174
+ surfaceId: "antigravity",
175
+ deliveryMode: "managed-pins",
176
+ instructions: {
177
+ global: ({ home }) => join(home, ".gemini", "GEMINI.md"),
178
+ },
179
+ },
180
+ goose: {
181
+ id: "goose",
182
+ deliveryMode: "full-vault",
183
+ detectionPath: ({ home }) => join(home, ".config", "goose"),
184
+ manualSkillSurfaceMessage: "configure the full vault in Goose",
185
+ instructions: {
186
+ global: ({ home }) => join(home, ".config", "goose", ".goosehints"),
187
+ },
188
+ },
189
+ hermes: {
190
+ id: "hermes",
191
+ deliveryMode: "full-vault",
192
+ detectionPath: ({ home }) => join(home, ".hermes"),
193
+ manualSkillSurfaceMessage: "configure the full vault in Hermes external_dirs",
194
+ instructions: {
195
+ global: ({ home }) => join(home, ".hermes.md"),
196
+ },
197
+ },
198
+ };
199
+
200
+ export function getAgentDefinition(agent: AgentId): AgentDefinition {
201
+ return AGENTS[agent];
202
+ }
203
+
204
+ /** Agents that can be attached to a project group (managed-pins delivery, not full-vault). */
205
+ export const MANAGED_PINS_AGENT_IDS = SUPPORTED_AGENT_IDS.filter(
206
+ (id) => AGENTS[id].deliveryMode === "managed-pins",
207
+ );
208
+
209
+ export function detectInstalledAgents(
210
+ options: {
211
+ home?: string;
212
+ codexHome?: string;
213
+ exists?: (path: string) => boolean;
214
+ } = {},
215
+ ): DetectedAgent[] {
216
+ const home = options.home ?? homedir();
217
+ const codexHome = options.codexHome ?? join(home, ".codex");
218
+ const exists = options.exists ?? existsSync;
219
+ return SUPPORTED_AGENT_IDS.flatMap((agent) => {
220
+ const evidence = AGENTS[agent].detectionPath?.({ home, codexHome });
221
+ return evidence && exists(evidence) ? [{ agent, evidence }] : [];
222
+ });
223
+ }
224
+
225
+ function surfacePath(
226
+ surfaceId: NonNullable<AgentDefinition["surfaceId"]>,
227
+ options: { home: string; codexHome?: string },
228
+ ): string {
229
+ if (surfaceId === "agent-skills") return join(options.home, ".agents", "skills");
230
+ if (surfaceId === "claude-code") return join(options.home, ".claude", "skills");
231
+ if (surfaceId === "codex") return join(options.codexHome ?? join(options.home, ".codex"), "skills");
232
+ return join(options.home, ".gemini", "config", "skills");
233
+ }
234
+
235
+ export function resolveBuiltInTarget(
236
+ name: string,
237
+ options: { home?: string; codexHome?: string; customPath?: string } = {},
238
+ ): ResolvedBuiltInTarget {
239
+ const home = options.home ?? homedir();
240
+ if (name === "custom") {
241
+ if (!options.customPath) throw new Error("--target custom requires --path <dir>");
242
+ return { targetName: name, path: options.customPath };
243
+ }
244
+ if (name === "agent-skills") {
245
+ return { targetName: name, path: surfacePath("agent-skills", { home }) };
246
+ }
247
+ if (name === "claude-code") {
248
+ return { targetName: name, path: surfacePath("claude-code", { home }) };
249
+ }
250
+ if (name === "codex") {
251
+ return {
252
+ targetName: name,
253
+ path: surfacePath("codex", { home, codexHome: options.codexHome }),
254
+ };
255
+ }
256
+ throw new Error(
257
+ `unknown --target "${name}"; supported targets: agent-skills, claude-code, codex, custom`,
258
+ );
259
+ }
260
+
261
+ export function planAgentSurfaces(
262
+ requestedAgents: readonly string[],
263
+ options: { home?: string; codexHome?: string } = {},
264
+ ): AgentSurfacePlan {
265
+ const agents = [...new Set(requestedAgents)].map((id) => {
266
+ if (!SUPPORTED_AGENT_IDS.includes(id as AgentId)) {
267
+ throw new Error(
268
+ `unsupported agent "${id}"; supported agents: ${SUPPORTED_AGENT_IDS.join(", ")}`,
269
+ );
270
+ }
271
+ return AGENTS[id as AgentId];
272
+ });
273
+ const home = options.home ?? homedir();
274
+ const surfaces = new Map<string, PlannedAgentSurface>();
275
+
276
+ for (const agent of agents) {
277
+ if (!agent.surfaceId) continue;
278
+ const path = surfacePath(agent.surfaceId, { home, codexHome: options.codexHome });
279
+ const existing = surfaces.get(path);
280
+ if (existing) {
281
+ if (!existing.agents.includes(agent.id)) existing.agents.push(agent.id);
282
+ continue;
283
+ }
284
+ surfaces.set(path, {
285
+ id: agent.surfaceId,
286
+ targetName: agent.surfaceId,
287
+ path,
288
+ deliveryMode: "managed-pins",
289
+ agents: [agent.id],
290
+ });
291
+ }
292
+
293
+ return { agents, surfaces: [...surfaces.values()] };
294
+ }
295
+
296
+ export function assessAgentReadiness(
297
+ plan: AgentSurfacePlan,
298
+ instructionReadiness: Partial<Record<AgentId, ReadinessAxis>> = {},
299
+ ): AgentReadiness[] {
300
+ return plan.agents.map((agent) => {
301
+ const surface = plan.surfaces.find((candidate) => candidate.agents.includes(agent.id));
302
+ let skillSurface: ReadinessAxis;
303
+ if (surface) {
304
+ skillSurface = { status: "planned", detail: surface.path };
305
+ } else if (agent.manualSkillSurfaceMessage) {
306
+ skillSurface = { status: "manual", detail: agent.manualSkillSurfaceMessage };
307
+ } else {
308
+ skillSurface = {
309
+ status: "not-applicable",
310
+ detail: "skills resolve through Skillmux MCP",
311
+ };
312
+ }
313
+
314
+ const mcpRegistration: ReadinessAxis = {
315
+ status: "not-applicable",
316
+ detail: "native skill loading",
317
+ };
318
+
319
+ return {
320
+ agent: agent.id,
321
+ skillSurface,
322
+ mcpRegistration,
323
+ instructionSetup: instructionReadiness[agent.id] ?? {
324
+ status: "manual",
325
+ detail: "instruction adapter not applied",
326
+ },
327
+ };
328
+ });
329
+ }
@@ -9,7 +9,7 @@ import {
9
9
  } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { DISCOVERY_PARAGRAPH } from "./init";
12
- import type { ClientId } from "./init-clients";
12
+ import { getAgentDefinition, type AgentId } from "./init-agents";
13
13
 
14
14
  export const INSTRUCTION_BLOCK_START = "<!-- skillmux:discovery:start -->";
15
15
  export const INSTRUCTION_BLOCK_END = "<!-- skillmux:discovery:end -->";
@@ -22,7 +22,7 @@ const MANAGED_BLOCK = [
22
22
 
23
23
  export interface InstructionChange {
24
24
  path: string;
25
- clients: ClientId[];
25
+ agents: AgentId[];
26
26
  status: "create" | "update" | "unchanged";
27
27
  before: string | null;
28
28
  after: string;
@@ -30,7 +30,7 @@ export interface InstructionChange {
30
30
 
31
31
  export interface InstructionPlan {
32
32
  changes: InstructionChange[];
33
- manual: Array<{ client: ClientId; reason: string }>;
33
+ manual: Array<{ agent: AgentId; reason: string }>;
34
34
  }
35
35
 
36
36
  interface InstructionPlanOptions {
@@ -41,18 +41,10 @@ interface InstructionPlanOptions {
41
41
  }
42
42
 
43
43
  function instructionPath(
44
- client: ClientId,
44
+ agent: AgentId,
45
45
  options: Required<Pick<InstructionPlanOptions, "home" | "codexHome" | "claudeConfigDir">>,
46
46
  ): string | undefined {
47
- if (client === "claude-code") return join(options.claudeConfigDir, "CLAUDE.md");
48
- if (client === "codex") return join(options.codexHome, "AGENTS.md");
49
- if (client === "gemini-cli" || client === "antigravity") {
50
- return join(options.home, ".gemini", "GEMINI.md");
51
- }
52
- if (client === "opencode") return join(options.home, ".config", "opencode", "AGENTS.md");
53
- if (client === "goose") return join(options.home, ".config", "goose", ".goosehints");
54
- if (client === "hermes") return join(options.home, ".hermes.md");
55
- return undefined;
47
+ return getAgentDefinition(agent).instructions?.global?.(options);
56
48
  }
57
49
 
58
50
  function readInstructionFile(path: string): string | null {
@@ -85,29 +77,23 @@ function withManagedBlock(existing: string | null, path: string): string {
85
77
  return `${existing.slice(0, start)}${MANAGED_BLOCK}${existing.slice(end + INSTRUCTION_BLOCK_END.length)}`;
86
78
  }
87
79
 
88
- export function planInstructionSetup(
89
- requestedClients: readonly ClientId[],
90
- options: InstructionPlanOptions = {},
80
+ function planInstructionSetupWithResolver(
81
+ requestedAgents: readonly AgentId[],
82
+ resolvePath: (agent: AgentId) => string | undefined,
83
+ readFile: (path: string) => string | null,
91
84
  ): InstructionPlan {
92
- const home = options.home ?? process.env.HOME ?? "";
93
- const resolvedOptions = {
94
- home,
95
- codexHome: options.codexHome ?? process.env.CODEX_HOME ?? join(home, ".codex"),
96
- claudeConfigDir: options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"),
97
- };
98
- const readFile = options.readFile ?? readInstructionFile;
99
85
  const changesByPath = new Map<string, InstructionChange>();
100
86
  const manual: InstructionPlan["manual"] = [];
101
87
 
102
- for (const client of [...new Set(requestedClients)]) {
103
- const path = instructionPath(client, resolvedOptions);
88
+ for (const agent of [...new Set(requestedAgents)]) {
89
+ const path = resolvePath(agent);
104
90
  if (!path) {
105
- manual.push({ client, reason: "no safe durable user instruction file is known" });
91
+ manual.push({ agent, reason: "no safe durable user instruction file is known" });
106
92
  continue;
107
93
  }
108
94
  const existingChange = changesByPath.get(path);
109
95
  if (existingChange) {
110
- existingChange.clients.push(client);
96
+ existingChange.agents.push(agent);
111
97
  continue;
112
98
  }
113
99
 
@@ -115,7 +101,7 @@ export function planInstructionSetup(
115
101
  const after = withManagedBlock(before, path);
116
102
  changesByPath.set(path, {
117
103
  path,
118
- clients: [client],
104
+ agents: [agent],
119
105
  status: before === null ? "create" : before === after ? "unchanged" : "update",
120
106
  before,
121
107
  after,
@@ -125,6 +111,39 @@ export function planInstructionSetup(
125
111
  return { changes: [...changesByPath.values()], manual };
126
112
  }
127
113
 
114
+ export function planInstructionSetup(
115
+ requestedAgents: readonly AgentId[],
116
+ options: InstructionPlanOptions = {},
117
+ ): InstructionPlan {
118
+ const home = options.home ?? process.env.HOME ?? "";
119
+ const resolvedOptions = {
120
+ home,
121
+ codexHome: options.codexHome ?? process.env.CODEX_HOME ?? join(home, ".codex"),
122
+ claudeConfigDir: options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"),
123
+ };
124
+ return planInstructionSetupWithResolver(
125
+ requestedAgents,
126
+ (agent) => instructionPath(agent, resolvedOptions),
127
+ options.readFile ?? readInstructionFile,
128
+ );
129
+ }
130
+
131
+ function projectInstructionPath(agent: AgentId, projectRoot: string): string | undefined {
132
+ return getAgentDefinition(agent).instructions?.project?.(projectRoot);
133
+ }
134
+
135
+ export function planProjectInstructionSetup(
136
+ requestedAgents: readonly AgentId[],
137
+ projectRoot: string,
138
+ options: Pick<InstructionPlanOptions, "readFile"> = {},
139
+ ): InstructionPlan {
140
+ return planInstructionSetupWithResolver(
141
+ requestedAgents,
142
+ (agent) => projectInstructionPath(agent, projectRoot),
143
+ options.readFile ?? readInstructionFile,
144
+ );
145
+ }
146
+
128
147
  function atomicWrite(path: string, content: string, mode: number): void {
129
148
  mkdirSync(dirname(path), { recursive: true });
130
149
  const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
@@ -0,0 +1,89 @@
1
+ import {
2
+ getAgentDefinition,
3
+ SUPPORTED_AGENT_IDS,
4
+ type AgentId,
5
+ type McpRegistrationScope,
6
+ } from "./init-agents";
7
+
8
+ export type { McpRegistrationScope };
9
+
10
+ export const MCP_REGISTRABLE_AGENTS = SUPPORTED_AGENT_IDS.filter(
11
+ (agent) => getAgentDefinition(agent).mcpRegistration !== undefined,
12
+ );
13
+
14
+ export const MCP_PROJECT_REGISTRABLE_AGENTS = MCP_REGISTRABLE_AGENTS.filter(
15
+ (agent) => getAgentDefinition(agent).mcpRegistration!.buildArgs("project") !== undefined,
16
+ );
17
+
18
+ export function isMcpRegistrable(agent: AgentId): boolean {
19
+ return getAgentDefinition(agent).mcpRegistration !== undefined;
20
+ }
21
+
22
+ export interface McpRegistrationResult {
23
+ agent: AgentId;
24
+ ok: boolean;
25
+ error?: string;
26
+ }
27
+
28
+ type SpawnFn = (
29
+ cmd: string[],
30
+ opts?: { cwd?: string },
31
+ ) => {
32
+ exited: Promise<number>;
33
+ stderr: ReadableStream<Uint8Array> | number;
34
+ };
35
+
36
+ /**
37
+ * Runs the agent's own CLI to register skillmux as an MCP server. Never
38
+ * throws — registration failure (tool not installed, command rejected,
39
+ * etc.) is reported in the result, not fatal to the caller's larger flow.
40
+ */
41
+ export async function registerMcpServer(
42
+ agent: AgentId,
43
+ options: {
44
+ spawn?: SpawnFn;
45
+ scope?: McpRegistrationScope;
46
+ cwd?: string;
47
+ } = {},
48
+ ): Promise<McpRegistrationResult> {
49
+ const entry = getAgentDefinition(agent).mcpRegistration;
50
+ const scope = options.scope ?? "user";
51
+ const args = entry?.buildArgs(scope);
52
+ if (!entry || !args) {
53
+ return {
54
+ agent,
55
+ ok: false,
56
+ error: `no ${scope === "project" ? "project-scoped " : ""}MCP registration command known for agent "${agent}"`,
57
+ };
58
+ }
59
+ const spawn: SpawnFn =
60
+ options.spawn ??
61
+ ((cmd, opts) => Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", cwd: opts?.cwd }));
62
+ try {
63
+ const proc = spawn([entry.command, ...args], { cwd: options.cwd });
64
+ const stderrText =
65
+ typeof proc.stderr === "number"
66
+ ? ""
67
+ : await new Response(proc.stderr).text();
68
+ const exitCode = await proc.exited;
69
+ if (exitCode !== 0) {
70
+ return {
71
+ agent,
72
+ ok: false,
73
+ error:
74
+ stderrText.trim() || `${entry.command} exited with code ${exitCode}`,
75
+ };
76
+ }
77
+ return { agent, ok: true };
78
+ } catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ const notFound = /ENOENT|not found|no such file/i.test(message);
81
+ return {
82
+ agent,
83
+ ok: false,
84
+ error: notFound
85
+ ? `"${entry.command}" is not installed or not on PATH`
86
+ : message,
87
+ };
88
+ }
89
+ }
package/src/output.ts CHANGED
@@ -3,6 +3,8 @@ import type { ResolvedContext } from "./context";
3
3
  export interface JsonEnvelope<T = any> {
4
4
  schema_version: 1;
5
5
  ok: boolean;
6
+ context: string | { name: string; server: string };
7
+ /** @deprecated Slated for removal in the next major version. Use `context` instead. */
6
8
  target: string | { name: string; server: string };
7
9
  data: T | null;
8
10
  error: { code: string; message: string; details?: any } | null;
@@ -10,27 +12,34 @@ export interface JsonEnvelope<T = any> {
10
12
 
11
13
  export function formatJsonEnvelope<T>(opts: {
12
14
  ok: boolean;
13
- target: ResolvedContext | string | { name: string; server: string };
15
+ /** @deprecated Slated for removal in the next major version. Use `context` instead. */
16
+ target?: ResolvedContext | string | { name: string; server: string };
17
+ context?: ResolvedContext | string | { name: string; server: string };
14
18
  data?: T;
15
19
  error?: { code: string; message: string; details?: any } | null;
16
20
  }): JsonEnvelope<T> {
17
- let targetVal: string | { name: string; server: string };
18
- if (typeof opts.target === "string" || (typeof opts.target === "object" && "server" in opts.target && !("type" in opts.target))) {
19
- targetVal = opts.target as any;
20
- } else if (typeof opts.target === "object" && "type" in opts.target) {
21
- if (opts.target.type === "local") {
22
- targetVal = "local";
21
+ const input: ResolvedContext | string | { name: string; server: string } =
22
+ opts.context ?? opts.target ?? "local";
23
+ let contextVal: string | { name: string; server: string };
24
+ if (typeof input === "string") {
25
+ contextVal = input;
26
+ } else if (typeof input === "object" && input !== null) {
27
+ if ("type" in input && (input as any).type === "local") {
28
+ contextVal = "local";
29
+ } else if ("name" in input && "server" in input) {
30
+ contextVal = { name: input.name, server: input.server };
23
31
  } else {
24
- targetVal = { name: opts.target.name, server: opts.target.server };
32
+ contextVal = "local";
25
33
  }
26
34
  } else {
27
- targetVal = "local";
35
+ contextVal = "local";
28
36
  }
29
37
 
30
38
  return {
31
39
  schema_version: 1,
32
40
  ok: opts.ok,
33
- target: targetVal,
41
+ context: contextVal,
42
+ target: contextVal,
34
43
  data: opts.data ?? null,
35
44
  error: opts.error ?? null,
36
45
  };
@@ -51,12 +60,18 @@ export class CliError extends Error {
51
60
  }
52
61
 
53
62
  export function emitSuccess<T>(
54
- ctx: { isJson: boolean; target?: ResolvedContext | string | { name: string; server: string } },
63
+ ctx: {
64
+ isJson: boolean;
65
+ /** @deprecated Slated for removal in the next major version. Use `context` instead. */
66
+ target?: ResolvedContext | string | { name: string; server: string };
67
+ context?: ResolvedContext | string | { name: string; server: string };
68
+ },
55
69
  data: T,
56
70
  renderText: () => void,
57
71
  ): void {
58
72
  if (ctx.isJson) {
59
- console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target ?? "local", data })));
73
+ const contextVal = ctx.context ?? ctx.target ?? "local";
74
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, context: contextVal, target: contextVal, data })));
60
75
  } else {
61
76
  renderText();
62
77
  }
@@ -112,6 +127,28 @@ export function suggestCorrection(input: string, candidates: string[]): string |
112
127
  return bestMatch;
113
128
  }
114
129
 
130
+ /**
131
+ * Builds the error for an unrecognized subcommand: "did you mean X" when
132
+ * close to a valid one, otherwise the full <a|b|c> usage list — never a
133
+ * fixed, possibly-unrelated usage string for just one of several valid
134
+ * subcommands (that's what `config`'s fallback used to do before this
135
+ * existed: any invalid subcommand got told "usage: skillmux config show",
136
+ * silently omitting get/set/validate/diff/status/init).
137
+ */
138
+ export function unknownSubcommandError(
139
+ command: string,
140
+ subCommand: string,
141
+ validSubcommands: string[],
142
+ ): Error {
143
+ const suggestion = subCommand ? suggestCorrection(subCommand, validSubcommands) : null;
144
+ if (suggestion) {
145
+ return new Error(
146
+ `Unknown "${command} ${subCommand}" subcommand. Did you mean "${command} ${suggestion}"?`,
147
+ );
148
+ }
149
+ return new Error(`usage: skillmux ${command} <${validSubcommands.join("|")}>`);
150
+ }
151
+
115
152
  export function isInteractive(
116
153
  env: NodeJS.ProcessEnv = process.env,
117
154
  stdoutIsTTY = process.stdout.isTTY,
@@ -144,12 +181,12 @@ export function warn(line: string): void {
144
181
  console.error(yellow(`warning: ${line}`));
145
182
  }
146
183
 
147
- export function renderTargetBanner(target: ResolvedContext): void {
184
+ export function renderContextBanner(context: ResolvedContext): void {
148
185
  if (!isInteractive()) return;
149
- if (target.type === "local") {
150
- console.log(`Target: local`);
186
+ if (context.type === "local") {
187
+ console.log(`Context: local`);
151
188
  } else {
152
- console.log(`Target: remote (${target.name} -> ${target.server})`);
189
+ console.log(`Context: remote (${context.name} -> ${context.server})`);
153
190
  }
154
191
  }
155
192