@klhapp/skillmux 1.10.0 → 1.11.1

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.
@@ -1,7 +1,11 @@
1
1
  import { existsSync, lstatSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { expandHome } from "../config";
4
- import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
4
+ import { planAgentSurfaces, SUPPORTED_AGENT_IDS, type AgentId } from "../init-agents";
5
+ import {
6
+ applyInstructionPlan,
7
+ planProjectInstructionSetup,
8
+ } from "../init-instructions";
5
9
  import {
6
10
  parseManifest,
7
11
  pinProject,
@@ -12,6 +16,11 @@ import {
12
16
  validateManifest,
13
17
  writeManifestAtomic,
14
18
  } from "../manifest";
19
+ import {
20
+ MCP_PROJECT_REGISTRABLE_AGENTS,
21
+ registerMcpServer,
22
+ type McpRegistrationResult,
23
+ } from "../mcp-registration";
15
24
  import { resolveProjectDirectory, suggestProjectName } from "../project-setup";
16
25
  import {
17
26
  parseCommaList,
@@ -19,18 +28,19 @@ import {
19
28
  promptText,
20
29
  shouldUseWizard,
21
30
  } from "../prompts";
22
- import { emitSuccess, isInteractive } from "../output";
31
+ import { emitSuccess, isInteractive, unknownSubcommandError } from "../output";
23
32
  import { confirmAction, confirmIfNeeded, loadManifestContext } from "./shared";
24
33
  import { isGlobalFlag } from "../global-flags";
25
34
  const PROJECT_INIT_USAGE =
26
- "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
35
+ "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--agent <id>...] [--target <name>...] [--register-mcp] [--yes] [--no-sync]";
27
36
 
28
37
  interface ProjectInitArgs {
29
38
  path: string;
30
39
  name: string;
31
40
  skills: string[];
32
- clients: string[];
41
+ agents: string[];
33
42
  targets: string[];
43
+ registerMcp: boolean;
34
44
  yes: boolean;
35
45
  sync: boolean;
36
46
  }
@@ -45,16 +55,32 @@ export function configuredTargetForSurface(
45
55
  )?.[0];
46
56
  }
47
57
 
48
- function configuredTargetsForClients(
58
+ function configuredTargetsForAgents(
49
59
  manifest: ReturnType<typeof parseManifest>,
50
- clients: readonly string[],
60
+ agents: readonly string[],
51
61
  ): string[] {
52
- return planClientSurfaces(clients).surfaces.map((surface) => {
62
+ const plan = planAgentSurfaces(agents);
63
+
64
+ // planAgentSurfaces silently drops any agent with no surfaceId, which is how
65
+ // full-vault agents (goose, hermes) are modelled: they get the whole vault
66
+ // rather than a sync target directory. Dropping one here used to make
67
+ // "project attach --agent goose" a successful no-op, so refuse instead.
68
+ const covered = new Set<string>(plan.surfaces.flatMap((surface) => surface.agents));
69
+ for (const agent of agents) {
70
+ if (!covered.has(agent)) {
71
+ throw new Error(
72
+ `agent "${agent}" uses full-vault delivery and maps to no sync target; ` +
73
+ `name a target directly with --target instead`,
74
+ );
75
+ }
76
+ }
77
+
78
+ return plan.surfaces.map((surface) => {
53
79
  const target = configuredTargetForSurface(manifest, surface);
54
80
  if (target) return target;
55
- const client = surface.clients[0]!;
81
+ const agent = surface.agents[0]!;
56
82
  throw new Error(
57
- `client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
83
+ `agent target for "${agent}" is not configured; run "skillmux init --agent ${agent} --yes" first`,
58
84
  );
59
85
  });
60
86
  }
@@ -63,8 +89,9 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
63
89
  let projectPath: string | undefined;
64
90
  let name: string | undefined;
65
91
  const skills: string[] = [];
66
- const clients: string[] = [];
92
+ const agents: string[] = [];
67
93
  const targets: string[] = [];
94
+ let registerMcp = false;
68
95
  let yes = false;
69
96
  let sync = true;
70
97
 
@@ -81,10 +108,12 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
81
108
  const target = args[++i];
82
109
  if (!target) throw new Error("--target requires a name");
83
110
  targets.push(target);
84
- } else if (arg === "--client") {
85
- const client = args[++i];
86
- if (!client) throw new Error("--client requires a name");
87
- clients.push(client);
111
+ } else if (arg === "--agent") {
112
+ const agent = args[++i];
113
+ if (!agent) throw new Error("--agent requires a name");
114
+ agents.push(agent);
115
+ } else if (arg === "--register-mcp") {
116
+ registerMcp = true;
88
117
  } else if (arg === "--yes") {
89
118
  yes = true;
90
119
  } else if (arg === "--no-sync") {
@@ -110,8 +139,9 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
110
139
  path,
111
140
  name: name ?? suggestProjectName(basename(path)),
112
141
  skills,
113
- clients,
142
+ agents,
114
143
  targets,
144
+ registerMcp,
115
145
  yes,
116
146
  sync,
117
147
  };
@@ -263,15 +293,15 @@ export async function runProject(
263
293
  const group = args[0];
264
294
  if (!group)
265
295
  throw new Error(
266
- `usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
296
+ `usage: skillmux project ${subCommand} <group> (--agent <id>... | --target <name>...) --yes`,
267
297
  );
268
- const clients: string[] = [];
298
+ const agents: string[] = [];
269
299
  const requestedTargets: string[] = [];
270
300
  for (let i = 1; i < args.length; i++) {
271
- if (args[i] === "--client") {
301
+ if (args[i] === "--agent") {
272
302
  const value = args[++i];
273
- if (!value) throw new Error("--client requires a name");
274
- clients.push(value);
303
+ if (!value) throw new Error("--agent requires a name");
304
+ agents.push(value);
275
305
  } else if (args[i] === "--target") {
276
306
  const value = args[++i];
277
307
  if (!value) throw new Error("--target requires a name");
@@ -286,11 +316,20 @@ export async function runProject(
286
316
  }
287
317
  const { config, vaultPath, manifestPath, manifest } =
288
318
  await loadManifestContext();
289
- const clientTargets = configuredTargetsForClients(manifest, clients);
290
- const targets = [...new Set([...requestedTargets, ...clientTargets])];
319
+ const agentTargets = configuredTargetsForAgents(manifest, agents);
320
+ const targets = [...new Set([...requestedTargets, ...agentTargets])];
291
321
  if (targets.length === 0) {
292
- throw new Error(`project ${subCommand} requires --client or --target`);
322
+ throw new Error(`project ${subCommand} requires --agent or --target`);
293
323
  }
324
+ // Several agents can share one target (e.g. opencode/windsurf both use
325
+ // agent-skills) — show the resolved directory, not just the target name,
326
+ // so it's clear at confirmation time which physical folder this affects.
327
+ const targetDirs = Object.fromEntries(
328
+ targets.map((t) => [t, manifest.targets[t]?.dir ?? "(unknown)"]),
329
+ );
330
+ const targetsDisplay = targets
331
+ .map((t) => `${t} (${targetDirs[t]})`)
332
+ .join(", ");
294
333
  const updated = updateProjectTargets(manifest, group, {
295
334
  ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
296
335
  });
@@ -302,10 +341,10 @@ export async function runProject(
302
341
  if (options.dryRun) {
303
342
  emitSuccess(
304
343
  { isJson: options.isJson },
305
- { subcommand: subCommand, group, targets },
344
+ { subcommand: subCommand, group, targets, target_dirs: targetDirs },
306
345
  () =>
307
346
  console.log(
308
- `${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
347
+ `${subCommand}: [project.${group}] ${targetsDisplay} (dry-run)`,
309
348
  ),
310
349
  );
311
350
  return;
@@ -314,7 +353,7 @@ export async function runProject(
314
353
  !(await confirmIfNeeded({
315
354
  confirmed: args.includes("--yes"),
316
355
  isJson: options.isJson,
317
- prompt: `${subCommand} [project.${group}] to ${targets.join(", ")}?`,
356
+ prompt: `${subCommand} [project.${group}] to ${targetsDisplay}?`,
318
357
  nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
319
358
  }))
320
359
  )
@@ -322,12 +361,23 @@ export async function runProject(
322
361
  writeManifestAtomic(manifestPath, updated);
323
362
  emitSuccess(
324
363
  { isJson: options.isJson },
325
- { subcommand: subCommand, group, targets },
326
- () => console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`),
364
+ { subcommand: subCommand, group, targets, target_dirs: targetDirs },
365
+ () => console.log(`${subCommand}: [project.${group}] ${targetsDisplay}`),
327
366
  );
328
367
  return;
329
368
  }
330
- if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
369
+ if (subCommand !== "init")
370
+ throw unknownSubcommandError("project", subCommand, [
371
+ "init",
372
+ "list",
373
+ "show",
374
+ "add-path",
375
+ "remove-path",
376
+ "pin",
377
+ "unpin",
378
+ "attach",
379
+ "detach",
380
+ ]);
331
381
  let request = parseProjectInitArgs(args);
332
382
  const guided = shouldUseWizard(args, {
333
383
  interactive: isInteractive(),
@@ -345,20 +395,20 @@ export async function runProject(
345
395
  const localVaultPaths = config.local_vault_paths.map(expandHome);
346
396
  if (guided) {
347
397
  const name = await promptText("Project group", request.name);
348
- const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
349
- const surface = planClientSurfaces([client]).surfaces[0];
398
+ const availableAgents = SUPPORTED_AGENT_IDS.filter((agent) => {
399
+ const surface = planAgentSurfaces([agent]).surfaces[0];
350
400
  return (
351
401
  surface !== undefined &&
352
402
  configuredTargetForSurface(manifest, surface) !== undefined
353
403
  );
354
404
  });
355
- const clients = await promptMultiSelect(
356
- "Which clients should receive project skills?",
357
- availableClients.map((client) => ({
358
- value: client,
359
- label: client,
405
+ const agents = await promptMultiSelect(
406
+ "Which agents should receive project skills?",
407
+ availableAgents.map((agent) => ({
408
+ value: agent,
409
+ label: agent,
360
410
  selected:
361
- request.clients.length === 0 || request.clients.includes(client),
411
+ request.agents.length === 0 || request.agents.includes(agent),
362
412
  })),
363
413
  );
364
414
  const skills = parseCommaList(
@@ -367,10 +417,25 @@ export async function runProject(
367
417
  request.skills.join(","),
368
418
  ),
369
419
  );
370
- request = { ...request, name, clients, skills };
420
+ request = { ...request, name, agents, skills };
421
+ }
422
+ // Local MCP registration + instruction writing are independent of skill
423
+ // pins — only offered for agents with a verified project-scoped CLI
424
+ // command (currently just claude-code; see MCP_PROJECT_REGISTRABLE_AGENTS).
425
+ const registrableAgents = request.agents.filter((agent) =>
426
+ MCP_PROJECT_REGISTRABLE_AGENTS.includes(agent as AgentId),
427
+ ) as AgentId[];
428
+ if (guided && registrableAgents.length > 0) {
429
+ request = {
430
+ ...request,
431
+ registerMcp: await confirmAction(
432
+ `Also register skillmux as a project-scoped MCP server for ${registrableAgents.join(", ")}? ` +
433
+ `This writes ${request.path}/.mcp.json, shared via git.`,
434
+ ),
435
+ };
371
436
  }
372
- const clientTargets = configuredTargetsForClients(manifest, request.clients);
373
- const targets = [...new Set([...request.targets, ...clientTargets])];
437
+ const agentTargets = configuredTargetsForAgents(manifest, request.agents);
438
+ const targets = [...new Set([...request.targets, ...agentTargets])];
374
439
  const updated = upsertProject(manifest, {
375
440
  name: request.name,
376
441
  paths: [request.path],
@@ -378,15 +443,34 @@ export async function runProject(
378
443
  targets,
379
444
  });
380
445
  const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
446
+
447
+ // The project-local instruction block only teaches an agent to call
448
+ // resolve_skill/fetch_skill (MCP tools) — write it only for agents that
449
+ // are actually getting a project-scoped MCP registration this run.
450
+ const mcpInstructionAgents = request.registerMcp ? registrableAgents : [];
451
+ const instructionPlan = planProjectInstructionSetup(
452
+ mcpInstructionAgents,
453
+ request.path,
454
+ );
455
+ const hasInstructionWrites = instructionPlan.changes.some(
456
+ (change) => change.status !== "unchanged",
457
+ );
458
+
381
459
  const plan = {
382
460
  mode: "project",
383
461
  project: request.name,
384
462
  path: request.path,
385
463
  skills: request.skills,
386
- clients: request.clients,
464
+ agents: request.agents,
387
465
  targets,
388
466
  sync: request.sync,
389
467
  notes,
468
+ instructions: instructionPlan.changes.map(({ path, agents, status }) => ({
469
+ path,
470
+ agents,
471
+ status,
472
+ })),
473
+ register_mcp_for: mcpInstructionAgents,
390
474
  };
391
475
 
392
476
  if (options.dryRun) {
@@ -401,8 +485,14 @@ export async function runProject(
401
485
  console.log("\nReview");
402
486
  console.log(` project: ${request.name}`);
403
487
  console.log(` path: ${request.path}`);
404
- console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
488
+ console.log(` agents: ${request.agents.join(", ") || "(none)"}`);
405
489
  console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
490
+ console.log(
491
+ ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
492
+ );
493
+ console.log(
494
+ ` MCP registration: ${mcpInstructionAgents.join(", ") || "(none)"}`,
495
+ );
406
496
  console.log(` sync: ${request.sync ? "yes" : "no"}`);
407
497
  }
408
498
  if (
@@ -421,6 +511,17 @@ export async function runProject(
421
511
  }
422
512
 
423
513
  writeManifestAtomic(manifestPath, updated);
514
+ if (hasInstructionWrites) {
515
+ try {
516
+ applyInstructionPlan(instructionPlan);
517
+ } catch (error) {
518
+ throw new Error(
519
+ `project configuration was saved, but writing instruction files failed: ${
520
+ error instanceof Error ? error.message : String(error)
521
+ }`,
522
+ );
523
+ }
524
+ }
424
525
  if (request.sync) {
425
526
  try {
426
527
  // Reaching here already required approval above (request.yes, or an
@@ -437,7 +538,39 @@ export async function runProject(
437
538
  );
438
539
  }
439
540
  }
440
- emitSuccess({ isJson: options.isJson }, { result: plan }, () =>
441
- console.log(`project "${request.name}" ready at ${request.path}`),
541
+
542
+ // Best-effort and outside the checks above: this mutates another tool's
543
+ // own config, not skillmux's, so a registration failure is reported, never
544
+ // rolled back — the successful project setup above still stands either way.
545
+ const mcpRegistrations: McpRegistrationResult[] = [];
546
+ if (request.registerMcp) {
547
+ for (const agent of registrableAgents) {
548
+ mcpRegistrations.push(
549
+ await registerMcpServer(agent, { scope: "project", cwd: request.path }),
550
+ );
551
+ }
552
+ }
553
+
554
+ emitSuccess(
555
+ { isJson: options.isJson },
556
+ {
557
+ result: {
558
+ ...plan,
559
+ instructions_changed: instructionPlan.changes
560
+ .filter((change) => change.status !== "unchanged")
561
+ .map((change) => change.path),
562
+ mcp_registrations: mcpRegistrations,
563
+ },
564
+ },
565
+ () => {
566
+ console.log(`project "${request.name}" ready at ${request.path}`);
567
+ for (const registration of mcpRegistrations) {
568
+ console.log(
569
+ registration.ok
570
+ ? `MCP registered: ${registration.agent} (project scope)`
571
+ : `MCP registration failed for ${registration.agent}: ${registration.error}`,
572
+ );
573
+ }
574
+ },
442
575
  );
443
576
  }
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
- import type { TargetAdapter } from "../adapters";
2
+ import type { ContextAdapter } from "../adapters";
3
3
  import type { ResolvedContext } from "../context";
4
4
  import { emitSuccess } from "../output";
5
5
  import { getStats, renderStatsText } from "../stats";
@@ -36,14 +36,14 @@ function parseReportArgs(args: string[]): {
36
36
 
37
37
  export async function runReport(
38
38
  args: string[],
39
- options: { isJson: boolean; target: ResolvedContext; allowInsecure: boolean; adapter: TargetAdapter },
39
+ options: { isJson: boolean; context: ResolvedContext; allowInsecure: boolean; adapter: ContextAdapter },
40
40
  ): Promise<void> {
41
41
  const { db: dbPath, since } = parseReportArgs(args);
42
42
  if (!since)
43
43
  throw new Error(
44
44
  "usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
45
45
  );
46
- if (dbPath && options.target.type === "remote")
46
+ if (dbPath && options.context.type === "remote")
47
47
  throw new Error("--db and --context/--server are mutually exclusive");
48
48
 
49
49
  if (dbPath) {
@@ -1,6 +1,7 @@
1
1
  import { expandHome, loadConfig } from "../config";
2
- import { emitSuccess } from "../output";
2
+ import { emitSuccess, warn } from "../output";
3
3
  import {
4
+ parseFailOn,
4
5
  renderScanJson,
5
6
  renderScanText,
6
7
  scanExitCode,
@@ -12,10 +13,12 @@ import { isGlobalFlag } from "../global-flags";
12
13
  function parseScanArgs(args: string[]): {
13
14
  path?: string;
14
15
  format: "text" | "json";
16
+ formatExplicit: boolean;
15
17
  failOn?: ScanSeverity;
16
18
  } {
17
19
  let path: string | undefined;
18
20
  let format: "text" | "json" = "text";
21
+ let formatExplicit = false;
19
22
  let failOn: ScanSeverity | undefined;
20
23
  for (let i = 0; i < args.length; i++) {
21
24
  const option = args[i];
@@ -24,12 +27,13 @@ function parseScanArgs(args: string[]): {
24
27
  if (value !== "text" && value !== "json")
25
28
  throw new Error("--format must be text or json");
26
29
  format = value;
30
+ formatExplicit = true;
27
31
  } else if (option === "--fail-on") {
28
- const value = args[++i];
29
- if (value !== "low" && value !== "medium" && value !== "high") {
30
- throw new Error("--fail-on must be low, medium, or high");
31
- }
32
- failOn = value;
32
+ // scan accepts "none" for symmetry with install/update, where it is the
33
+ // opt-out. Here it is already the default: scan reports, and the caller
34
+ // opts into a non-zero exit code.
35
+ const parsed = parseFailOn(args[++i]);
36
+ failOn = parsed === "none" ? undefined : parsed;
33
37
  } else if (isGlobalFlag(option, "--json")) {
34
38
  // handled globally by main()'s isJson flag; recognized here so it isn't rejected
35
39
  } else if (option?.startsWith("--")) {
@@ -40,14 +44,20 @@ function parseScanArgs(args: string[]): {
40
44
  path = option;
41
45
  }
42
46
  }
43
- return { path, format, failOn };
47
+ return { path, format, formatExplicit, failOn };
44
48
  }
45
49
 
46
50
  export async function runScan(
47
51
  args: string[],
48
52
  options: { isJson: boolean },
49
53
  ): Promise<void> {
50
- const { path, format, failOn } = parseScanArgs(args);
54
+ const { path, format, formatExplicit, failOn } = parseScanArgs(args);
55
+ if (formatExplicit) {
56
+ // --format predates the shared --json envelope and is the last command
57
+ // flag that emits JSON outside it. Kept working for existing callers;
58
+ // the warning goes to stderr so stdout stays machine-parseable.
59
+ warn("--format is deprecated and will be removed in a future 1.x release; use --json instead");
60
+ }
51
61
  const rootPath = path
52
62
  ? expandHome(path)
53
63
  : expandHome((await loadConfig()).vault_path);
@@ -1,21 +1,14 @@
1
- import { createInterface } from "node:readline/promises";
2
1
  import { expandHome, loadConfig } from "../config";
3
2
  import { parseManifest, resolveManifestPath } from "../manifest";
4
3
  import { isInteractive } from "../output";
4
+ import { askQuestion, type PromptIO } from "../prompts";
5
5
 
6
- export async function confirmAction(prompt: string): Promise<boolean> {
7
- const readline = createInterface({
8
- input: process.stdin,
9
- output: process.stdout,
10
- });
11
- try {
12
- const answer = (await readline.question(`${prompt} [y/N] `))
13
- .trim()
14
- .toLowerCase();
15
- return answer === "y" || answer === "yes";
16
- } finally {
17
- readline.close();
18
- }
6
+ export async function confirmAction(
7
+ prompt: string,
8
+ io: PromptIO = {},
9
+ ): Promise<boolean> {
10
+ const answer = (await askQuestion(`${prompt} [y/N] `, io)).trim().toLowerCase();
11
+ return answer === "y" || answer === "yes";
19
12
  }
20
13
 
21
14
  export async function loadManifestContext() {
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { expandHome, loadConfig } from "../config";
4
+ import { unknownSubcommandError } from "../output";
4
5
  import { vaultResolutionOrder } from "../vault";
5
6
 
6
7
  export async function runSkill(subCommand: string, args: string[]): Promise<void> {
7
- if (subCommand !== "which") throw new Error("usage: skillmux skill <which>");
8
+ if (subCommand !== "which") throw unknownSubcommandError("skill", subCommand, ["which"]);
8
9
  await runWhich(args);
9
10
  }
10
11
 
@@ -1,9 +1,15 @@
1
1
  import { expandHome } from "../config";
2
- import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
2
+ import {
3
+ BUILT_IN_TARGET_NAMES,
4
+ planAgentSurfaces,
5
+ resolveBuiltInTarget,
6
+ SUPPORTED_AGENT_IDS,
7
+ } from "../init-agents";
3
8
  import { planInitManifest, applyInit } from "../init";
4
9
  import { writeManifestAtomic } from "../manifest";
5
- import { emitSuccess } from "../output";
10
+ import { emitSuccess, unknownSubcommandError } from "../output";
6
11
  import { confirmIfNeeded, loadManifestContext } from "./shared";
12
+
7
13
  export async function runTarget(
8
14
  subCommand: string,
9
15
  args: string[],
@@ -19,11 +25,11 @@ export async function runTarget(
19
25
  }
20
26
  const targets = names.map((name) => {
21
27
  const target = manifest.targets[name]!;
22
- const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
23
- const surface = planClientSurfaces([client]).surfaces[0];
28
+ const agents = SUPPORTED_AGENT_IDS.filter((agent) => {
29
+ const surface = planAgentSurfaces([agent]).surfaces[0];
24
30
  return surface !== undefined && surface.path === expandHome(target.dir);
25
31
  });
26
- return { name, ...target, clients };
32
+ return { name, ...target, agents };
27
33
  });
28
34
  emitSuccess({ isJson: options.isJson }, { targets }, () => {
29
35
  if (targets.length === 0) {
@@ -33,7 +39,7 @@ export async function runTarget(
33
39
  console.log(`${target.name}:`);
34
40
  console.log(` dir: ${target.dir}`);
35
41
  console.log(` host: ${target.host ?? "(global)"}`);
36
- console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
42
+ console.log(` agents: ${target.agents.join(", ") || "(custom)"}`);
37
43
  console.log(
38
44
  ` projects: ${target.project_groups.join(", ") || "(none)"}`,
39
45
  );
@@ -47,9 +53,21 @@ export async function runTarget(
47
53
  const name = args[0];
48
54
  const dirIndex = args.indexOf("--dir");
49
55
  const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
50
- if (!name || !rawPath)
56
+ if (!name)
51
57
  throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
52
- const path = expandHome(rawPath);
58
+
59
+ let path: string;
60
+ if (rawPath) {
61
+ path = expandHome(rawPath);
62
+ } else if (BUILT_IN_TARGET_NAMES.has(name)) {
63
+ path = resolveBuiltInTarget(name, {
64
+ codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
65
+ }).path;
66
+ } else {
67
+ throw new Error(
68
+ "usage: skillmux target add <name> --dir <dir> --yes (--dir may be omitted for built-in target names: agent-skills, claude-code, codex)",
69
+ );
70
+ }
53
71
  if (options.dryRun) {
54
72
  const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
55
73
  emitSuccess(
@@ -118,5 +136,5 @@ export async function runTarget(
118
136
  return;
119
137
  }
120
138
 
121
- throw new Error("usage: skillmux target <list|show|add|remove>");
139
+ throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove"]);
122
140
  }
@@ -14,7 +14,15 @@ import {
14
14
  import { emitSuccess } from "../output";
15
15
  import { hashSkillContent, readSkillOrigin, writeSkillOrigin } from "../provenance";
16
16
  import type { SkillOrigin } from "../provenance";
17
- import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
17
+ import {
18
+ FAIL_ON_USAGE,
19
+ parseFailOn,
20
+ resolveMutatingFailOn,
21
+ scanExitCode,
22
+ type FailOnOption,
23
+ type ScanFinding,
24
+ type ScanSeverity,
25
+ } from "../scan";
18
26
  import { SKILL_ID_PATTERN } from "../vault";
19
27
  import { confirmIfNeeded } from "./shared";
20
28
  import { checkOutdated } from "./outdated";
@@ -175,14 +183,14 @@ function parseUpdateArgs(args: string[]): {
175
183
  yes: boolean;
176
184
  dryRun: boolean;
177
185
  force: boolean;
178
- failOn?: ScanSeverity;
186
+ failOn?: FailOnOption;
179
187
  allowLocalSource: boolean;
180
188
  } {
181
189
  let skillId: string | undefined;
182
190
  let yes = false;
183
191
  let dryRun = false;
184
192
  let force = false;
185
- let failOn: ScanSeverity | undefined;
193
+ let failOn: FailOnOption | undefined;
186
194
  let allowLocalSource = false;
187
195
  for (let i = 0; i < args.length; i++) {
188
196
  const arg = args[i];
@@ -191,11 +199,7 @@ function parseUpdateArgs(args: string[]): {
191
199
  else if (arg === "--force") force = true;
192
200
  else if (arg === "--allow-local-source") allowLocalSource = true;
193
201
  else if (arg === "--fail-on") {
194
- const value = args[++i];
195
- if (value !== "low" && value !== "medium" && value !== "high") {
196
- throw new Error("--fail-on must be low, medium, or high");
197
- }
198
- failOn = value;
202
+ failOn = parseFailOn(args[++i]);
199
203
  } else if (isGlobalFlag(arg, "--json")) {
200
204
  // handled globally
201
205
  } else if (arg?.startsWith("--")) {
@@ -215,7 +219,10 @@ export async function runUpdate(args: string[], options: { isJson: boolean }): P
215
219
  const vaultPath = expandHome(config.vault_path);
216
220
 
217
221
  const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource, config.egress?.allowed_hosts);
218
- const plan = await buildPlan(vaultPath, candidates, failOn, force, config.egress?.allowed_hosts);
222
+ // Same default as install: block on a high-severity finding unless the
223
+ // caller explicitly opts out with --fail-on none.
224
+ const effectiveFailOn = resolveMutatingFailOn(failOn);
225
+ const plan = await buildPlan(vaultPath, candidates, effectiveFailOn, force, config.egress?.allowed_hosts);
219
226
  try {
220
227
  const toWrite = plan.filter((item) => item.kind === "update");
221
228