@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
@@ -0,0 +1,132 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { expandHome, loadConfig } from "../config";
4
+ import {
5
+ assertHostAllowed,
6
+ cloneToTemp,
7
+ deriveRepoName,
8
+ installIntoVault,
9
+ isLocalFileUrl,
10
+ resolveCloneCommit,
11
+ resolveRepoSource,
12
+ resolveSkillDir,
13
+ validateSkillCandidate,
14
+ } from "../install";
15
+ import { emitSuccess } from "../output";
16
+ import { hashSkillContent, writeSkillOrigin } from "../provenance";
17
+ import { renderScanText, scanExitCode, type ScanSeverity } from "../scan";
18
+ import { isGlobalFlag } from "../global-flags";
19
+
20
+ function parseInstallArgs(args: string[]): {
21
+ repo?: string;
22
+ force: boolean;
23
+ dryRun: boolean;
24
+ failOn?: ScanSeverity;
25
+ allowLocalSource: boolean;
26
+ } {
27
+ let repo: string | undefined;
28
+ let force = false;
29
+ let dryRun = false;
30
+ let failOn: ScanSeverity | undefined;
31
+ let allowLocalSource = false;
32
+ for (let i = 0; i < args.length; i++) {
33
+ const option = args[i];
34
+ if (option === "--force") force = true;
35
+ else if (option === "--dry-run") dryRun = true;
36
+ else if (option === "--allow-local-source") allowLocalSource = true;
37
+ else if (option === "--fail-on") {
38
+ const value = args[++i];
39
+ if (value !== "low" && value !== "medium" && value !== "high") {
40
+ throw new Error("--fail-on must be low, medium, or high");
41
+ }
42
+ failOn = value;
43
+ } else if (isGlobalFlag(option, "--json", "--verbose")) {
44
+ // handled globally by main()'s isJson/isVerbose flags; recognized here so they aren't rejected
45
+ } else if (option?.startsWith("--")) {
46
+ throw new Error(`unknown install option: ${option}`);
47
+ } else if (repo !== undefined) {
48
+ throw new Error("skillmux install accepts at most one <repo> argument");
49
+ } else {
50
+ repo = option;
51
+ }
52
+ }
53
+ return { repo, force, dryRun, failOn, allowLocalSource };
54
+ }
55
+
56
+ export async function runInstall(
57
+ args: string[],
58
+ options: { isJson: boolean },
59
+ ): Promise<void> {
60
+ const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
61
+ if (!repo) {
62
+ throw new Error(
63
+ "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]",
64
+ );
65
+ }
66
+
67
+ const source = resolveRepoSource(repo);
68
+ if (!allowLocalSource && isLocalFileUrl(source.url)) {
69
+ throw new Error(
70
+ `"${repo}" is a local (file://) source — pass --allow-local-source to install from it`,
71
+ );
72
+ }
73
+ const config = await loadConfig();
74
+ assertHostAllowed(source.url, config.egress?.allowed_hosts);
75
+ const cloneDir = await cloneToTemp(source.url);
76
+ try {
77
+ const resolved = resolveSkillDir(
78
+ cloneDir,
79
+ deriveRepoName(source.url),
80
+ source.skillPath,
81
+ );
82
+ const { findings } = await validateSkillCandidate(
83
+ resolved.skillId,
84
+ resolved.dir,
85
+ );
86
+ if (!options.isJson) console.log(renderScanText({ scanned: 1, findings }));
87
+
88
+ if (scanExitCode(findings, failOn) !== 0) {
89
+ process.exitCode = 1;
90
+ console.error(
91
+ `aborting install: a finding met the --fail-on ${failOn} threshold`,
92
+ );
93
+ return;
94
+ }
95
+
96
+ const vaultPath = expandHome(config.vault_path);
97
+ if (dryRun) {
98
+ const plannedPath = join(vaultPath, resolved.skillId);
99
+ emitSuccess(
100
+ { isJson: options.isJson },
101
+ { skill_id: resolved.skillId, would_install_at: plannedPath },
102
+ () =>
103
+ console.log(
104
+ `dry-run: would install "${resolved.skillId}" into ${plannedPath}`,
105
+ ),
106
+ );
107
+ return;
108
+ }
109
+
110
+ const commit = resolveCloneCommit(cloneDir);
111
+ const targetDir = installIntoVault(
112
+ vaultPath,
113
+ resolved.skillId,
114
+ resolved.dir,
115
+ force,
116
+ );
117
+ writeSkillOrigin(targetDir, {
118
+ source_url: source.url,
119
+ skill_path: source.skillPath,
120
+ commit,
121
+ installed_at: new Date().toISOString(),
122
+ content_hash: hashSkillContent(targetDir),
123
+ });
124
+ emitSuccess(
125
+ { isJson: options.isJson },
126
+ { skill_id: resolved.skillId, installed_at: targetDir },
127
+ () => console.log(`installed "${resolved.skillId}" into ${targetDir}`),
128
+ );
129
+ } finally {
130
+ rmSync(cloneDir, { recursive: true, force: true });
131
+ }
132
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { expandHome, loadConfig } from "../config";
4
+ import { emitSuccess } from "../output";
5
+ import { writeLocalVaultMarker } from "../sync";
6
+ import { confirmIfNeeded } from "./shared";
7
+
8
+ export async function runLocalVaultInit(
9
+ args: string[],
10
+ options: { isJson: boolean; dryRun: boolean },
11
+ ): Promise<void> {
12
+ const path = args[0];
13
+ if (!path) throw new Error("usage: skillmux local-vault init <path> --yes");
14
+ const expanded = expandHome(path);
15
+ const config = await loadConfig();
16
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
17
+ if (!localVaultPaths.includes(expanded)) {
18
+ throw new Error(
19
+ `"${path}" is not one of the configured local_vault_paths — add it to config.toml first`,
20
+ );
21
+ }
22
+ if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
23
+ const markerPath = join(expanded, ".skillmux");
24
+ if (options.dryRun) {
25
+ emitSuccess(
26
+ { isJson: options.isJson },
27
+ {
28
+ marker_path: markerPath,
29
+ vault_path: expandHome(config.vault_path),
30
+ },
31
+ () =>
32
+ console.log(
33
+ `local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
34
+ ),
35
+ );
36
+ return;
37
+ }
38
+ if (
39
+ !(await confirmIfNeeded({
40
+ confirmed: args.includes("--yes"),
41
+ isJson: options.isJson,
42
+ prompt: `mark ${expanded} as a local_vault (role: local_vault, vault_path: ${expandHome(config.vault_path)})?`,
43
+ nonInteractiveError:
44
+ "skillmux local-vault init requires --yes when run non-interactively",
45
+ }))
46
+ )
47
+ return;
48
+ writeLocalVaultMarker(expanded, expandHome(config.vault_path));
49
+ emitSuccess(
50
+ { isJson: options.isJson },
51
+ {
52
+ marker_path: markerPath,
53
+ vault_path: expandHome(config.vault_path),
54
+ },
55
+ () =>
56
+ console.log(
57
+ `wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
58
+ ),
59
+ );
60
+ }
@@ -0,0 +1,10 @@
1
+ import { loadConfig } from "../config";
2
+ import { downloadLocalModels } from "../models";
3
+ import { emitSuccess } from "../output";
4
+
5
+ export async function runModelDownload(options: { isJson: boolean }): Promise<void> {
6
+ const cacheDir = await downloadLocalModels(await loadConfig());
7
+ emitSuccess({ isJson: options.isJson }, { cache_dir: cacheDir }, () =>
8
+ console.log(`models ready in ${cacheDir}`),
9
+ );
10
+ }
@@ -5,6 +5,7 @@ import { assertHostAllowed, isLocalFileUrl, remoteHeadCommit } from "../install"
5
5
  import { emitSuccess } from "../output";
6
6
  import { readSkillOrigin } from "../provenance";
7
7
  import { SKILL_ID_PATTERN } from "../vault";
8
+ import { isGlobalFlag } from "../global-flags";
8
9
 
9
10
  export interface OutdatedCheckResult {
10
11
  skill_id: string;
@@ -87,7 +88,7 @@ export async function checkOutdated(
87
88
  export async function runOutdated(args: string[], options: { isJson: boolean }): Promise<void> {
88
89
  let allowLocalSource = false;
89
90
  for (const arg of args) {
90
- if (arg === "--json") continue;
91
+ if (isGlobalFlag(arg, "--json")) continue;
91
92
  if (arg === "--allow-local-source") {
92
93
  allowLocalSource = true;
93
94
  continue;
@@ -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,17 +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";
33
+ import { isGlobalFlag } from "../global-flags";
24
34
  const PROJECT_INIT_USAGE =
25
- "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]";
26
36
 
27
37
  interface ProjectInitArgs {
28
38
  path: string;
29
39
  name: string;
30
40
  skills: string[];
31
- clients: string[];
41
+ agents: string[];
32
42
  targets: string[];
43
+ registerMcp: boolean;
33
44
  yes: boolean;
34
45
  sync: boolean;
35
46
  }
@@ -44,16 +55,16 @@ export function configuredTargetForSurface(
44
55
  )?.[0];
45
56
  }
46
57
 
47
- function configuredTargetsForClients(
58
+ function configuredTargetsForAgents(
48
59
  manifest: ReturnType<typeof parseManifest>,
49
- clients: readonly string[],
60
+ agents: readonly string[],
50
61
  ): string[] {
51
- return planClientSurfaces(clients).surfaces.map((surface) => {
62
+ return planAgentSurfaces(agents).surfaces.map((surface) => {
52
63
  const target = configuredTargetForSurface(manifest, surface);
53
64
  if (target) return target;
54
- const client = surface.clients[0]!;
65
+ const agent = surface.agents[0]!;
55
66
  throw new Error(
56
- `client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
67
+ `agent target for "${agent}" is not configured; run "skillmux init --agent ${agent} --yes" first`,
57
68
  );
58
69
  });
59
70
  }
@@ -62,8 +73,9 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
62
73
  let projectPath: string | undefined;
63
74
  let name: string | undefined;
64
75
  const skills: string[] = [];
65
- const clients: string[] = [];
76
+ const agents: string[] = [];
66
77
  const targets: string[] = [];
78
+ let registerMcp = false;
67
79
  let yes = false;
68
80
  let sync = true;
69
81
 
@@ -80,17 +92,18 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
80
92
  const target = args[++i];
81
93
  if (!target) throw new Error("--target requires a name");
82
94
  targets.push(target);
83
- } else if (arg === "--client") {
84
- const client = args[++i];
85
- if (!client) throw new Error("--client requires a name");
86
- clients.push(client);
95
+ } else if (arg === "--agent") {
96
+ const agent = args[++i];
97
+ if (!agent) throw new Error("--agent requires a name");
98
+ agents.push(agent);
99
+ } else if (arg === "--register-mcp") {
100
+ registerMcp = true;
87
101
  } else if (arg === "--yes") {
88
102
  yes = true;
89
103
  } else if (arg === "--no-sync") {
90
104
  sync = false;
91
105
  } else if (
92
- arg === "--dry-run" ||
93
- arg === "--json" ||
106
+ isGlobalFlag(arg, "--dry-run", "--json") ||
94
107
  arg === "--interactive"
95
108
  ) {
96
109
  continue;
@@ -110,8 +123,9 @@ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
110
123
  path,
111
124
  name: name ?? suggestProjectName(basename(path)),
112
125
  skills,
113
- clients,
126
+ agents,
114
127
  targets,
128
+ registerMcp,
115
129
  yes,
116
130
  sync,
117
131
  };
@@ -184,7 +198,11 @@ export async function runProject(
184
198
  config.local_vault_paths.map(expandHome),
185
199
  );
186
200
  if (options.dryRun) {
187
- console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
201
+ emitSuccess(
202
+ { isJson: options.isJson },
203
+ { subcommand: subCommand, group, path: projectPath },
204
+ () => console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`),
205
+ );
188
206
  return;
189
207
  }
190
208
  if (
@@ -197,7 +215,11 @@ export async function runProject(
197
215
  )
198
216
  return;
199
217
  writeManifestAtomic(manifestPath, updated);
200
- console.log(`${subCommand}: [project.${group}] ${projectPath}`);
218
+ emitSuccess(
219
+ { isJson: options.isJson },
220
+ { subcommand: subCommand, group, path: projectPath },
221
+ () => console.log(`${subCommand}: [project.${group}] ${projectPath}`),
222
+ );
201
223
  return;
202
224
  }
203
225
  if (subCommand === "pin" || subCommand === "unpin") {
@@ -224,8 +246,13 @@ export async function runProject(
224
246
  config.local_vault_paths.map(expandHome),
225
247
  );
226
248
  if (options.dryRun) {
227
- console.log(
228
- `${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
249
+ emitSuccess(
250
+ { isJson: options.isJson },
251
+ { subcommand: subCommand, group, skill_ids: skills },
252
+ () =>
253
+ console.log(
254
+ `${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
255
+ ),
229
256
  );
230
257
  return;
231
258
  }
@@ -239,22 +266,26 @@ export async function runProject(
239
266
  )
240
267
  return;
241
268
  writeManifestAtomic(manifestPath, updated);
242
- console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
269
+ emitSuccess(
270
+ { isJson: options.isJson },
271
+ { subcommand: subCommand, group, skill_ids: skills },
272
+ () => console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`),
273
+ );
243
274
  return;
244
275
  }
245
276
  if (subCommand === "attach" || subCommand === "detach") {
246
277
  const group = args[0];
247
278
  if (!group)
248
279
  throw new Error(
249
- `usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
280
+ `usage: skillmux project ${subCommand} <group> (--agent <id>... | --target <name>...) --yes`,
250
281
  );
251
- const clients: string[] = [];
282
+ const agents: string[] = [];
252
283
  const requestedTargets: string[] = [];
253
284
  for (let i = 1; i < args.length; i++) {
254
- if (args[i] === "--client") {
285
+ if (args[i] === "--agent") {
255
286
  const value = args[++i];
256
- if (!value) throw new Error("--client requires a name");
257
- clients.push(value);
287
+ if (!value) throw new Error("--agent requires a name");
288
+ agents.push(value);
258
289
  } else if (args[i] === "--target") {
259
290
  const value = args[++i];
260
291
  if (!value) throw new Error("--target requires a name");
@@ -269,11 +300,20 @@ export async function runProject(
269
300
  }
270
301
  const { config, vaultPath, manifestPath, manifest } =
271
302
  await loadManifestContext();
272
- const clientTargets = configuredTargetsForClients(manifest, clients);
273
- const targets = [...new Set([...requestedTargets, ...clientTargets])];
303
+ const agentTargets = configuredTargetsForAgents(manifest, agents);
304
+ const targets = [...new Set([...requestedTargets, ...agentTargets])];
274
305
  if (targets.length === 0) {
275
- throw new Error(`project ${subCommand} requires --client or --target`);
306
+ throw new Error(`project ${subCommand} requires --agent or --target`);
276
307
  }
308
+ // Several agents can share one target (e.g. opencode/windsurf both use
309
+ // agent-skills) — show the resolved directory, not just the target name,
310
+ // so it's clear at confirmation time which physical folder this affects.
311
+ const targetDirs = Object.fromEntries(
312
+ targets.map((t) => [t, manifest.targets[t]?.dir ?? "(unknown)"]),
313
+ );
314
+ const targetsDisplay = targets
315
+ .map((t) => `${t} (${targetDirs[t]})`)
316
+ .join(", ");
277
317
  const updated = updateProjectTargets(manifest, group, {
278
318
  ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
279
319
  });
@@ -283,8 +323,13 @@ export async function runProject(
283
323
  config.local_vault_paths.map(expandHome),
284
324
  );
285
325
  if (options.dryRun) {
286
- console.log(
287
- `${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
326
+ emitSuccess(
327
+ { isJson: options.isJson },
328
+ { subcommand: subCommand, group, targets, target_dirs: targetDirs },
329
+ () =>
330
+ console.log(
331
+ `${subCommand}: [project.${group}] ${targetsDisplay} (dry-run)`,
332
+ ),
288
333
  );
289
334
  return;
290
335
  }
@@ -292,16 +337,31 @@ export async function runProject(
292
337
  !(await confirmIfNeeded({
293
338
  confirmed: args.includes("--yes"),
294
339
  isJson: options.isJson,
295
- prompt: `${subCommand} [project.${group}] to ${targets.join(", ")}?`,
340
+ prompt: `${subCommand} [project.${group}] to ${targetsDisplay}?`,
296
341
  nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
297
342
  }))
298
343
  )
299
344
  return;
300
345
  writeManifestAtomic(manifestPath, updated);
301
- console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
346
+ emitSuccess(
347
+ { isJson: options.isJson },
348
+ { subcommand: subCommand, group, targets, target_dirs: targetDirs },
349
+ () => console.log(`${subCommand}: [project.${group}] ${targetsDisplay}`),
350
+ );
302
351
  return;
303
352
  }
304
- if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
353
+ if (subCommand !== "init")
354
+ throw unknownSubcommandError("project", subCommand, [
355
+ "init",
356
+ "list",
357
+ "show",
358
+ "add-path",
359
+ "remove-path",
360
+ "pin",
361
+ "unpin",
362
+ "attach",
363
+ "detach",
364
+ ]);
305
365
  let request = parseProjectInitArgs(args);
306
366
  const guided = shouldUseWizard(args, {
307
367
  interactive: isInteractive(),
@@ -319,20 +379,20 @@ export async function runProject(
319
379
  const localVaultPaths = config.local_vault_paths.map(expandHome);
320
380
  if (guided) {
321
381
  const name = await promptText("Project group", request.name);
322
- const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
323
- const surface = planClientSurfaces([client]).surfaces[0];
382
+ const availableAgents = SUPPORTED_AGENT_IDS.filter((agent) => {
383
+ const surface = planAgentSurfaces([agent]).surfaces[0];
324
384
  return (
325
385
  surface !== undefined &&
326
386
  configuredTargetForSurface(manifest, surface) !== undefined
327
387
  );
328
388
  });
329
- const clients = await promptMultiSelect(
330
- "Which clients should receive project skills?",
331
- availableClients.map((client) => ({
332
- value: client,
333
- label: client,
389
+ const agents = await promptMultiSelect(
390
+ "Which agents should receive project skills?",
391
+ availableAgents.map((agent) => ({
392
+ value: agent,
393
+ label: agent,
334
394
  selected:
335
- request.clients.length === 0 || request.clients.includes(client),
395
+ request.agents.length === 0 || request.agents.includes(agent),
336
396
  })),
337
397
  );
338
398
  const skills = parseCommaList(
@@ -341,10 +401,25 @@ export async function runProject(
341
401
  request.skills.join(","),
342
402
  ),
343
403
  );
344
- request = { ...request, name, clients, skills };
404
+ request = { ...request, name, agents, skills };
345
405
  }
346
- const clientTargets = configuredTargetsForClients(manifest, request.clients);
347
- const targets = [...new Set([...request.targets, ...clientTargets])];
406
+ // Local MCP registration + instruction writing are independent of skill
407
+ // pins only offered for agents with a verified project-scoped CLI
408
+ // command (currently just claude-code; see MCP_PROJECT_REGISTRABLE_AGENTS).
409
+ const registrableAgents = request.agents.filter((agent) =>
410
+ MCP_PROJECT_REGISTRABLE_AGENTS.includes(agent as AgentId),
411
+ ) as AgentId[];
412
+ if (guided && registrableAgents.length > 0) {
413
+ request = {
414
+ ...request,
415
+ registerMcp: await confirmAction(
416
+ `Also register skillmux as a project-scoped MCP server for ${registrableAgents.join(", ")}? ` +
417
+ `This writes ${request.path}/.mcp.json, shared via git.`,
418
+ ),
419
+ };
420
+ }
421
+ const agentTargets = configuredTargetsForAgents(manifest, request.agents);
422
+ const targets = [...new Set([...request.targets, ...agentTargets])];
348
423
  const updated = upsertProject(manifest, {
349
424
  name: request.name,
350
425
  paths: [request.path],
@@ -352,15 +427,34 @@ export async function runProject(
352
427
  targets,
353
428
  });
354
429
  const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
430
+
431
+ // The project-local instruction block only teaches an agent to call
432
+ // resolve_skill/fetch_skill (MCP tools) — write it only for agents that
433
+ // are actually getting a project-scoped MCP registration this run.
434
+ const mcpInstructionAgents = request.registerMcp ? registrableAgents : [];
435
+ const instructionPlan = planProjectInstructionSetup(
436
+ mcpInstructionAgents,
437
+ request.path,
438
+ );
439
+ const hasInstructionWrites = instructionPlan.changes.some(
440
+ (change) => change.status !== "unchanged",
441
+ );
442
+
355
443
  const plan = {
356
444
  mode: "project",
357
445
  project: request.name,
358
446
  path: request.path,
359
447
  skills: request.skills,
360
- clients: request.clients,
448
+ agents: request.agents,
361
449
  targets,
362
450
  sync: request.sync,
363
451
  notes,
452
+ instructions: instructionPlan.changes.map(({ path, agents, status }) => ({
453
+ path,
454
+ agents,
455
+ status,
456
+ })),
457
+ register_mcp_for: mcpInstructionAgents,
364
458
  };
365
459
 
366
460
  if (options.dryRun) {
@@ -375,13 +469,19 @@ export async function runProject(
375
469
  console.log("\nReview");
376
470
  console.log(` project: ${request.name}`);
377
471
  console.log(` path: ${request.path}`);
378
- console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
472
+ console.log(` agents: ${request.agents.join(", ") || "(none)"}`);
379
473
  console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
474
+ console.log(
475
+ ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
476
+ );
477
+ console.log(
478
+ ` MCP registration: ${mcpInstructionAgents.join(", ") || "(none)"}`,
479
+ );
380
480
  console.log(` sync: ${request.sync ? "yes" : "no"}`);
381
481
  }
382
482
  if (
383
483
  !(await confirmAction(
384
- `Apply project setup for ${request.name} at ${request.path}?`,
484
+ `apply project setup for ${request.name} at ${request.path}?`,
385
485
  ))
386
486
  ) {
387
487
  console.log("project setup cancelled");
@@ -395,6 +495,17 @@ export async function runProject(
395
495
  }
396
496
 
397
497
  writeManifestAtomic(manifestPath, updated);
498
+ if (hasInstructionWrites) {
499
+ try {
500
+ applyInstructionPlan(instructionPlan);
501
+ } catch (error) {
502
+ throw new Error(
503
+ `project configuration was saved, but writing instruction files failed: ${
504
+ error instanceof Error ? error.message : String(error)
505
+ }`,
506
+ );
507
+ }
508
+ }
398
509
  if (request.sync) {
399
510
  try {
400
511
  // Reaching here already required approval above (request.yes, or an
@@ -411,7 +522,39 @@ export async function runProject(
411
522
  );
412
523
  }
413
524
  }
414
- emitSuccess({ isJson: options.isJson }, { result: plan }, () =>
415
- console.log(`project "${request.name}" ready at ${request.path}`),
525
+
526
+ // Best-effort and outside the checks above: this mutates another tool's
527
+ // own config, not skillmux's, so a registration failure is reported, never
528
+ // rolled back — the successful project setup above still stands either way.
529
+ const mcpRegistrations: McpRegistrationResult[] = [];
530
+ if (request.registerMcp) {
531
+ for (const agent of registrableAgents) {
532
+ mcpRegistrations.push(
533
+ await registerMcpServer(agent, { scope: "project", cwd: request.path }),
534
+ );
535
+ }
536
+ }
537
+
538
+ emitSuccess(
539
+ { isJson: options.isJson },
540
+ {
541
+ result: {
542
+ ...plan,
543
+ instructions_changed: instructionPlan.changes
544
+ .filter((change) => change.status !== "unchanged")
545
+ .map((change) => change.path),
546
+ mcp_registrations: mcpRegistrations,
547
+ },
548
+ },
549
+ () => {
550
+ console.log(`project "${request.name}" ready at ${request.path}`);
551
+ for (const registration of mcpRegistrations) {
552
+ console.log(
553
+ registration.ok
554
+ ? `MCP registered: ${registration.agent} (project scope)`
555
+ : `MCP registration failed for ${registration.agent}: ${registration.error}`,
556
+ );
557
+ }
558
+ },
416
559
  );
417
560
  }