pi-profile-switch 0.2.0 → 0.3.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/README.md CHANGED
@@ -29,30 +29,11 @@ pi --profile read-only
29
29
  pi --profile default
30
30
  ```
31
31
 
32
- Create profiles with `/profile create`, which writes `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only). The wizard offers the shipped `read-only` preset — Pi's built-in tools only, no skills, MCP servers, or model assumed — or a blank definition:
33
-
34
- ```json
35
- {
36
- "schemaVersion": 1,
37
- "profiles": {
38
- "read-only": {
39
- "label": "Read-only",
40
- "description": "Read-only session; no skills or MCP servers assumed — add your own.",
41
- "tools": [
42
- "read",
43
- "grep",
44
- "find",
45
- "ls"
46
- ],
47
- "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
48
- }
49
- }
50
- }
51
- ```
32
+ Create profiles with `/profile create`, which writes `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only). The wizard offers the shipped `read-only` preset — Pi's built-in tools only, no skills, MCP servers, or model assumed — or a blank definition.
52
33
 
53
- A preset is a one-time copy into your catalog: it is not tracked, so a package update never changes a profile you already created. Profiles **reference** resources by name — they never copy them. [`examples/profiles.json`](examples/profiles.json) is that same preset as a catalog; the field reference is [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
34
+ A preset is a one-time copy into your catalog: it is not tracked, so a package update never changes a profile you already created. Profiles **reference** resources by name — they never copy them. [`examples/profiles.example.json`](examples/profiles.example.json) is a complete catalog with several profiles covering every field; replace its resource names with ones you own.
54
35
 
55
- `schemaVersion` is 1. A profile cannot select extensions: they load natively in every profile, so manage them with `pi install`.
36
+ `schemaVersion` is 1 and is the only accepted value. A profile cannot select extensions: they load natively in every profile, so manage them with `pi install`.
56
37
 
57
38
  ## What a profile controls
58
39
 
@@ -61,7 +42,7 @@ A preset is a one-time copy into your catalog: it is not tracked, so a package u
61
42
  | `instructions` | Text appended to the system prompt every turn |
62
43
  | `model` | Session-start model preset; an explicit `--model`/`--thinking` or a model recorded in the session wins |
63
44
  | `skills` | What the model sees in the prompt's skills list. Every installed skill stays loaded and callable by the user through `/skill:name` |
64
- | `mcp` | Runtime server allowlist published to `pi-mcp-adapter`; connection details stay in the adapter's own config |
45
+ | `mcp` | Which MCP servers the session exposes: the profile's allowlist is written into the adapter's own config (`~/.pi/agent/mcp.json`), disabling every other server. Servers you keep in that file move to `mcp.user.json`; connection details stay in your config, never in the profile |
65
46
  | `tools` | Active tool set: declared names/globs become the active set; names that register later (MCP, extensions) are applied when they appear |
66
47
 
67
48
  Anything a profile does not declare keeps Pi's native behavior, and `default` declares nothing.
@@ -74,7 +55,7 @@ In the TUI, the `/profile` command family manages everything in-session:
74
55
  | --- | --- |
75
56
  | `/profile` | Interactive profile picker |
76
57
  | `/profile list` / `/profile status` | Show profiles / active profile details |
77
- | `/profile use <name>` | Switch instantly — same session, no reload |
58
+ | `/profile use <name>` | Switch in place — same session; when the MCP selection changes, the runtime reloads automatically to re-apply it |
78
59
  | `/profile create\|edit\|delete\|duplicate` | Guided profile CRUD (TUI only) |
79
60
  | `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
80
61
  | `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
@@ -88,12 +69,11 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
88
69
  - **Reference, never copy** — profiles point at resources you already own and maintain.
89
70
  - **Pi-native** — the configuration directory is Pi's own, so sessions, extension config, packages, context files, and trust behave exactly as they do in plain Pi.
90
71
  - **Fail safe** — untrusted project directories are never read; a failed activation applies nothing and reports the cause.
91
- - **No reload** — switching re-applies runtime state in place; the next turn's prompt carries the new selection.
72
+ - **No manual reload** — switching re-applies runtime state in place and the next turn's prompt carries the new selection. Only a change to the MCP selection rebuilds the runtime, automatically, in the same session.
92
73
 
93
74
  ## Docs
94
75
 
95
76
  - [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md) (Chinese)
96
- - JSON Schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json)
97
77
 
98
78
  ## License
99
79
 
package/README.zh-CN.md CHANGED
@@ -29,30 +29,11 @@ pi --profile read-only
29
29
  pi --profile default
30
30
  ```
31
31
 
32
- 用 `/profile create` 创建 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始:
33
-
34
- ```json
35
- {
36
- "schemaVersion": 1,
37
- "profiles": {
38
- "read-only": {
39
- "label": "Read-only",
40
- "description": "Read-only session; no skills or MCP servers assumed — add your own.",
41
- "tools": [
42
- "read",
43
- "grep",
44
- "find",
45
- "ls"
46
- ],
47
- "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
48
- }
49
- }
50
- }
51
- ```
32
+ 用 `/profile create` 创建 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始。
52
33
 
53
- 预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.json`](examples/profiles.json) 就是同一个预设构成的 catalog;字段参考见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
34
+ 预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.example.json`](examples/profiles.example.json) 是覆盖全部字段的完整 catalog,示例中的资源名请替换成你本机已有的名字。
54
35
 
55
- `schemaVersion` 为 1Profile 不选择 extension:extensions 在所有 profile 中原生加载,请用 `pi install` 管理。
36
+ `schemaVersion` 为 1,也是唯一接受的值。Profile 不选择 extension:extensions 在所有 profile 中原生加载,请用 `pi install` 管理。
56
37
 
57
38
  ## Profile 控制的范围
58
39
 
@@ -61,7 +42,7 @@ pi --profile default
61
42
  | `instructions` | 每个 turn 追加到 system prompt 末尾 |
62
43
  | `model` | 会话启动的模型预设;显式 `--model`/`--thinking` 或 session 历史中记录的模型优先 |
63
44
  | `skills` | 模型在 prompt skills 列表中看到的内容;所有已安装 skill 仍保持加载,用户可用 `/skill:name` 手动调用 |
64
- | `mcp` | 发布给 `pi-mcp-adapter` 的运行时 server allowlist;连接参数仍由 adapter 自己管理 |
45
+ | `mcp` | 本次会话暴露哪些 MCP server:白名单写入 adapter 自己的配置(`~/.pi/agent/mcp.json`),其余 server 一律标为 disabled。你原本放在该文件里的 server 会迁移到 `mcp.user.json`;连接参数只在你自己的配置里,从不进入 profile |
65
46
  | `tools` | 活动工具集:声明的名字/glob 成为活动集合;之后才注册的工具(MCP、扩展)在出现时补上 |
66
47
 
67
48
  未声明的字段保持 Pi 原生行为,`default` 什么都不声明。
@@ -74,7 +55,7 @@ pi --profile default
74
55
  | --- | --- |
75
56
  | `/profile` | 交互式选择 profile |
76
57
  | `/profile list` / `/profile status` | 列出 profile / 查看活动 profile 详情 |
77
- | `/profile use <name>` | 即时切换——同一 session,无 reload |
58
+ | `/profile use <name>` | 原位切换——同一 session;当 MCP 选择发生变化时会自动 reload 以重新应用 |
78
59
  | `/profile create\|edit\|delete\|duplicate` | 向导式 profile 增删改(仅 TUI) |
79
60
  | `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
80
61
  | `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
@@ -88,12 +69,11 @@ pi --profile default
88
69
  - **引用而非复制**——profile 指向你自己拥有和维护的资源。
89
70
  - **Pi 原生**——配置目录就是 Pi 自己的目录,session、扩展配置、packages、context 文件和信任行为与原生 Pi 完全一致。
90
71
  - **失败安全**——未信任的项目目录从不读取;激活失败时不应用任何设置并报出原因。
91
- - **无 reload**——切换在原位重新应用运行时状态;下一个 turn 的 prompt 直接带上新选择。
72
+ - **无需手动 reload**——切换在原位重新应用运行时状态,下一个 turn 的 prompt 直接带上新选择;只有 MCP 选择变化时才会自动重建运行时(同一 session)
92
73
 
93
74
  ## 文档
94
75
 
95
76
  - [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
96
- - JSON Schema:[`schemas/profiles.schema.json`](schemas/profiles.schema.json)
97
77
 
98
78
  ## 许可证
99
79
 
@@ -0,0 +1,74 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "profiles": {
4
+ "read-only": {
5
+ "label": "Read-only",
6
+ "description": "Read-only session; no skills or MCP servers assumed — add your own.",
7
+ "tools": [
8
+ "read",
9
+ "grep",
10
+ "find",
11
+ "ls"
12
+ ],
13
+ "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
14
+ },
15
+ "review": {
16
+ "label": "Code review",
17
+ "description": "Review a diff or a branch: read the change, report findings, modify nothing.",
18
+ "skills": [
19
+ "code-review",
20
+ "git-*"
21
+ ],
22
+ "mcp": [
23
+ "github"
24
+ ],
25
+ "tools": [
26
+ "read",
27
+ "grep",
28
+ "find",
29
+ "ls",
30
+ "bash"
31
+ ],
32
+ "model": {
33
+ "provider": "anthropic",
34
+ "id": "claude-sonnet-4-5",
35
+ "thinkingLevel": "high"
36
+ },
37
+ "instructions": "Review only: read the change under review, then report findings with file:line evidence.\nNever edit tracked files, commit, or push; describe the required fix instead.\nSeparate verified behavior from suspected issues."
38
+ },
39
+ "plan": {
40
+ "label": "Plan",
41
+ "description": "Research a change and write the plan; no edits, no implementation.",
42
+ "skills": [],
43
+ "tools": [
44
+ "read",
45
+ "grep",
46
+ "find",
47
+ "ls",
48
+ "bash"
49
+ ],
50
+ "instructions": "Planning session: research the code first, then write the plan in your reply.\nDo not create, edit, rename, or delete files."
51
+ },
52
+ "implement": {
53
+ "label": "Implement",
54
+ "description": "Full tool access for implementing a change end to end.",
55
+ "skills": [
56
+ "git-commit",
57
+ "test-*"
58
+ ],
59
+ "mcp": [
60
+ "github"
61
+ ],
62
+ "tools": [
63
+ "read",
64
+ "grep",
65
+ "find",
66
+ "ls",
67
+ "bash",
68
+ "edit",
69
+ "write"
70
+ ],
71
+ "instructions": "Implement the requested change: read before writing, keep the diff minimal.\nRun the tests that cover your change and report the exact commands."
72
+ }
73
+ }
74
+ }
@@ -6,6 +6,7 @@ import {
6
6
  type ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
+ import { adapterPresent } from "../../src/adapter-presence.ts";
9
10
  import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
10
11
  import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
11
12
  import { readSessionChoices } from "../../src/model-selection.ts";
@@ -30,6 +31,11 @@ import {
30
31
  registerProfileFlag,
31
32
  resolveStartupProfile,
32
33
  } from "../../src/startup-selection.ts";
34
+ import {
35
+ readFlagFromArgv,
36
+ syncMcpOverlayForSelection,
37
+ syncStartupMcpOverlay,
38
+ } from "../../src/startup-mcp-scope.ts";
33
39
  import { retryPendingTools, type ApplySurface } from "../../src/switching/apply-profile.ts";
34
40
  import {
35
41
  activateProfile,
@@ -115,7 +121,27 @@ const PROFILE_USAGE = [
115
121
 
116
122
  export default function piProfileExtension(pi: ExtensionAPI): void {
117
123
  registerProfileFlag(pi);
118
- const explicit = detectExplicitDeclarations(process.argv.slice(2));
124
+ const argv = process.argv.slice(2);
125
+ const explicit = detectExplicitDeclarations(argv);
126
+ // pi-mcp-adapter reads its config before any session event fires (and, for
127
+ // eager servers, at its own load time), so the startup profile's overlay
128
+ // is generated here, synchronously. Pi applies CLI flag values only after
129
+ // extension loading, hence argv.
130
+ const loadAgentDir = getAgentDir();
131
+ const adapterInstalled = adapterPresent({
132
+ agentDir: loadAgentDir,
133
+ argv,
134
+ probeAnswered: probeAdapterPresence(pi.events),
135
+ });
136
+ if (adapterInstalled) {
137
+ const requestedConfigPath = readFlagFromArgv(argv, "mcp-config");
138
+ syncStartupMcpOverlay({
139
+ agentDir: loadAgentDir,
140
+ cwd: process.cwd(),
141
+ argv,
142
+ ...(requestedConfigPath === undefined ? {} : { overridePath: requestedConfigPath }),
143
+ });
144
+ }
119
145
  let current: Activation | undefined;
120
146
  let filterWarningShown = false;
121
147
  /** The last badge written to the footer, so a refresh only talks to Pi
@@ -239,6 +265,75 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
239
265
  });
240
266
  }
241
267
 
268
+ /** `session_start` re-check. The load-time pass cannot know an interactive
269
+ * trust answer, and the adapter has already read its config by now — so a
270
+ * difference is written for the next reload and reported instead. */
271
+ function reportMcpOverlayState(
272
+ ctx: ExtensionContext,
273
+ profileName: string,
274
+ selection: ResolvedSelection,
275
+ input: { agentDir: string; cwd: string; projectTrusted: boolean },
276
+ ): void {
277
+ const configOverride = readFlagFromArgv(argv, "mcp-config");
278
+ const sync = syncStartupMcpOverlay({
279
+ agentDir: input.agentDir,
280
+ cwd: input.cwd,
281
+ projectTrusted: input.projectTrusted,
282
+ profileName,
283
+ argv,
284
+ ...(configOverride === undefined ? {} : { overridePath: configOverride }),
285
+ });
286
+ if (sync.error !== undefined) {
287
+ notify(ctx, `pi-profile-switch: MCP overlay unavailable — ${sync.error}`, "warning");
288
+ return;
289
+ }
290
+ if (!sync.managed) {
291
+ if (selection.mcp !== undefined) {
292
+ notify(
293
+ ctx,
294
+ `pi-profile-switch: --mcp-config points at another file — profile "${profileName}" cannot filter MCP servers`,
295
+ "warning",
296
+ );
297
+ }
298
+ return;
299
+ }
300
+ if (!sync.changed) return;
301
+ notify(ctx, `pi-profile-switch: MCP config updated for profile "${profileName}" — run /reload to apply`, "warning");
302
+ }
303
+
304
+ /** Repoints the adapter at the overlay for a just-activated selection and
305
+ * rebuilds the runtime when the MCP surface actually moved. Must be the
306
+ * caller's LAST use of `ctx`: `reload()` invalidates the old context. */
307
+ async function reloadForMcpOverlay(ctx: ExtensionCommandContext, selection: ResolvedSelection): Promise<void> {
308
+ if (!adapterInstalled) return;
309
+ const configOverride = readFlagFromArgv(argv, "mcp-config");
310
+ const sync = syncMcpOverlayForSelection({
311
+ agentDir: getAgentDir(),
312
+ cwd: ctx.cwd,
313
+ projectTrusted: ctx.isProjectTrusted(),
314
+ allowed: selection.mcp === undefined ? "all" : selection.mcp,
315
+ ...(configOverride === undefined ? {} : { overridePath: configOverride }),
316
+ });
317
+ if (sync.error !== undefined) {
318
+ notify(ctx, `pi-profile-switch: MCP overlay not updated — ${sync.error}`, "warning");
319
+ return;
320
+ }
321
+ if (!sync.managed) {
322
+ if (selection.mcp !== undefined) {
323
+ notify(
324
+ ctx,
325
+ `pi-profile-switch: --mcp-config points at another file — profile "${selection.name}" cannot filter MCP servers`,
326
+ "warning",
327
+ );
328
+ }
329
+ return;
330
+ }
331
+ if (!sync.changed) return;
332
+ notify(ctx, `profile "${selection.name}": MCP servers updated — reloading runtime`, "info");
333
+ await ctx.waitForIdle();
334
+ await ctx.reload();
335
+ }
336
+
242
337
  /** Bare `/profile`: the interactive picker, with a list fallback for
243
338
  * modes without dialogs. */
244
339
  async function runPicker(ctx: ExtensionCommandContext, entries: ProfileListEntry[]): Promise<void> {
@@ -258,6 +353,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
258
353
  if (chosen === undefined || chosen.name === current?.selection.name) return;
259
354
  const result = await activate(ctx, chosen.name, { force: true, overlay: null, persist: true });
260
355
  notify(ctx, `profile active: ${result.selection.name}`, "info");
356
+ await reloadForMcpOverlay(ctx, result.selection);
261
357
  }
262
358
 
263
359
  /** `/profile create|duplicate|edit|delete`: TUI-only catalog CRUD. */
@@ -319,8 +415,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
319
415
  if (wizard === undefined) return;
320
416
  await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
321
417
  if (current !== undefined && name === current.selection.name) {
322
- await activate(ctx, name, { persist: true });
418
+ const reactivated = await activate(ctx, name, { persist: true });
323
419
  notify(ctx, `saved and reactivated profile "${name}"`, "info");
420
+ await reloadForMcpOverlay(ctx, reactivated.selection);
324
421
  } else {
325
422
  notify(ctx, `saved profile "${name}" (inactive — runtime untouched)`, "info");
326
423
  }
@@ -358,6 +455,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
358
455
  if (isActive) {
359
456
  const result = await activate(ctx, replacement ?? name, { force: replacement !== undefined, persist: true });
360
457
  notify(ctx, `deleted "${name}" (${scope}); profile active: ${result.selection.name}`, "info");
458
+ await reloadForMcpOverlay(ctx, result.selection);
361
459
  } else {
362
460
  notify(ctx, `deleted profile "${name}" (${scope})`, "info");
363
461
  }
@@ -384,8 +482,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
384
482
  try {
385
483
  // Startup activation never persists (a `--profile` selection is for
386
484
  // this run only) and never applies a stored overlay.
387
- await activate(ctx, startup.name, { persist: false, overlay: null });
485
+ const activated = await activate(ctx, startup.name, { persist: false, overlay: null });
388
486
  reportWarnings(ctx, startup.warnings);
487
+ if (adapterInstalled) {
488
+ reportMcpOverlayState(ctx, startup.name, activated.selection, {
489
+ agentDir,
490
+ cwd: ctx.cwd,
491
+ projectTrusted,
492
+ });
493
+ }
389
494
  } catch (error) {
390
495
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
391
496
  reportWarnings(ctx, startup.warnings);
@@ -498,6 +603,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
498
603
  case "use": {
499
604
  const result = await activate(ctx, rest[0] as string, { force: true, overlay: null, persist: true });
500
605
  notify(ctx, `profile active: ${result.selection.name}`, "info");
606
+ await reloadForMcpOverlay(ctx, result.selection);
501
607
  return;
502
608
  }
503
609
  case "customize": {
@@ -578,12 +684,13 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
578
684
  // catalog; the stored overlay is preserved.
579
685
  const overlay = (await new RuntimeStateStore(stateDirFor(profile.source, { agentDir, cwd: ctx.cwd })).read())
580
686
  .overlay;
581
- await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
687
+ const reactivated = await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
582
688
  notify(
583
689
  ctx,
584
690
  `${action}d MCP server "${server}" in profile "${profile.name}" (mcp: [${result.mcp.join(", ")}])`,
585
691
  "info",
586
692
  );
693
+ await reloadForMcpOverlay(ctx, reactivated.selection);
587
694
  } catch (error) {
588
695
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
589
696
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Named profiles for Pi: skills, MCP servers, tools, model, and instructions per workflow — switched in place in the same session.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,8 +10,8 @@
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
12
  "schemaVersion": {
13
- "description": "1 is current. 2 is the number v0.1.0 wrote for the same field shape and still reads; saves always write 1.",
14
- "enum": [1, 2]
13
+ "description": "Catalog format version. Only 1 is accepted.",
14
+ "const": 1
15
15
  },
16
16
  "profiles": {
17
17
  "type": "object",
@@ -44,13 +44,6 @@
44
44
  },
45
45
  "description": "Skill names or globs. Controls what the model sees in the system prompt's skills section; every loaded skill stays callable by the user through /skill:name."
46
46
  },
47
- "extensions": {
48
- "type": "array",
49
- "items": {
50
- "type": "string"
51
- },
52
- "description": "Ignored. Extensions always load natively (ADR-0007) and are managed with pi install; the field is accepted so existing catalogs keep validating."
53
- },
54
47
  "mcp": {
55
48
  "type": "array",
56
49
  "items": {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * AdapterPresence: a cheap, deterministic "is pi-mcp-adapter installed?"
3
+ * check that does not depend on extension load order.
4
+ *
5
+ * The overlay mechanism only exists to serve the adapter; when the adapter is
6
+ * absent the extension must not register the `mcp-config` flag default and
7
+ * must not write anything. Signals, in order of reliability:
8
+ *
9
+ * 1. Pi's npm package root (`<agentDir>/npm/node_modules/pi-mcp-adapter`).
10
+ * 2. The command line (`-e <path>` / `--extension <path>`).
11
+ * 3. Pi settings `packages` entries.
12
+ * 4. The adapter's own event-bus presence probe, when it answered during
13
+ * extension loading (only reliable when the adapter loaded first).
14
+ *
15
+ * Any failure reads as "absent": a false negative only disables the overlay,
16
+ * while a false positive could hide the user's own Pi-global slot file.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ import { isRecord } from "./json-file.ts";
23
+
24
+ const ADAPTER_PACKAGE = "pi-mcp-adapter";
25
+
26
+ export interface AdapterPresenceInput {
27
+ agentDir: string;
28
+ argv: readonly string[];
29
+ /** Result of the adapter's event-bus probe, when the caller ran one. */
30
+ probeAnswered?: boolean;
31
+ }
32
+
33
+ export function adapterPresent(input: AdapterPresenceInput): boolean {
34
+ if (input.probeAnswered === true) return true;
35
+ try {
36
+ for (const candidate of [
37
+ path.join(input.agentDir, "npm", "node_modules", ADAPTER_PACKAGE),
38
+ path.join(input.agentDir, "node_modules", ADAPTER_PACKAGE),
39
+ ]) {
40
+ if (existsSync(candidate)) return true;
41
+ }
42
+ return argvMentionsAdapter(input.argv) || settingsListAdapter(input.agentDir);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function argvMentionsAdapter(argv: readonly string[]): boolean {
49
+ for (let index = 0; index < argv.length; index++) {
50
+ const token = argv[index] ?? "";
51
+ if (token.includes(ADAPTER_PACKAGE)) return true;
52
+ if ((token === "-e" || token === "--extension") && (argv[index + 1] ?? "").includes(ADAPTER_PACKAGE)) {
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function settingsListAdapter(agentDir: string): boolean {
60
+ try {
61
+ const raw: unknown = JSON.parse(readFileSync(path.join(agentDir, "settings.json"), "utf8"));
62
+ if (!isRecord(raw) || !Array.isArray(raw.packages)) return false;
63
+ return raw.packages.some((entry) => {
64
+ const source =
65
+ typeof entry === "string"
66
+ ? entry
67
+ : isRecord(entry) && typeof entry.source === "string"
68
+ ? entry.source
69
+ : undefined;
70
+ return source !== undefined && source.includes(ADAPTER_PACKAGE);
71
+ });
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
package/src/json-file.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * fallback); this helper only classifies the outcome.
6
6
  */
7
7
 
8
+ import { readFileSync } from "node:fs";
8
9
  import { readFile } from "node:fs/promises";
9
10
 
10
11
  export type JsonFileResult =
@@ -30,6 +31,25 @@ export async function readJsonFile(filePath: string): Promise<JsonFileResult> {
30
31
  }
31
32
  }
32
33
 
34
+ /** Synchronous twin of `readJsonFile`, for callers that must finish before
35
+ * an event Pi is about to emit (extension loading). Same classification. */
36
+ export function readJsonFileSync(filePath: string): JsonFileResult {
37
+ let raw: string;
38
+ try {
39
+ raw = readFileSync(filePath, "utf8");
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
42
+ return { ok: false, reason: "missing" };
43
+ }
44
+ throw error;
45
+ }
46
+ try {
47
+ return { ok: true, value: JSON.parse(raw) };
48
+ } catch {
49
+ return { ok: false, reason: "invalid" };
50
+ }
51
+ }
52
+
33
53
  export function isRecord(value: unknown): value is Record<string, unknown> {
34
54
  return typeof value === "object" && value !== null && !Array.isArray(value);
35
55
  }
package/src/mcp-config.ts CHANGED
@@ -1,23 +1,32 @@
1
1
  /**
2
- * AdapterConfigDiscovery: reads the MCP server NAMES pi-mcp-adapter would
3
- * discover from its pi-native config files, without ever managing them.
2
+ * AdapterConfigDiscovery: reads what pi-mcp-adapter would load from its
3
+ * pi-native FILE sources server names for reference validation, and the
4
+ * "Pi global override" slot document for the generated overlay.
4
5
  *
5
- * pi-profile-switch never stores MCP connection parameters or credentials
6
- * (ADR-0002); this module reads only the `mcpServers` key names so an
7
- * activation can validate a profile's `mcp` references before applying it.
6
+ * Discovery mirrors the adapter's own source order (later sources override
7
+ * earlier ones): shared global MCP config, the two `.agents` globals, the Pi
8
+ * global override slot (`--mcp-config`, else `<agentDir>/mcp.json`), then the
9
+ * project's `.mcp.json` and `.pi/mcp.json` (project sources only when Pi
10
+ * reports the project trusted — an untrusted project's config is never read).
8
11
  *
9
- * Discovery scope (documented limitation): the pi-native files only — the
10
- * global `<agentDir>/mcp.json` and, when trusted, the project's
11
- * `.pi/mcp.json`. Servers defined solely in the adapter's editor-specific
12
- * legacy locations (~/.claude/mcp.json et al.) are invisible here; profiles
13
- * referencing them fail activation validation. The pi-native files are the
14
- * adapter's documented default, so keep configs there.
12
+ * Not covered (documented limitation): the adapter's opt-in host discovery
13
+ * (`~/.claude.json`, `~/.cursor/mcp.json`, …), package manifests (`pi.mcp`)
14
+ * and agent/Claude plugin sources. Their servers are namespaced and cannot be
15
+ * referenced by a profile today; the overlay still disables them when a name
16
+ * happens to match one it knows.
17
+ *
18
+ * pi-profile-switch never writes these files and never exposes connection
19
+ * parameters; the slot document is read only to be carried into the generated
20
+ * overlay, because that file replaces the slot.
15
21
  *
16
22
  * Malformed config files fail loudly — a broken mcp.json must not silently
17
23
  * read as "no servers" and reject every reference.
18
24
  */
19
25
 
20
- import { isRecord, readJsonFile } from "./json-file.ts";
26
+ import { homedir } from "node:os";
27
+ import path from "node:path";
28
+
29
+ import { isRecord, readJsonFileSync } from "./json-file.ts";
21
30
 
22
31
  export class McpConfigError extends Error {
23
32
  readonly filePath: string;
@@ -29,35 +38,145 @@ export class McpConfigError extends Error {
29
38
  }
30
39
  }
31
40
 
32
- async function readServerNames(filePath: string): Promise<string[]> {
33
- const result = await readJsonFile(filePath);
34
- if (!result.ok) {
35
- if (result.reason === "missing") {
36
- return [];
41
+ export interface AdapterMcpSource {
42
+ label: string;
43
+ filePath: string;
44
+ scope: "global" | "project";
45
+ /** True for the slot `--mcp-config` replaces. */
46
+ slot: boolean;
47
+ }
48
+
49
+ export interface AdapterMcpDiscoveryInput {
50
+ agentDir: string;
51
+ cwd: string;
52
+ projectTrusted: boolean;
53
+ /** The effective `--mcp-config` value, when one is in play. */
54
+ overridePath?: string;
55
+ /** Home directory override; tests point this at their fixture. */
56
+ homeDir?: string;
57
+ }
58
+
59
+ /** The adapter's file sources, in its own precedence order. Paths are
60
+ * de-duplicated: the adapter skips a source whose read path equals the slot. */
61
+ export function adapterMcpSources(input: AdapterMcpDiscoveryInput): AdapterMcpSource[] {
62
+ const home = input.homeDir ?? homedir();
63
+ const slotPath = path.resolve(input.overridePath ?? path.join(input.agentDir, "mcp.json"));
64
+ const candidates: AdapterMcpSource[] = [
65
+ { label: "shared global MCP config", filePath: path.join(home, ".config", "mcp", "mcp.json"), scope: "global", slot: false },
66
+ { label: ".agents MCP config", filePath: path.join(home, ".agents", "mcp.json"), scope: "global", slot: false },
67
+ { label: ".agents/mcp MCP config", filePath: path.join(home, ".agents", "mcp", "mcp.json"), scope: "global", slot: false },
68
+ { label: "Pi global MCP override", filePath: slotPath, scope: "global", slot: true },
69
+ ];
70
+ if (input.projectTrusted) {
71
+ candidates.push(
72
+ { label: "project MCP config", filePath: path.resolve(input.cwd, ".mcp.json"), scope: "project", slot: false },
73
+ { label: "project Pi MCP override", filePath: path.resolve(input.cwd, ".pi", "mcp.json"), scope: "project", slot: false },
74
+ );
75
+ }
76
+ const seen = new Set<string>();
77
+ return candidates.filter((source) => {
78
+ if (source.slot) return true; // the slot is always the read path
79
+ if (seen.has(source.filePath)) return false;
80
+ seen.add(source.filePath);
81
+ return true;
82
+ });
83
+ }
84
+
85
+ export interface AdapterMcpView {
86
+ /** Read path of the slot `--mcp-config` replaces. */
87
+ slotPath: string;
88
+ /** Parsed slot document (verbatim), when the file exists. */
89
+ slotDocument?: Record<string, unknown>;
90
+ slotNames: string[];
91
+ /** Server names from the other file sources. */
92
+ otherNames: string[];
93
+ /** Union of slot and other names, sorted. */
94
+ serverNames: string[];
95
+ }
96
+
97
+ /** Synchronous read: the extension-load pass must finish before the adapter's
98
+ * session initialization, and the files are tiny. */
99
+ export function readAdapterMcpViewSync(input: AdapterMcpDiscoveryInput): AdapterMcpView {
100
+ const sources = adapterMcpSources(input);
101
+ const slot = sources.find((source) => source.slot);
102
+ const slotPath = slot?.filePath ?? path.join(input.agentDir, "mcp.json");
103
+ let slotDocument: Record<string, unknown> | undefined;
104
+ const slotNames: string[] = [];
105
+ const otherNames = new Set<string>();
106
+ for (const source of sources) {
107
+ if (source.slot) {
108
+ const document = readMcpDocumentSync(source.filePath);
109
+ if (document === undefined) continue;
110
+ slotDocument = document;
111
+ slotNames.push(...serverNames(document, source.filePath));
112
+ continue;
37
113
  }
114
+ const document = readMcpDocumentSync(source.filePath);
115
+ if (document === undefined) continue;
116
+ for (const name of serverNames(document, source.filePath)) otherNames.add(name);
117
+ }
118
+ for (const name of slotNames) otherNames.delete(name);
119
+ return {
120
+ slotPath,
121
+ ...(slotDocument === undefined ? {} : { slotDocument }),
122
+ slotNames,
123
+ otherNames: [...otherNames].sort(),
124
+ serverNames: [...new Set([...slotNames, ...otherNames])].sort(),
125
+ };
126
+ }
127
+
128
+ /** Async twin for the runtime paths (selection, status, toggles). */
129
+ export async function readAdapterMcpView(input: AdapterMcpDiscoveryInput): Promise<AdapterMcpView> {
130
+ return readAdapterMcpViewSync(input);
131
+ }
132
+
133
+ /** Server names from every source EXCEPT the slot file. The slot is generated
134
+ * by pi-profile-switch, so it must not feed back into the next generation —
135
+ * otherwise a stub would look like a source server and vanish on the next
136
+ * write. */
137
+ export function readAdapterOtherServerNamesSync(input: AdapterMcpDiscoveryInput): string[] {
138
+ const names = new Set<string>();
139
+ for (const source of adapterMcpSources(input)) {
140
+ if (source.slot) continue;
141
+ const document = readMcpDocumentSync(source.filePath);
142
+ if (document === undefined) continue;
143
+ for (const name of serverNames(document, source.filePath)) names.add(name);
144
+ }
145
+ return [...names].sort();
146
+ }
147
+
148
+ /** Server names the adapter would discover. `projectDir` is passed only when
149
+ * the trust check passed. */
150
+ export async function discoverAdapterServerNames(
151
+ agentDir: string,
152
+ projectDir?: string,
153
+ homeDir?: string,
154
+ ): Promise<string[]> {
155
+ return readAdapterMcpViewSync({
156
+ agentDir,
157
+ cwd: projectDir ?? process.cwd(),
158
+ projectTrusted: projectDir !== undefined,
159
+ ...(homeDir === undefined ? {} : { homeDir }),
160
+ }).serverNames;
161
+ }
162
+
163
+ /** Missing file → undefined; malformed → McpConfigError. */
164
+ export function readMcpDocumentSync(filePath: string): Record<string, unknown> | undefined {
165
+ const result = readJsonFileSync(filePath);
166
+ if (!result.ok) {
167
+ if (result.reason === "missing") return undefined;
38
168
  throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
39
169
  }
40
170
  if (!isRecord(result.value)) {
41
171
  throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
42
172
  }
43
- if (result.value.mcpServers === undefined) {
44
- return [];
45
- }
46
- if (!isRecord(result.value.mcpServers)) {
47
- throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
48
- }
49
- return Object.keys(result.value.mcpServers);
173
+ return result.value;
50
174
  }
51
175
 
52
- /** Server names the adapter would discover: global agentDir config plus the
53
- * trusted project's config. Pass `projectDir` only when the trust check
54
- * passed — an untrusted project's config is never read. */
55
- export async function discoverAdapterServerNames(agentDir: string, projectDir?: string): Promise<string[]> {
56
- const names = new Set(await readServerNames(`${agentDir}/mcp.json`));
57
- if (projectDir !== undefined) {
58
- for (const name of await readServerNames(`${projectDir}/.pi/mcp.json`)) {
59
- names.add(name);
60
- }
176
+ function serverNames(document: Record<string, unknown>, filePath: string): string[] {
177
+ if (document.mcpServers === undefined) return [];
178
+ if (!isRecord(document.mcpServers)) {
179
+ throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
61
180
  }
62
- return [...names].sort();
181
+ return Object.keys(document.mcpServers);
63
182
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * McpOverlayFile: the only writer of the generated MCP overlay.
3
+ *
4
+ * Invariants:
5
+ * - Atomic (temporary file + rename) so a reader never sees a half-written
6
+ * document.
7
+ * - Written only when the bytes change: a profile switch that does not move
8
+ * the MCP selection produces no write and therefore no reload.
9
+ * - Mode 0600: the overlay carries the Pi-global slot's definitions verbatim
10
+ * when the user keeps servers there, and those definitions may embed
11
+ * credentials even though the overlay itself never introduces any.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ /** Current bytes, or undefined when the file is missing/unreadable. */
18
+ export function readMcpOverlaySync(overlayPath: string): string | undefined {
19
+ try {
20
+ return readFileSync(overlayPath, "utf8");
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ /** Writes `content` when it differs from what is on disk. Returns true when
27
+ * the file changed. */
28
+ export function writeMcpOverlayIfChangedSync(overlayPath: string, content: string): boolean {
29
+ if (readMcpOverlaySync(overlayPath) === content) return false;
30
+ mkdirSync(path.dirname(overlayPath), { recursive: true });
31
+ const temporary = `${overlayPath}.tmp-${process.pid}`;
32
+ writeFileSync(temporary, content, { mode: 0o600 });
33
+ renameSync(temporary, overlayPath);
34
+ return true;
35
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * McpOverlay: the PURE half of the profile-scoped MCP filter.
3
+ *
4
+ * `pi-mcp-adapter` has no runtime allowlist channel (ADR-0002 amendment), so
5
+ * the profile's `mcp` declaration is enforced by a generated config file the
6
+ * adapter reads as its "Pi global override" slot (the slot `--mcp-config`
7
+ * replaces). The file is a DISABLE OVERLAY: entries carry no connection
8
+ * parameters and no credentials — `{ "<server>": { "disabled": true } }` is
9
+ * the adapter's own idiom for `/mcp disable`, and its config merge is
10
+ * per-field, so a stub merges onto the definition owned by the user's own
11
+ * file.
12
+ *
13
+ * One exception is structural: because the overlay file REPLACES the Pi
14
+ * global slot, the servers the user keeps in that slot are carried over
15
+ * verbatim from the sidecar (`mcp.user.json`); a hand-written slot file is
16
+ * adopted into the sidecar before the first overwrite.
17
+ *
18
+ * `allowed` semantics:
19
+ * - `"all"` — the profile declares no `mcp`: nothing is disabled.
20
+ * - `[]` — every discovered server is disabled.
21
+ * - `["github", …]` — everything outside the list is disabled.
22
+ */
23
+
24
+ import path from "node:path";
25
+
26
+ import { isRecord } from "./json-file.ts";
27
+ import { matchesReference } from "./name-matching.ts";
28
+
29
+ /**
30
+ * The generated overlay REPLACES the adapter's Pi-global slot, so it lives at
31
+ * the slot's default path. The user's own Pi-global servers move to a sidecar
32
+ * that this package never writes except when adopting a hand-written slot.
33
+ */
34
+ export const MCP_SLOT_FILE_NAME = "mcp.json";
35
+ export const MCP_SOURCE_FILE_NAME = "mcp.user.json";
36
+ /** Top-level key marking a file as generated. The adapter ignores unknown
37
+ * top-level keys, so the marker never reaches it as configuration. */
38
+ export const MCP_GENERATED_MARKER = "piProfileSwitch";
39
+
40
+ /** The adapter's Pi-global slot: the generated overlay. */
41
+ export function mcpSlotPath(agentDir: string): string {
42
+ return path.join(agentDir, MCP_SLOT_FILE_NAME);
43
+ }
44
+
45
+ /** The user-owned sidecar holding the Pi-global servers verbatim. */
46
+ export function mcpSourcePath(agentDir: string): string {
47
+ return path.join(agentDir, MCP_SOURCE_FILE_NAME);
48
+ }
49
+
50
+ /** True when the document was produced by pi-profile-switch. */
51
+ export function isGeneratedOverlay(value: unknown): boolean {
52
+ if (!isRecord(value)) return false;
53
+ const marker = value[MCP_GENERATED_MARKER];
54
+ return isRecord(marker) && marker.generated === true;
55
+ }
56
+
57
+ /** True for the credential-free stubs the overlay adds for servers defined in
58
+ * the adapter's other sources. */
59
+ export function isDisabledStub(value: unknown): boolean {
60
+ return isRecord(value) && Object.keys(value).length === 1 && value.disabled === true;
61
+ }
62
+
63
+ /** Server names a profile's `mcp` references resolve to. `undefined` (the
64
+ * profile declares nothing) means "no filtering" and is reported as the
65
+ * literal `"all"`. Glob references follow the same rules as every other
66
+ * profile reference. */
67
+ export function resolveAllowedServers(
68
+ refs: readonly string[] | undefined,
69
+ discovered: readonly string[],
70
+ ): readonly string[] | "all" {
71
+ if (refs === undefined) return "all";
72
+ const allowed = new Set<string>();
73
+ for (const ref of refs) {
74
+ for (const name of discovered) {
75
+ if (matchesReference(ref, name)) allowed.add(name);
76
+ }
77
+ }
78
+ return [...allowed];
79
+ }
80
+
81
+ export interface McpOverlayInput {
82
+ /** Parsed Pi-global slot document, when the user has one. */
83
+ slotDocument?: Record<string, unknown>;
84
+ /** Server names defined in the adapter's other file sources. */
85
+ otherServerNames: readonly string[];
86
+ allowed: readonly string[] | "all";
87
+ }
88
+
89
+ /** Builds the overlay document. Server order is sorted so the serialized
90
+ * bytes are stable and a rewrite only happens on a real change. */
91
+ export function buildMcpOverlay(input: McpOverlayInput): Record<string, unknown> {
92
+ const allowed = input.allowed === "all" ? undefined : new Set(input.allowed);
93
+ const slotServers = isRecord(input.slotDocument?.mcpServers) ? input.slotDocument.mcpServers : {};
94
+ const servers: Record<string, unknown> = {};
95
+ for (const [name, definition] of Object.entries(slotServers)) {
96
+ if (allowed === undefined || allowed.has(name) || !isRecord(definition)) {
97
+ servers[name] = definition;
98
+ continue;
99
+ }
100
+ servers[name] = { ...definition, disabled: true };
101
+ }
102
+ for (const name of input.otherServerNames) {
103
+ if (name in servers) continue; // the slot's definition owns the name
104
+ if (allowed === undefined || allowed.has(name)) continue;
105
+ servers[name] = { disabled: true };
106
+ }
107
+ const document: Record<string, unknown> = {};
108
+ for (const [key, value] of Object.entries(input.slotDocument ?? {})) {
109
+ if (key !== "mcpServers" && key !== MCP_GENERATED_MARKER) document[key] = value;
110
+ }
111
+ document[MCP_GENERATED_MARKER] = { generated: true, version: 1 };
112
+ document.mcpServers = Object.fromEntries(
113
+ Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)),
114
+ );
115
+ return document;
116
+ }
117
+
118
+ /** Stable serialization: two runs with the same semantics produce the same
119
+ * bytes, so the writer can skip no-op writes. */
120
+ export function serializeMcpOverlay(document: Record<string, unknown>): string {
121
+ return `${JSON.stringify(document, null, 2)}\n`;
122
+ }
@@ -37,7 +37,7 @@ export class ProfileCatalogStore {
37
37
 
38
38
  /** Validated definitions: missing file → empty; malformed → CatalogError
39
39
  * (catalog errors never pass silently, even on the write path).
40
- * Unknown fields (a legacy `extensions` declaration) are dropped. */
40
+ * Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
41
41
  async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
42
42
  const result = await readJsonFile(this.#filePath);
43
43
  if (!result.ok) {
@@ -7,11 +7,11 @@
7
7
  * - A profile references skills, MCP servers, and tools, and may declare
8
8
  * instructions and a model preset. Extensions are not a profile resource:
9
9
  * every installed extension loads natively in every profile.
10
- * - schemaVersion 1 is current. Version 2 describes the same fields — the
11
- * number only marked the era in which `extensions` was still a profile
12
- * resource and reads the same; every save writes version 1.
13
- * - A legacy `extensions` field is ignored silently, like any other unknown
14
- * field: extensions load natively and are managed with `pi install`.
10
+ * - schemaVersion 1 is the only accepted version: any other value fails
11
+ * loudly instead of guessing at a shape.
12
+ * - Unknown fields (an `extensions` declaration left over from v0.1.0, an
13
+ * inheritance key) are ignored silently; saving drops them, so a written
14
+ * definition always matches the current shape.
15
15
  *
16
16
  * Invariants:
17
17
  * - The built-in `default` profile never exists in either file and cannot be
@@ -30,8 +30,6 @@ import path from "node:path";
30
30
  import { isRecord, readJsonFile } from "./json-file.ts";
31
31
 
32
32
  export const PROFILE_SCHEMA_VERSION = 1;
33
- /** The number v0.1.0 wrote for the same field shape: read, never written. */
34
- const LEGACY_SCHEMA_VERSION = 2;
35
33
  export const DEFAULT_PROFILE_NAME = "default";
36
34
 
37
35
  export interface ProfileModel {
@@ -91,8 +89,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
91
89
 
92
90
  /** Parses one raw profile definition; exported for the write-side store
93
91
  * (profile-catalog-store.ts) so anything written is loadable. Unknown
94
- * fields are ignored by design — a legacy `extensions` declaration is
95
- * dropped silently, exactly like any other unknown key. */
92
+ * fields are ignored by design — an `extensions` key left over from
93
+ * v0.1.0 is dropped silently, exactly like any other unknown key. */
96
94
  export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
97
95
  if (!isRecord(raw)) {
98
96
  throw new CatalogError(`profile "${name}" must be an object`);
@@ -125,7 +123,7 @@ export function parseCatalogDocument(value: unknown, filePath: string): Map<stri
125
123
  throw new CatalogError(`${filePath}: catalog must be an object`);
126
124
  }
127
125
  const version = value.schemaVersion;
128
- if (version !== PROFILE_SCHEMA_VERSION && version !== LEGACY_SCHEMA_VERSION) {
126
+ if (version !== PROFILE_SCHEMA_VERSION) {
129
127
  throw new CatalogError(
130
128
  `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
131
129
  );
@@ -25,8 +25,9 @@
25
25
  * - `instructions` states behavior, never a capability name, and stays
26
26
  * short: Pi appends it to the system prompt on every turn.
27
27
  *
28
- * `test/profile-presets.test.ts` enforces every rule above and asserts
29
- * `examples/profiles.json` is exactly this catalog.
28
+ * `test/profile-presets.test.ts` enforces every rule above, asserts
29
+ * `examples/profiles.json` is exactly this catalog, and asserts each preset
30
+ * appears verbatim in `examples/profiles.example.json`.
30
31
  */
31
32
 
32
33
  import type { ProfileDefinition } from "./profile-catalog.ts";
@@ -0,0 +1,262 @@
1
+ /**
2
+ * StartupMcpScope: derives the profile-scoped MCP overlay at times when a
3
+ * full activation has not (or may not yet) run.
4
+ *
5
+ * Two callers:
6
+ * - The extension-load pass: must be synchronous and must finish before
7
+ * pi-mcp-adapter reads its config at `session_start` (and, for eager
8
+ * servers, at its own load time). Pi applies CLI flag values only AFTER
9
+ * extension loading, so the `--profile` / `--mcp-config` values are read
10
+ * from argv here; trust is mirrored from Pi's own resolution order
11
+ * (`hasTrustRequiringProjectResources` → stored decision →
12
+ * `defaultProjectTrust`; an interactive first-time prompt is not yet
13
+ * answerable at load time and counts as untrusted).
14
+ * - The switch pass: the caller already has the resolved allowlist, so the
15
+ * overlay is derived from it directly.
16
+ *
17
+ * The generated file IS the adapter's Pi-global slot (`<agentDir>/mcp.json`):
18
+ * flag injection is impossible (Pi rejects two extensions registering the
19
+ * same flag), so the mechanism works with the slot the adapter already reads.
20
+ * The user's own Pi-global servers live in the sidecar `mcp.user.json`.
21
+ *
22
+ * Failure policy: a profile name that cannot be resolved (missing, malformed
23
+ * catalog, unknown name) falls back to "no filtering" — the generated overlay
24
+ * then changes nothing, and the real problem is reported loudly by the
25
+ * activation that runs right after. A foreign `--mcp-config` disables
26
+ * management entirely: the user's explicit file is never overwritten.
27
+ */
28
+
29
+ import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
30
+ import path from "node:path";
31
+
32
+ import { isRecord, readJsonFileSync } from "./json-file.ts";
33
+ import { readAdapterOtherServerNamesSync, readMcpDocumentSync } from "./mcp-config.ts";
34
+ import {
35
+ buildMcpOverlay,
36
+ isDisabledStub,
37
+ isGeneratedOverlay,
38
+ MCP_GENERATED_MARKER,
39
+ mcpSlotPath,
40
+ mcpSourcePath,
41
+ resolveAllowedServers,
42
+ serializeMcpOverlay,
43
+ } from "./mcp-overlay.ts";
44
+ import { writeMcpOverlayIfChangedSync } from "./mcp-overlay-file.ts";
45
+ import { DEFAULT_PROFILE_NAME, parseCatalogDocument, type ProfileDefinition } from "./profile-catalog.ts";
46
+
47
+ /** Reads `--<name> <value>` / `--<name>=<value>` without Pi's parser (Pi
48
+ * applies extension flag values only after extension loading). Last wins. */
49
+ export function readFlagFromArgv(argv: readonly string[], name: string): string | undefined {
50
+ const long = `--${name}`;
51
+ let value: string | undefined;
52
+ for (let index = 0; index < argv.length; index++) {
53
+ const token = argv[index] ?? "";
54
+ if (token === long) {
55
+ const next = argv[index + 1];
56
+ if (next !== undefined && !next.startsWith("--")) value = next;
57
+ continue;
58
+ }
59
+ if (token.startsWith(`${long}=`)) {
60
+ const inline = token.slice(long.length + 1);
61
+ if (inline.length > 0) value = inline;
62
+ }
63
+ }
64
+ return value;
65
+ }
66
+
67
+ /** True when Pi itself would consider `cwd` trusted right now. Mirrors
68
+ * `resolveProjectTrusted` minus extension votes and the interactive prompt. */
69
+ export function resolveProjectTrustedSync(agentDir: string, cwd: string): boolean {
70
+ if (!hasTrustRequiringProjectResources(cwd)) return true;
71
+ const stored = new ProjectTrustStore(agentDir).get(cwd);
72
+ if (stored !== null) return stored;
73
+ return readDefaultProjectTrustSync(agentDir) === "always";
74
+ }
75
+
76
+ /** The saved selection for this run: project state wins over global state
77
+ * (project state only when trusted), then the built-in default. */
78
+ export function resolveStartupProfileNameSync(input: {
79
+ agentDir: string;
80
+ cwd: string;
81
+ projectTrusted: boolean;
82
+ argv: readonly string[];
83
+ }): string {
84
+ const requested = readFlagFromArgv(input.argv, "profile");
85
+ if (requested !== undefined && requested.length > 0) return requested;
86
+ const project = input.projectTrusted
87
+ ? readActiveProfile(path.join(input.cwd, ".pi", "pi-profile-state.json"))
88
+ : undefined;
89
+ return project ?? readActiveProfile(path.join(input.agentDir, "pi-profile-state.json")) ?? DEFAULT_PROFILE_NAME;
90
+ }
91
+
92
+ export interface McpOverlaySyncInput {
93
+ agentDir: string;
94
+ cwd: string;
95
+ /** Pi's trust decision. Omitted on the extension-load pass, where Pi has
96
+ * not resolved trust yet; the module mirrors Pi's own order then. */
97
+ projectTrusted?: boolean;
98
+ /** Effective `--mcp-config`; undefined means the managed overlay path. */
99
+ overridePath?: string;
100
+ /** The command line, for the load pass (Pi applies flag values only after
101
+ * extension loading, so `--profile` is read from here). */
102
+ argv?: readonly string[];
103
+ homeDir?: string;
104
+ }
105
+
106
+ export interface McpOverlaySyncResult {
107
+ overlayPath: string;
108
+ /** False when a foreign `--mcp-config` owns the adapter's slot. */
109
+ managed: boolean;
110
+ changed: boolean;
111
+ /** Set when nothing was written; the caller surfaces it as a warning. */
112
+ error?: string;
113
+ }
114
+
115
+ /** Load-time (and `session_start` re-check) pass. `profileName` short-circuits
116
+ * the state lookup when the caller already activated a profile. */
117
+ export function syncStartupMcpOverlay(
118
+ input: McpOverlaySyncInput & { profileName?: string; mcpRefs?: readonly string[] | undefined },
119
+ ): McpOverlaySyncResult {
120
+ try {
121
+ const trust = input.projectTrusted;
122
+ const profileName =
123
+ input.profileName ??
124
+ resolveStartupProfileNameSync({
125
+ agentDir: input.agentDir,
126
+ cwd: input.cwd,
127
+ projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
128
+ argv: input.argv ?? [],
129
+ });
130
+ const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
131
+ return writeOverlayForRefs({ ...input, refs });
132
+ } catch (error) {
133
+ return {
134
+ overlayPath: mcpSlotPath(input.agentDir),
135
+ managed: true,
136
+ changed: false,
137
+ error: error instanceof Error ? error.message : String(error),
138
+ };
139
+ }
140
+ }
141
+
142
+ /** Switch pass: the caller already resolved the allowlist. */
143
+ export function syncMcpOverlayForSelection(
144
+ input: McpOverlaySyncInput & { allowed: readonly string[] | "all" },
145
+ ): McpOverlaySyncResult {
146
+ try {
147
+ return writeOverlay({ ...input, refs: input.allowed });
148
+ } catch (error) {
149
+ return {
150
+ overlayPath: mcpSlotPath(input.agentDir),
151
+ managed: true,
152
+ changed: false,
153
+ error: error instanceof Error ? error.message : String(error),
154
+ };
155
+ }
156
+ }
157
+
158
+ function writeOverlayForRefs(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "unknown" }): McpOverlaySyncResult {
159
+ // An unresolvable profile is not an error here: the generated overlay
160
+ // then filters nothing and the activation reports the real problem.
161
+ const refs = input.refs === "unknown" ? undefined : input.refs;
162
+ return writeOverlay({ ...input, refs });
163
+ }
164
+
165
+ function writeOverlay(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "all" }): McpOverlaySyncResult {
166
+ const overlayPath = mcpSlotPath(input.agentDir);
167
+ if (input.overridePath !== undefined && path.resolve(input.overridePath) !== overlayPath) {
168
+ return { overlayPath, managed: false, changed: false };
169
+ }
170
+ const projectTrusted = input.projectTrusted ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
171
+ const discovery = {
172
+ agentDir: input.agentDir,
173
+ cwd: input.cwd,
174
+ projectTrusted,
175
+ ...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
176
+ };
177
+ // The slot is ours; a slot written by hand is adopted into the sidecar
178
+ // before the first overwrite, so a user's Pi-global servers survive.
179
+ const sourcePath = mcpSourcePath(input.agentDir);
180
+ adoptHandWrittenSlotSync(overlayPath, sourcePath);
181
+ const sourceDocument = readMcpDocumentSync(sourcePath);
182
+ const sourceNames = isRecord(sourceDocument?.mcpServers) ? Object.keys(sourceDocument.mcpServers) : [];
183
+ const otherNames = readAdapterOtherServerNamesSync(discovery);
184
+ const allowed =
185
+ input.refs === "all"
186
+ ? "all"
187
+ : resolveAllowedServers(input.refs, [...new Set([...sourceNames, ...otherNames])].sort());
188
+ const content = serializeMcpOverlay(
189
+ buildMcpOverlay({
190
+ ...(sourceDocument === undefined ? {} : { slotDocument: sourceDocument }),
191
+ otherServerNames: otherNames,
192
+ allowed,
193
+ }),
194
+ );
195
+ return { overlayPath, managed: true, changed: writeMcpOverlayIfChangedSync(overlayPath, content) };
196
+ }
197
+
198
+ /** A slot file without the generated marker holds the user's own Pi-global
199
+ * servers (from before this package managed the slot, or from a manual
200
+ * edit). Move them — plus the slot's non-server keys — into the sidecar
201
+ * before the slot is overwritten. Stub entries are skipped: they describe
202
+ * servers owned by the adapter's other sources. */
203
+ function adoptHandWrittenSlotSync(slotPath: string, sourcePath: string): void {
204
+ const slot = readMcpDocumentSync(slotPath);
205
+ if (slot === undefined || isGeneratedOverlay(slot)) return;
206
+ const sidecar = readMcpDocumentSync(sourcePath) ?? {};
207
+ const sidecarServers = isRecord(sidecar.mcpServers) ? sidecar.mcpServers : {};
208
+ const servers: Record<string, unknown> = { ...sidecarServers };
209
+ const slotServers = isRecord(slot.mcpServers) ? slot.mcpServers : {};
210
+ for (const [name, definition] of Object.entries(slotServers)) {
211
+ if (isDisabledStub(definition)) continue;
212
+ servers[name] = definition;
213
+ }
214
+ const document: Record<string, unknown> = { ...sidecar };
215
+ delete document[MCP_GENERATED_MARKER];
216
+ for (const [key, value] of Object.entries(slot)) {
217
+ if (key === "mcpServers" || key === MCP_GENERATED_MARKER) continue;
218
+ document[key] = value;
219
+ }
220
+ document.mcpServers = Object.fromEntries(Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)));
221
+ writeMcpOverlayIfChangedSync(sourcePath, `${JSON.stringify(document, null, 2)}\n`);
222
+ }
223
+
224
+ function readActiveProfile(statePath: string): string | undefined {
225
+ const result = readJsonFileSync(statePath);
226
+ if (!result.ok || !isRecord(result.value)) return undefined;
227
+ const name = result.value.activeProfile;
228
+ return typeof name === "string" && name.length > 0 ? name : undefined;
229
+ }
230
+
231
+ /** The profile's raw `mcp` references; `"unknown"` when the profile or its
232
+ * catalog cannot be read (caller falls back to "no filtering"). */
233
+ function readProfileMcpRefsSync(
234
+ input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
235
+ ): readonly string[] | undefined | "unknown" {
236
+ if (input.name === DEFAULT_PROFILE_NAME) return undefined;
237
+ const trusted = input.trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
238
+ const global = readCatalogSync(path.join(input.agentDir, "profiles.json"));
239
+ const project = trusted
240
+ ? readCatalogSync(path.join(input.cwd, ".pi", "profiles.json"))
241
+ : new Map<string, ProfileDefinition>();
242
+ if (global === "error" || project === "error") return "unknown";
243
+ const definition = project.get(input.name) ?? global.get(input.name);
244
+ return definition === undefined ? "unknown" : definition.mcp;
245
+ }
246
+
247
+ function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
248
+ const result = readJsonFileSync(filePath);
249
+ if (!result.ok) return result.reason === "missing" ? new Map<string, ProfileDefinition>() : "error";
250
+ try {
251
+ return parseCatalogDocument(result.value, filePath);
252
+ } catch {
253
+ return "error";
254
+ }
255
+ }
256
+
257
+ function readDefaultProjectTrustSync(agentDir: string): string | undefined {
258
+ const result = readJsonFileSync(path.join(agentDir, "settings.json"));
259
+ if (!result.ok || !isRecord(result.value)) return undefined;
260
+ const value = result.value.defaultProjectTrust;
261
+ return typeof value === "string" ? value : undefined;
262
+ }