pi-profile-switch 0.13.1 → 0.14.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 +39 -57
- package/README.zh-CN.md +33 -69
- package/bin/pi-profile.ts +3 -0
- package/extensions/pi-profile/index.ts +10 -0
- package/package.json +3 -3
- package/src/launcher/initial-profile.ts +1 -1
- package/src/launcher/spawn.ts +2 -2
- package/src/mcp-config.ts +38 -9
- package/src/profile-resolver.ts +54 -15
- package/src/settings-generator.ts +98 -44
- package/src/startup-notifier.ts +8 -8
- package/src/switching/apply-plan.ts +40 -2
- package/src/switching/switch-profile.ts +21 -17
package/README.md
CHANGED
|
@@ -10,15 +10,15 @@ Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a nam
|
|
|
10
10
|
npm install -g pi-profile-switch
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (installed automatically as a peer dependency).
|
|
13
|
+
Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) 0.99.1 or newer (installed automatically as a peer dependency). Use `npm install -g`, not `pi install` — this package provides the `pi-profile` launcher.
|
|
14
14
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
#
|
|
18
|
+
# Built-in default profile: all resources, plain Pi behavior
|
|
19
19
|
pi-profile
|
|
20
20
|
|
|
21
|
-
#
|
|
21
|
+
# Starter read-only ask profile
|
|
22
22
|
pi-profile ask
|
|
23
23
|
|
|
24
24
|
# Anything after -- is passed to pi verbatim
|
|
@@ -27,58 +27,28 @@ pi-profile ask -- --model openai/gpt-5.4
|
|
|
27
27
|
|
|
28
28
|
## Define your own profiles
|
|
29
29
|
|
|
30
|
-
Profiles live in two directories,
|
|
30
|
+
Profiles live in two directories, one JSON file per profile:
|
|
31
31
|
|
|
32
32
|
| Path | Scope |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `~/.pi-profile-switch/profiles/<name>.json` | Global, all projects. `PI_PROFILE_SWITCH_DIR` overrides the
|
|
35
|
-
| `<project>/.pi/profiles/<name>.json` | Project-level, trusted projects only. |
|
|
34
|
+
| `~/.pi-profile-switch/profiles/<name>.json` | Global, all projects. `PI_PROFILE_SWITCH_DIR` overrides the root directory. |
|
|
35
|
+
| `<project>/.pi/profiles/<name>.json` | Project-level, trusted projects only. Completely replaces a global profile with the same name. |
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Write the JSON directly (schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json)), or configure profiles conversationally: the package ships a [`profile-config`](skills/profile-config/SKILL.md) skill that creates, edits, and deletes profiles. Profiles created with a `skills` list include `"profile-config"` by default (unless you opt out or cover it with a wildcard like `"*"`), keeping configuration available after switching.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
When the global profiles directory has no profile yet, pi-profile-switch writes a starter **`ask`** profile — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely. See [`examples/ask.json`](examples/ask.json).
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```json
|
|
44
|
-
{
|
|
45
|
-
"label": "Ask & Discuss",
|
|
46
|
-
"description": "Read-only Q&A and code exploration; no file modifications or command execution",
|
|
47
|
-
"skills": [],
|
|
48
|
-
"extensions": [],
|
|
49
|
-
"tools": ["read", "grep", "find", "ls"],
|
|
50
|
-
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
One profile can use every field at once. This example `impl` profile (`impl.json`) loads the TDD skill and your internal skills; wires up two MCP servers; allows the built-in tools; restricts GitHub MCP tools while denying Linear tools; and pins the model and standing instructions:
|
|
41
|
+
A profile that uses every field:
|
|
55
42
|
|
|
56
43
|
```json
|
|
57
44
|
{
|
|
58
45
|
"label": "Implementation",
|
|
59
46
|
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
60
|
-
"skills": [
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
],
|
|
64
|
-
"mcps": [
|
|
65
|
-
"github",
|
|
66
|
-
"linear"
|
|
67
|
-
],
|
|
68
|
-
"tools": [
|
|
69
|
-
"read",
|
|
70
|
-
"grep",
|
|
71
|
-
"find",
|
|
72
|
-
"ls",
|
|
73
|
-
"bash",
|
|
74
|
-
"edit",
|
|
75
|
-
"write"
|
|
76
|
-
],
|
|
47
|
+
"skills": ["tdd", "internal-*"],
|
|
48
|
+
"mcps": ["github", "linear"],
|
|
49
|
+
"tools": ["read", "grep", "find", "ls", "bash", "edit", "write"],
|
|
77
50
|
"mcp_tools": {
|
|
78
|
-
"github": [
|
|
79
|
-
"search",
|
|
80
|
-
"get_issue"
|
|
81
|
-
],
|
|
51
|
+
"github": ["search", "get_issue"],
|
|
82
52
|
"linear": []
|
|
83
53
|
},
|
|
84
54
|
"defaultProvider": "anthropic",
|
|
@@ -88,42 +58,54 @@ One profile can use every field at once. This example `impl` profile (`impl.json
|
|
|
88
58
|
}
|
|
89
59
|
```
|
|
90
60
|
|
|
91
|
-
How fields
|
|
61
|
+
How the fields behave:
|
|
92
62
|
|
|
93
63
|
- `skills`, `extensions`, `mcps`, `tools` take names or globs (e.g. `"internal-*"`) referencing resources you already installed or configured — profiles never copy them. Installed packages and files in standard locations are discovered automatically; no registration needed.
|
|
94
|
-
- `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-contributed tools, attributed by registration ownership (`sourceInfo`). Available MCP tools remain usable independently of `tools`.
|
|
64
|
+
- `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-contributed tools, attributed by registration ownership (`sourceInfo`). Available MCP tools remain usable independently of `tools`. When a profile declares `tools` and at least one MCP server is enabled, Pi's native MCP discovery entry points (`codemode` and `tool_search`) stay active even if you did not list them; unrelated non-MCP tools excluded by `tools` stay excluded.
|
|
95
65
|
- `mcp_tools` defines per-server MCP tool filtering: keys are literal configured server names and values are literal MCP tool names as exposed by Pi's built-in MCP extension. Globs are not accepted. An omitted server keeps native access to all its tools; a nonempty array allows only matched tools; an empty array (`[]`) denies all tools for that server while leaving it enabled. Unmatched selectors remain restrictive and are not diagnosed, so confirm selectors with the server before writing them.
|
|
96
66
|
- **Migration notes:**
|
|
97
67
|
- Former MCP references in `tools` (e.g. `mcp__*`, `<server>_*`) no longer govern MCP access. Move desired MCP tool restrictions to `mcp_tools`.
|
|
98
68
|
- Prefixed selectors (e.g. `<server>_<tool>` forms used by previous MCP integrations) no longer apply; replace them with the literal tool names from Pi's built-in MCP extension.
|
|
99
69
|
- `mcps` references servers from your Pi user-level MCP configuration (`~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, and `<agentDir>/mcp.json`); connection details stay in those files.
|
|
100
70
|
- **Omitting `mcps`** leaves all discovered user-level servers at their normal availability.
|
|
101
|
-
- **`mcps: []`** disables every discovered user-level server, including agentDir-only servers; every unselected user-level server is explicitly marked `enabled: false` in the generated instance `mcp.json`. Project-level servers are never narrowed.
|
|
71
|
+
- **`mcps: []`** disables every discovered user-level server, including agentDir-only servers; every unselected user-level server keeps its full definition and is explicitly marked `enabled: false` in the generated instance `mcp.json`. Project-level servers are never narrowed.
|
|
102
72
|
- Trusted project-level MCP servers are always kept enabled and are never narrowed by `mcps`.
|
|
103
73
|
- Servers using `type: "sse"` cannot be selected; migrate them to streamable HTTP before referencing them in a profile.
|
|
74
|
+
- A later user-level source replaces a same-named server from an earlier source in full (no field-wise merging), so connection and credential fields are never inherited across files.
|
|
75
|
+
- A profile that declares neither `mcps` nor `mcp_tools` treats a malformed user-level MCP source as a non-fatal diagnostic (printed on stderr with the file path) and starts with the remaining valid sources. Declaring `mcps` or a nonempty `mcp_tools` makes the same malformed source fail activation, because the allowlist cannot be trusted.
|
|
104
76
|
- The instance `mcp.json` is always a generated snapshot of the merged user-level configuration. In-session `pi mcp add` edits the instance copy, and the next `/profile use` or `/profile reload` overwrites it with the profile's snapshot.
|
|
105
77
|
- Any field you omit keeps plain Pi behavior.
|
|
78
|
+
- `label` and `description` are display metadata. `defaultProvider` and `defaultModel` (declared together) set the startup model; `defaultThinkingLevel` sets its thinking level; `instructions` is appended to the system prompt.
|
|
79
|
+
- `skills`, `extensions`, `mcps`, and `tools` reference installed resources by name or glob; profiles never copy resources. `tools` covers non-MCP tools only (built-ins and extension tools).
|
|
80
|
+
- `mcp_tools` selects tools inside MCP servers by literal server and tool name — globs are rejected. Omit a server to leave it unchanged, use `[]` to deny all of its tools while keeping the server enabled, or list names to allow only those. A literal selector that matches nothing stays restrictive without warning; a server that is unknown, disabled, or project-only fails activation with candidates.
|
|
81
|
+
- `mcps` names user-level servers from `~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, and `<agentDir>/mcp.json`. Omit it to leave all servers as configured; use `[]` to disable every user-level server. Project-level servers (`.pi/mcp.json`) are read by Pi itself and are never narrowed. Legacy SSE servers cannot be selected.
|
|
82
|
+
- Older profiles expressed MCP tool access through `mcp__*` or `<server>_*` entries in `tools`; use `mcp_tools` instead.
|
|
83
|
+
- Every omitted field keeps plain Pi behavior.
|
|
106
84
|
|
|
107
|
-
The
|
|
85
|
+
The instance's `mcp.json` is generated by the launcher. Running `pi mcp add` inside a session only edits that generated copy, and the next profile switch or reload overwrites it — edit your real MCP configuration instead.
|
|
108
86
|
|
|
109
|
-
|
|
87
|
+
[`examples/`](examples/) contains the full example above and the starter `ask`.
|
|
110
88
|
|
|
111
|
-
|
|
89
|
+
## Commands
|
|
112
90
|
|
|
113
91
|
| Command | What it does |
|
|
114
92
|
| --- | --- |
|
|
115
|
-
| `/profile` |
|
|
116
|
-
| `/profile use <name>`
|
|
117
|
-
| `/profile
|
|
118
|
-
| `/profile
|
|
119
|
-
| `/profile overlay
|
|
93
|
+
| `/profile` | Show the available profiles and pick one (interactive selector; prints the list outside the TUI). |
|
|
94
|
+
| `/profile use <name>` | Switch profiles now. The session reloads with the new resources; a failed switch rolls back. The choice is remembered for the next launch. |
|
|
95
|
+
| `/profile reload` | Re-read the active profile file after editing it. |
|
|
96
|
+
| `/profile status` | Report the active profile, resolved resources and paths, overlay, MCP server state, and conflicts. |
|
|
97
|
+
| `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | Narrow or restore resources for this session only. |
|
|
98
|
+
| `/profile overlay clear` | Drop the overlay and use the profile as written. |
|
|
120
99
|
|
|
121
|
-
All forms work in every mode, including non-interactive ones (`--mode rpc|
|
|
100
|
+
All forms work in every mode, including non-interactive ones (`--mode rpc|text|json`); the bare selector degrades to the profile list where no interactive UI exists. The overlay is a runtime-only narrowing: it is never written to a catalog file and never survives a restart. Tools follow the same disable/enable model as the other resource kinds: a tool `disable` entry narrows the profile's resolved tool references — or the runtime's full available tool set when the profile declares no `tools`.
|
|
101
|
+
All of these work in every mode, including non-interactive ones (`--mode text`, `--mode json`, `--mode rpc`). An overlay lives only in the current runtime: it is never written to your profile files and is gone after a restart. Disabling MCP servers with an overlay is not possible on the built-in `default` profile — it has no server list to narrow.
|
|
122
102
|
|
|
123
103
|
## Docs
|
|
124
104
|
|
|
125
|
-
- [
|
|
126
|
-
-
|
|
105
|
+
- Field reference: [`schemas/profiles.schema.json`](schemas/profiles.schema.json)
|
|
106
|
+
- Architecture and terminology: [`docs/architecture/overview.md`](docs/architecture/overview.md) and [`CONTEXT.md`](CONTEXT.md)
|
|
107
|
+
- Decisions: [`docs/adr/`](docs/adr/)
|
|
108
|
+
- Authoring guide: [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md)
|
|
127
109
|
|
|
128
110
|
## License
|
|
129
111
|
|
package/README.zh-CN.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
npm install -g pi-profile-switch
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
需要 [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) 0.99.1 或更新版本(作为 peer dependency 自动安装)。请用 `npm install -g` 安装,而不是 `pi install`:本包提供的是 `pi-profile` 启动器。
|
|
14
14
|
|
|
15
15
|
## 快速上手
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@ npm install -g pi-profile-switch
|
|
|
18
18
|
# 使用内建 default profile 启动(全量资源,等同原生 Pi)
|
|
19
19
|
pi-profile
|
|
20
20
|
|
|
21
|
-
#
|
|
21
|
+
# 使用初始的只读 ask profile 启动
|
|
22
22
|
pi-profile ask
|
|
23
23
|
|
|
24
24
|
# -- 后面的参数原样传给 pi
|
|
@@ -27,62 +27,28 @@ pi-profile ask -- --model openai/gpt-5.4
|
|
|
27
27
|
|
|
28
28
|
## 自定义 profile
|
|
29
29
|
|
|
30
|
-
profile 保存在两个目录中,每个 profile
|
|
30
|
+
profile 保存在两个目录中,每个 profile 对应一个 JSON 文件:
|
|
31
31
|
|
|
32
32
|
| 路径 | 作用域 |
|
|
33
33
|
| --- | --- |
|
|
34
34
|
| `~/.pi-profile-switch/profiles/<name>.json` | 全局,对所有项目生效。`PI_PROFILE_SWITCH_DIR` 可自定义根目录。 |
|
|
35
|
-
| `<项目>/.pi/profiles/<name>.json` |
|
|
35
|
+
| `<项目>/.pi/profiles/<name>.json` | 项目级,仅对已信任项目生效;同名时会完全替换全局 profile。 |
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
直接写 JSON 文件即可(schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)),也可以让 agent 帮你改:本包自带 [`profile-config`](skills/profile-config/SKILL.md) skill,能创建、修改和删除 profile。只要新建的 profile 声明了 `skills`,默认会包含 `"profile-config"`(除非你明确排除,或已被 `"*"` 之类的 glob 覆盖),这样切过去之后还能继续用对话调整。
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
全局 `profiles/` 目录里还没有 profile 时,pi-profile-switch 会写入一个初始 **`ask`** profile——只读问答与代码走读。它不假设你安装过任何插件,可随意修改或删除。见 [`examples/ask.json`](examples/ask.json)。
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```json
|
|
44
|
-
{
|
|
45
|
-
"label": "Ask & Discuss",
|
|
46
|
-
"description": "Read-only Q&A and code exploration; no file modifications or command execution",
|
|
47
|
-
"skills": [],
|
|
48
|
-
"extensions": [],
|
|
49
|
-
"tools": ["read", "grep", "find", "ls"],
|
|
50
|
-
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例(`impl.json`)加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;允许内建工具并按 server 细化 MCP 工具;同时钉住模型与常驻 instructions:
|
|
41
|
+
一个用到全部字段的 profile:
|
|
55
42
|
|
|
56
43
|
```json
|
|
57
44
|
{
|
|
58
45
|
"label": "Implementation",
|
|
59
46
|
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
60
|
-
"skills": [
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"mcp-scripting"
|
|
64
|
-
],
|
|
65
|
-
"extensions": [
|
|
66
|
-
"pi-mcp-adapter"
|
|
67
|
-
],
|
|
68
|
-
"mcps": [
|
|
69
|
-
"github",
|
|
70
|
-
"linear"
|
|
71
|
-
],
|
|
72
|
-
"tools": [
|
|
73
|
-
"read",
|
|
74
|
-
"grep",
|
|
75
|
-
"find",
|
|
76
|
-
"ls",
|
|
77
|
-
"bash",
|
|
78
|
-
"edit",
|
|
79
|
-
"write"
|
|
80
|
-
],
|
|
47
|
+
"skills": ["tdd", "internal-*"],
|
|
48
|
+
"mcps": ["github", "linear"],
|
|
49
|
+
"tools": ["read", "grep", "find", "ls", "bash", "edit", "write"],
|
|
81
50
|
"mcp_tools": {
|
|
82
|
-
"github": [
|
|
83
|
-
"search",
|
|
84
|
-
"get_issue"
|
|
85
|
-
],
|
|
51
|
+
"github": ["search", "get_issue"],
|
|
86
52
|
"linear": []
|
|
87
53
|
},
|
|
88
54
|
"defaultProvider": "anthropic",
|
|
@@ -92,41 +58,39 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
92
58
|
}
|
|
93
59
|
```
|
|
94
60
|
|
|
95
|
-
|
|
61
|
+
字段行为:
|
|
96
62
|
|
|
97
|
-
- `
|
|
98
|
-
- `tools`
|
|
99
|
-
- `mcp_tools`
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
- **省略 `mcps`**:保留所有已发现用户级 server 的原生可用性。
|
|
104
|
-
- **`mcps: []` 且选中了 `pi-mcp-adapter`**:禁用所有已发现用户级 server(共享位置的 server 会被显式标记为 `disabled`;仅位于 agentDir 的 server 会从生成的实例 `mcp.json` 中省略)。
|
|
105
|
-
- **`mcps: []` 但未选中 `pi-mcp-adapter`**:该声明不生效,不会读取 MCP 配置、改变 server 可用性,也不会因配置文件损坏而报错。
|
|
106
|
-
- 受信任的项目级 MCP server 始终保持启用,不受 `mcps` 收窄。
|
|
107
|
-
- 未写的字段保持原生 Pi 行为。
|
|
63
|
+
- `label`、`description` 仅用于显示。`defaultProvider` 与 `defaultModel` 需同时声明,用于指定启动模型;`defaultThinkingLevel` 指定思考等级;`instructions` 会追加到系统提示词。
|
|
64
|
+
- `skills`、`extensions`、`mcps`、`tools` 以名称或 glob 引用已安装的资源;profile 不会复制资源。`tools` 只涉及非 MCP 工具(内置工具和扩展工具)。
|
|
65
|
+
- `mcp_tools` 按字面 server 名和工具名选择 MCP 工具,不接受 glob。省略某个 server 表示保持原样,写 `[]` 表示禁用它的全部工具但保留 server 本身,列出名称表示只允许这些工具。字面选择器匹配不到内容时仍保持限制且不报错;server 未知、已禁用或只存在于项目级配置时,激活会失败并给出候选。
|
|
66
|
+
- `mcps` 列出用户级 server,可来自 `~/.config/mcp/mcp.json`、`~/.agents/mcp.json`、`~/.agents/mcp/mcp.json` 和 `<agentDir>/mcp.json`。省略表示全部保持原样;写 `[]` 表示禁用所有用户级 server。项目级 server(`.pi/mcp.json`)由 Pi 自己读取,profile 不会收窄。旧的 SSE server 无法选择。
|
|
67
|
+
- 旧版 profile 用 `tools` 里的 `mcp__*`、`<server>_*` 表达 MCP 工具访问;现在请改用 `mcp_tools`。
|
|
68
|
+
- 未声明的字段完全保持原生 Pi 行为。
|
|
108
69
|
|
|
109
|
-
|
|
70
|
+
实例目录里的 `mcp.json` 由启动器生成。在会话里执行 `pi mcp add` 只会改到这份生成副本,下次切换 profile 或重载时会被覆盖——要永久生效,请直接修改你自己的 MCP 配置。
|
|
110
71
|
|
|
111
|
-
|
|
72
|
+
[`examples/`](examples/) 里有上面的完整示例和初始 `ask`。
|
|
112
73
|
|
|
113
|
-
|
|
74
|
+
## 命令
|
|
114
75
|
|
|
115
76
|
| 命令 | 作用 |
|
|
116
77
|
| --- | --- |
|
|
117
|
-
| `/profile` |
|
|
118
|
-
| `/profile use <name>`
|
|
119
|
-
| `/profile
|
|
120
|
-
| `/profile
|
|
121
|
-
| `/profile overlay
|
|
78
|
+
| `/profile` | 显示可选 profile 并选择(TUI 里是交互式选择器,非 TUI 下打印列表)。 |
|
|
79
|
+
| `/profile use <name>` | 立即切换 profile。会话会用新资源重载;切换失败会回滚。选择会被记住,下次启动继续使用。 |
|
|
80
|
+
| `/profile reload` | 修改 profile 文件后重新读取。 |
|
|
81
|
+
| `/profile status` | 显示当前 profile、解析出的资源与路径、overlay、MCP server 状态以及冲突。 |
|
|
82
|
+
| `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | 只对当前会话收窄或恢复资源。 |
|
|
83
|
+
| `/profile overlay clear` | 去掉 overlay,按 profile 本来的写法运行。 |
|
|
122
84
|
|
|
123
|
-
|
|
85
|
+
以上命令在所有模式下都可用,包括非交互模式(`--mode text`、`--mode json`、`--mode rpc`)。overlay 只作用于当前运行期:不会写入 profile 文件,重启后即消失。内建的 `default` profile 不能用 overlay 禁用 MCP server——它没有可供收窄的 MCP 白名单。
|
|
124
86
|
|
|
125
87
|
## 文档
|
|
126
88
|
|
|
127
|
-
- [
|
|
128
|
-
-
|
|
89
|
+
- 字段参考:[`schemas/profiles.schema.json`](schemas/profiles.schema.json)
|
|
90
|
+
- 架构与术语:[`docs/architecture/overview.md`](docs/architecture/overview.md)、[`CONTEXT.md`](CONTEXT.md)
|
|
91
|
+
- 设计决策:[`docs/adr/`](docs/adr/)
|
|
92
|
+
- Profile 编写指南:[`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md)
|
|
129
93
|
|
|
130
|
-
##
|
|
94
|
+
## License
|
|
131
95
|
|
|
132
96
|
MIT
|
package/bin/pi-profile.ts
CHANGED
|
@@ -67,6 +67,9 @@ try {
|
|
|
67
67
|
console.error(`pi-profile: warning: ${warning}`);
|
|
68
68
|
}
|
|
69
69
|
const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectDir });
|
|
70
|
+
for (const warning of generated.warnings) {
|
|
71
|
+
console.error(`pi-profile: warning: ${warning}`);
|
|
72
|
+
}
|
|
70
73
|
process.exitCode = await spawnPi({
|
|
71
74
|
generated,
|
|
72
75
|
piArgs: args.piArgs,
|
|
@@ -224,7 +224,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
224
224
|
const mcpDiscovery = await loadMergedMcpServers(
|
|
225
225
|
plan.agentDir,
|
|
226
226
|
projectTrusted ? ctx.cwd : undefined,
|
|
227
|
+
{
|
|
228
|
+
invalidSource:
|
|
229
|
+
plan.mcps !== undefined ||
|
|
230
|
+
(plan.mcpTools !== undefined && Object.keys(plan.mcpTools).length > 0)
|
|
231
|
+
? "throw"
|
|
232
|
+
: "diagnose",
|
|
233
|
+
},
|
|
227
234
|
);
|
|
235
|
+
for (const diagnostic of mcpDiscovery.diagnostics ?? []) {
|
|
236
|
+
notify(diagnostic, "warning");
|
|
237
|
+
}
|
|
228
238
|
const discoveredMcpServers = Object.keys(mcpDiscovery.servers).sort();
|
|
229
239
|
const disabledMcpServers = discoveredMcpServers.filter(
|
|
230
240
|
(server) => mcpDiscovery.servers[server]?.enabled === false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"test:watch": "vitest"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "
|
|
33
|
+
"@earendil-works/pi-coding-agent": ">=0.99.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@earendil-works/pi-coding-agent": "
|
|
36
|
+
"@earendil-works/pi-coding-agent": ">=0.99.1",
|
|
37
37
|
"@types/node": "^24.0.0",
|
|
38
38
|
"ajv": "^8.20.0",
|
|
39
39
|
"typescript": "^5.8.0",
|
|
@@ -169,7 +169,7 @@ export async function resolveInitialProfile(
|
|
|
169
169
|
(options?.overlay?.disabledMcps?.length ?? 0) > 0,
|
|
170
170
|
);
|
|
171
171
|
const mcpDiscovery = needsMcp
|
|
172
|
-
? await loadMergedMcpServers(context.agentDir, projectDir)
|
|
172
|
+
? await loadMergedMcpServers(context.agentDir, projectDir, { invalidSource: "throw" })
|
|
173
173
|
: undefined;
|
|
174
174
|
|
|
175
175
|
const plan = await resolveProfile({
|
package/src/launcher/spawn.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The spawned pi gets: the pi-profile extension via `-e` and the user's
|
|
5
5
|
* arguments verbatim. stdio is inherited so interactive
|
|
6
|
-
* TUI,
|
|
7
|
-
* propagate.
|
|
6
|
+
* TUI, text, JSON, and RPC modes all behave natively; exit codes and
|
|
7
|
+
* signals propagate.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
package/src/mcp-config.ts
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* (read for ownership classification only — Pi reads the file itself).
|
|
14
14
|
* The legacy project-root `.mcp.json` source is not read.
|
|
15
15
|
*
|
|
16
|
-
* Malformed config files
|
|
17
|
-
* read as "no servers"
|
|
16
|
+
* Malformed config files follow the discovery tier: strict mode throws
|
|
17
|
+
* `McpConfigError` (a broken mcp.json must not silently read as "no servers"
|
|
18
|
+
* under an explicit MCP policy); diagnostic mode skips the malformed source
|
|
19
|
+
* with a path-bearing diagnostic so valid sources still merge.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { homedir } from "node:os";
|
|
@@ -34,6 +36,10 @@ export class McpConfigError extends Error {
|
|
|
34
36
|
|
|
35
37
|
export interface McpDiscoveryOptions {
|
|
36
38
|
homeDir?: string;
|
|
39
|
+
/** How an unreadable or malformed source is handled. "throw" (default)
|
|
40
|
+
* fails discovery with `McpConfigError`; "diagnose" skips the source and
|
|
41
|
+
* records a path-bearing diagnostic so valid sources still merge. */
|
|
42
|
+
invalidSource?: "throw" | "diagnose";
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
export interface MergedMcpResult {
|
|
@@ -48,6 +54,10 @@ export interface MergedMcpResult {
|
|
|
48
54
|
/** The merged user-level configuration object, with later sources
|
|
49
55
|
* overriding earlier ones per server name. */
|
|
50
56
|
baseConfig?: Record<string, unknown>;
|
|
57
|
+
/** Path-bearing diagnostics for sources skipped in "diagnose" mode.
|
|
58
|
+
* Always populated by `loadMergedMcpServers`; absent from hand-built
|
|
59
|
+
* discovery fixtures. */
|
|
60
|
+
diagnostics?: string[];
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
|
|
@@ -98,14 +108,19 @@ export async function loadMergedMcpServers(
|
|
|
98
108
|
projectDir?: string,
|
|
99
109
|
options?: McpDiscoveryOptions,
|
|
100
110
|
): Promise<MergedMcpResult> {
|
|
111
|
+
const invalidSource = options?.invalidSource ?? "throw";
|
|
101
112
|
const sources = getStandardMcpConfigSources(agentDir, projectDir, options);
|
|
102
113
|
const seenPaths = new Set<string>();
|
|
103
114
|
const servers: Record<string, Record<string, unknown>> = {};
|
|
104
115
|
const sharedServers = new Set<string>();
|
|
105
116
|
const projectServers = new Set<string>();
|
|
106
117
|
const serverOwners: Record<string, "user" | "project"> = {};
|
|
118
|
+
const diagnostics: string[] = [];
|
|
107
119
|
let baseConfig: Record<string, unknown> | undefined;
|
|
108
120
|
|
|
121
|
+
/** Strict mode fails discovery; diagnostic mode records the path and
|
|
122
|
+
* skips only that source, leaving valid sources available. */
|
|
123
|
+
|
|
109
124
|
for (const source of sources) {
|
|
110
125
|
const resolvedPath = path.resolve(source.path);
|
|
111
126
|
if (seenPaths.has(resolvedPath)) continue;
|
|
@@ -114,11 +129,26 @@ export async function loadMergedMcpServers(
|
|
|
114
129
|
const result = await readJsonFile(resolvedPath);
|
|
115
130
|
if (!result.ok) {
|
|
116
131
|
if (result.reason === "missing") continue;
|
|
132
|
+
if (invalidSource === "diagnose") {
|
|
133
|
+
diagnostics.push(`MCP config is not valid JSON: ${resolvedPath}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
117
136
|
throw new McpConfigError(`MCP config is not valid JSON: ${resolvedPath}`, resolvedPath);
|
|
118
137
|
}
|
|
119
138
|
if (!isRecord(result.value)) {
|
|
139
|
+
if (invalidSource === "diagnose") {
|
|
140
|
+
diagnostics.push(`MCP config must be a JSON object: ${resolvedPath}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
120
143
|
throw new McpConfigError(`MCP config must be a JSON object: ${resolvedPath}`, resolvedPath);
|
|
121
144
|
}
|
|
145
|
+
if (result.value.mcpServers !== undefined && !isRecord(result.value.mcpServers)) {
|
|
146
|
+
if (invalidSource === "diagnose") {
|
|
147
|
+
diagnostics.push(`"mcpServers" must be a JSON object: ${resolvedPath}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
|
|
151
|
+
}
|
|
122
152
|
|
|
123
153
|
if (!source.isProject) {
|
|
124
154
|
// Merge user-level base configuration: later sources override
|
|
@@ -128,9 +158,6 @@ export async function loadMergedMcpServers(
|
|
|
128
158
|
}
|
|
129
159
|
|
|
130
160
|
if (result.value.mcpServers === undefined) continue;
|
|
131
|
-
if (!isRecord(result.value.mcpServers)) {
|
|
132
|
-
throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
|
|
133
|
-
}
|
|
134
161
|
for (const [name, def] of Object.entries(result.value.mcpServers)) {
|
|
135
162
|
// Align discovery with JSON semantics: inherited prototype keys are
|
|
136
163
|
// never treated as discoverable server names.
|
|
@@ -140,11 +167,13 @@ export async function loadMergedMcpServers(
|
|
|
140
167
|
}
|
|
141
168
|
if (source.isProject === true) projectServers.add(name);
|
|
142
169
|
setOwnRecordValue(serverOwners, name, source.isProject === true ? "project" : "user");
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
170
|
+
// Whole-definition precedence: a later definition replaces the same-named
|
|
171
|
+
// server entirely. Connection, credential, and exposure fields are never
|
|
172
|
+
// inherited from an earlier source, so a later URL cannot pick up an
|
|
173
|
+
// earlier authorization header (D1).
|
|
174
|
+
setOwnRecordValue(servers, name, isRecord(def) ? { ...def } : {});
|
|
146
175
|
}
|
|
147
176
|
}
|
|
148
177
|
|
|
149
|
-
return { servers, sharedServers, projectServers, serverOwners, baseConfig };
|
|
178
|
+
return { servers, sharedServers, projectServers, serverOwners, baseConfig, diagnostics };
|
|
150
179
|
}
|
package/src/profile-resolver.ts
CHANGED
|
@@ -48,11 +48,27 @@ export class ActivationError extends Error {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Pi's built-in tool names (pi 0.
|
|
51
|
+
/** Pi's built-in tool names (pi 0.99.2 `allToolNames`; not exported by the
|
|
52
52
|
* SDK). The integration suite guards drift. Literal tool names pass through
|
|
53
53
|
* regardless — extension-provided tools are unknowable before spawn. */
|
|
54
54
|
export const BUILTIN_TOOL_NAMES = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"] as const;
|
|
55
55
|
|
|
56
|
+
/** User-level, enabled MCP server names, sorted — the set a profile can
|
|
57
|
+
* actually select via `mcps` or narrow via `mcp_tools`. Project-owned and
|
|
58
|
+
* source-disabled servers are not usable candidates. */
|
|
59
|
+
function userLevelMcpCandidates(mcpDiscovery: MergedMcpResult): string[] {
|
|
60
|
+
return Object.keys(mcpDiscovery.servers)
|
|
61
|
+
.filter((s) => {
|
|
62
|
+
const isUser =
|
|
63
|
+
Object.hasOwn(mcpDiscovery.serverOwners, s) &&
|
|
64
|
+
mcpDiscovery.serverOwners[s] === "user" &&
|
|
65
|
+
!mcpDiscovery.projectServers.has(s);
|
|
66
|
+
const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
|
|
67
|
+
return isUser && isEnabled;
|
|
68
|
+
})
|
|
69
|
+
.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
const VALID_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
57
73
|
|
|
58
74
|
/** Immutable, fully resolved activation set. */
|
|
@@ -182,7 +198,12 @@ function expandReferences<T>(
|
|
|
182
198
|
universe: readonly T[],
|
|
183
199
|
nameOf: (item: T) => string,
|
|
184
200
|
kind: string,
|
|
185
|
-
options?: {
|
|
201
|
+
options?: {
|
|
202
|
+
literalMustExist?: boolean;
|
|
203
|
+
onZeroMatch?: (reference: string) => void;
|
|
204
|
+
/** Custom literal-miss failure (e.g. with near-miss candidates). Throws. */
|
|
205
|
+
literalMissError?: (reference: string) => never;
|
|
206
|
+
},
|
|
186
207
|
): T[] {
|
|
187
208
|
const selected = new Map<string, T>();
|
|
188
209
|
for (const reference of references) {
|
|
@@ -204,6 +225,10 @@ function expandReferences<T>(
|
|
|
204
225
|
selected.set(reference, reference as T);
|
|
205
226
|
continue;
|
|
206
227
|
}
|
|
228
|
+
const missError = options?.literalMissError;
|
|
229
|
+
if (missError !== undefined) {
|
|
230
|
+
missError(reference);
|
|
231
|
+
}
|
|
207
232
|
throw new ActivationError(`unknown ${kind}: "${reference}" does not match any discovered ${kind}`);
|
|
208
233
|
}
|
|
209
234
|
selected.set(reference, item);
|
|
@@ -250,6 +275,17 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
250
275
|
}
|
|
251
276
|
mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
|
|
252
277
|
onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
|
|
278
|
+
literalMissError: (reference) => {
|
|
279
|
+
const candidates =
|
|
280
|
+
input.mcpDiscovery !== undefined
|
|
281
|
+
? userLevelMcpCandidates(input.mcpDiscovery)
|
|
282
|
+
: [...(input.discoveredMcpServers ?? [])].sort();
|
|
283
|
+
const suffix =
|
|
284
|
+
candidates.length > 0
|
|
285
|
+
? ` (usable candidates: ${candidates.join(", ")})`
|
|
286
|
+
: " (no user-level servers are discovered)";
|
|
287
|
+
throw new ActivationError(`unknown MCP server: "${reference}"${suffix}`);
|
|
288
|
+
},
|
|
253
289
|
});
|
|
254
290
|
if (input.mcpDiscovery !== undefined) {
|
|
255
291
|
const discovery = input.mcpDiscovery;
|
|
@@ -327,17 +363,9 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
327
363
|
}
|
|
328
364
|
|
|
329
365
|
const mcpDiscovery = input.mcpDiscovery;
|
|
330
|
-
const usableCandidates =
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
Object.hasOwn(mcpDiscovery.serverOwners, s) &&
|
|
334
|
-
mcpDiscovery.serverOwners[s] === "user" &&
|
|
335
|
-
!mcpDiscovery.projectServers.has(s);
|
|
336
|
-
const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
|
|
337
|
-
const isAllowedByMcps = mcps === undefined || mcps.includes(s);
|
|
338
|
-
return isUser && isEnabled && isAllowedByMcps;
|
|
339
|
-
})
|
|
340
|
-
.sort();
|
|
366
|
+
const usableCandidates = userLevelMcpCandidates(mcpDiscovery).filter(
|
|
367
|
+
(s) => mcps === undefined || mcps.includes(s),
|
|
368
|
+
);
|
|
341
369
|
const candidateMsg = usableCandidates.length > 0 ? ` (usable candidates: ${usableCandidates.join(", ")})` : "";
|
|
342
370
|
|
|
343
371
|
for (const serverKey of mcpToolKeys) {
|
|
@@ -479,6 +507,14 @@ export function buildInstanceMcpConfig(
|
|
|
479
507
|
mcpDiscovery.serverOwners[serverName] === "project" || mcpDiscovery.projectServers.has(serverName);
|
|
480
508
|
if (isProjectOwned) continue;
|
|
481
509
|
|
|
510
|
+
// A selected server the winning source explicitly disables fails with a
|
|
511
|
+
// fix, never a silent enablement override (D1).
|
|
512
|
+
if (mcps?.includes(serverName) === true && originalDef.enabled === false) {
|
|
513
|
+
throw new ActivationError(
|
|
514
|
+
`profile "${profileName}": selected MCP server "${serverName}" is disabled in its source configuration; enable it there or remove it from "mcps"`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
482
518
|
// A server explicitly selected by mcps whose definition uses a
|
|
483
519
|
// transport Pi's built-in MCP extension cannot use fails activation.
|
|
484
520
|
if (mcps?.includes(serverName) === true && originalDef.type === "sse") {
|
|
@@ -502,11 +538,14 @@ export function buildInstanceMcpConfig(
|
|
|
502
538
|
setOwnRecordValue(filteredServers, serverName, def);
|
|
503
539
|
}
|
|
504
540
|
|
|
505
|
-
// Unselected user-level servers
|
|
541
|
+
// Unselected user-level servers keep their complete winning definition and
|
|
542
|
+
// are explicitly disabled. Pi validates the transport before `enabled`, so
|
|
543
|
+
// a transport-less placeholder would warn on every valid disabled server.
|
|
506
544
|
if (mcps !== undefined) {
|
|
507
545
|
for (const userName of userServers) {
|
|
508
546
|
if (mcps.includes(userName)) continue;
|
|
509
|
-
|
|
547
|
+
const unselectedDef = mcpDiscovery.servers[userName] ?? {};
|
|
548
|
+
setOwnRecordValue(filteredServers, userName, { ...unselectedDef, enabled: false });
|
|
510
549
|
}
|
|
511
550
|
}
|
|
512
551
|
|
|
@@ -48,7 +48,7 @@ import path from "node:path";
|
|
|
48
48
|
import { buildInstanceMcpConfig, type ActivationPlan } from "./profile-resolver.ts";
|
|
49
49
|
import { getInstancesRootDir } from "./workspace.ts";
|
|
50
50
|
import { isRecord } from "./json-file.ts";
|
|
51
|
-
import { loadMergedMcpServers } from "./mcp-config.ts";
|
|
51
|
+
import { loadMergedMcpServers, type MergedMcpResult } from "./mcp-config.ts";
|
|
52
52
|
import type { SkillEntry } from "./skill-registry.ts";
|
|
53
53
|
|
|
54
54
|
/** A configured global package and its resolved install/local root. */
|
|
@@ -83,6 +83,9 @@ export interface GeneratedRuntime {
|
|
|
83
83
|
runtimeDir: string;
|
|
84
84
|
/** Environment variables for the spawned pi process. */
|
|
85
85
|
env: Record<string, string>;
|
|
86
|
+
/** Non-fatal diagnostics (e.g. malformed MCP sources skipped under an
|
|
87
|
+
* undeclared MCP policy). The launcher prints them on stderr. */
|
|
88
|
+
warnings: string[];
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
/** Files managed explicitly by pi-profile in runtimeDir; excluded from auto-symlinking. */
|
|
@@ -193,6 +196,32 @@ function userSkillExclusions(userSkills: unknown, agentDir: string, runtimeDir:
|
|
|
193
196
|
return exclusions;
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/** Pi's built-in MCP discovery entry points (see installed Pi
|
|
200
|
+
* `dist/extensions/mcp/index.js`, `codemode/tool.js`, `tool-search/tool.js`).
|
|
201
|
+
* These are the tools a narrowed `tools` profile must keep reachable when an
|
|
202
|
+
* MCP server is enabled; their identities are Pi's, not a profile field. */
|
|
203
|
+
const MCP_GATEWAY_TOOL_NAMES = ["codemode", "tool_search"] as const;
|
|
204
|
+
|
|
205
|
+
/** Whether the prepared effective MCP set contains at least one enabled
|
|
206
|
+
* server (user-level snapshot entries with `enabled !== false`, plus any
|
|
207
|
+
* enabled trusted-project server Pi reads itself). */
|
|
208
|
+
function hasEnabledMcpServer(
|
|
209
|
+
instanceMcpConfig: Record<string, unknown>,
|
|
210
|
+
discovery: MergedMcpResult,
|
|
211
|
+
): boolean {
|
|
212
|
+
const snapshotServers = instanceMcpConfig.mcpServers;
|
|
213
|
+
if (isRecord(snapshotServers)) {
|
|
214
|
+
for (const def of Object.values(snapshotServers)) {
|
|
215
|
+
if (isRecord(def) && def.enabled !== false) return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const name of discovery.projectServers) {
|
|
219
|
+
const def = discovery.servers[name];
|
|
220
|
+
if (def !== undefined && def.enabled !== false) return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
196
225
|
function buildSelectionSettings(
|
|
197
226
|
plan: ActivationPlan,
|
|
198
227
|
userSettings: Record<string, unknown>,
|
|
@@ -405,14 +434,47 @@ export interface ResolvedNames {
|
|
|
405
434
|
/** Writes settings.json + pi-profile.json into an existing runtime dir and
|
|
406
435
|
* keeps the trust.json link in place for every profile: Pi reads its
|
|
407
436
|
* project-scope decision from that path, and project-level resources belong
|
|
408
|
-
* to Pi's trust gate rather than to the profile.
|
|
437
|
+
* to Pi's trust gate rather than to the profile.
|
|
438
|
+
*
|
|
439
|
+
* All generated content (settings, launch plan, MCP snapshot, diagnostics)
|
|
440
|
+
* is prepared in memory before any runtime file is touched, so a discovery
|
|
441
|
+
* failure can never leave a half-written runtime dir (D3). */
|
|
409
442
|
export async function writeRuntimeFiles(
|
|
410
443
|
runtimeDir: string,
|
|
411
444
|
plan: ActivationPlan,
|
|
412
445
|
options: RuntimeFileOptions,
|
|
413
|
-
): Promise<
|
|
446
|
+
): Promise<{ warnings: string[] }> {
|
|
447
|
+
const warnings: string[] = [];
|
|
414
448
|
const settings = await computeSettings(plan, options, runtimeDir);
|
|
415
|
-
|
|
449
|
+
|
|
450
|
+
// Prepare the MCP snapshot and diagnostics before the write stage. A
|
|
451
|
+
// declared mcps or nonempty mcp_tools policy is strict; an undeclared
|
|
452
|
+
// policy diagnoses malformed sources by path and keeps valid ones (D2).
|
|
453
|
+
const hasMcpPolicy =
|
|
454
|
+
plan.mcps !== undefined || (plan.mcpTools !== undefined && Object.keys(plan.mcpTools).length > 0);
|
|
455
|
+
const discovery = await loadMergedMcpServers(
|
|
456
|
+
options.agentDir,
|
|
457
|
+
options.projectDir,
|
|
458
|
+
{
|
|
459
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
460
|
+
invalidSource: hasMcpPolicy ? "throw" : "diagnose",
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
warnings.push(...(discovery.diagnostics ?? []));
|
|
464
|
+
const instanceMcpConfig = buildInstanceMcpConfig(plan.profile, discovery, plan.mcps, plan.mcpTools);
|
|
465
|
+
|
|
466
|
+
// When a profile narrows `tools` and the effective MCP set still has an
|
|
467
|
+
// enabled server, keep Pi's native MCP discovery entry points reachable
|
|
468
|
+
// (D4). The marker drives the session-start preservation; the
|
|
469
|
+
// defaultTools baseline covers the boot window before session_start.
|
|
470
|
+
const mcpGateways = plan.tools !== undefined && hasEnabledMcpServer(instanceMcpConfig, discovery);
|
|
471
|
+
if (mcpGateways) {
|
|
472
|
+
const current = Array.isArray(settings.defaultTools) ? (settings.defaultTools as string[]) : [];
|
|
473
|
+
settings.defaultTools = [
|
|
474
|
+
...current,
|
|
475
|
+
...MCP_GATEWAY_TOOL_NAMES.filter((name) => !current.includes(name)),
|
|
476
|
+
];
|
|
477
|
+
}
|
|
416
478
|
|
|
417
479
|
// The launch plan feeds the in-pi extension: tool re-application after
|
|
418
480
|
// reload (the tools strict allowlist), in-session switching, status
|
|
@@ -420,33 +482,31 @@ export async function writeRuntimeFiles(
|
|
|
420
482
|
// agentDir is the REAL agent dir — the extension needs it for trust
|
|
421
483
|
// checks, state files, and catalog reads (its own
|
|
422
484
|
// PI_CODING_AGENT_DIR points at this runtime dir).
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)}\n`,
|
|
449
|
-
);
|
|
485
|
+
const launchPlan = {
|
|
486
|
+
profile: plan.profile,
|
|
487
|
+
source: plan.source,
|
|
488
|
+
agentDir: options.agentDir,
|
|
489
|
+
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
490
|
+
...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
|
|
491
|
+
...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
|
|
492
|
+
...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
|
|
493
|
+
...(plan.mcpTools !== undefined ? { mcpTools: plan.mcpTools } : {}),
|
|
494
|
+
...(mcpGateways ? { mcpGateways: true } : {}),
|
|
495
|
+
// The resolved sets feed /profile status (absolute paths) and the
|
|
496
|
+
// glob-delta diff against the previous activation.
|
|
497
|
+
resolved: {
|
|
498
|
+
skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
|
|
499
|
+
extensions: plan.extensions,
|
|
500
|
+
},
|
|
501
|
+
// Zero-match glob references (ADR-0009) — surfaced by /profile status
|
|
502
|
+
// so a typo'd glob is visible instead of silently selecting nothing.
|
|
503
|
+
...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
|
|
504
|
+
...options.planExtras,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// --- write stage: all content is ready; no reads re-run here. ---
|
|
508
|
+
await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
|
|
509
|
+
await writeFile(path.join(runtimeDir, "pi-profile.json"), `${JSON.stringify(launchPlan, null, 2)}\n`);
|
|
450
510
|
|
|
451
511
|
// Every profile gets the link, dangling allowed: Pi's stored trust decision
|
|
452
512
|
// is what makes a trusted project's resources visible, and a decision Pi
|
|
@@ -460,20 +520,11 @@ export async function writeRuntimeFiles(
|
|
|
460
520
|
|
|
461
521
|
// MCP Servers generation: the instance mcp.json is always a generated
|
|
462
522
|
// snapshot of the merged user-level configuration; it is never a symlink
|
|
463
|
-
// or a copy of the real agentDir file (ADR-0016).
|
|
523
|
+
// or a copy of the real agentDir file (ADR-0016). The replacement content
|
|
524
|
+
// was prepared above, so the old file is removed only once its successor
|
|
525
|
+
// is ready to write.
|
|
464
526
|
const mcpInstancePath = path.join(runtimeDir, "mcp.json");
|
|
465
527
|
try { await rm(mcpInstancePath); } catch {}
|
|
466
|
-
const discovery = await loadMergedMcpServers(
|
|
467
|
-
options.agentDir,
|
|
468
|
-
options.projectDir,
|
|
469
|
-
options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
|
|
470
|
-
);
|
|
471
|
-
const instanceMcpConfig = buildInstanceMcpConfig(
|
|
472
|
-
plan.profile,
|
|
473
|
-
discovery,
|
|
474
|
-
plan.mcps,
|
|
475
|
-
plan.mcpTools,
|
|
476
|
-
);
|
|
477
528
|
await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
|
|
478
529
|
|
|
479
530
|
// Instructions generation (Ticket 04)
|
|
@@ -486,6 +537,8 @@ export async function writeRuntimeFiles(
|
|
|
486
537
|
|
|
487
538
|
// Full-fidelity symlink mirroring and dangling link cleanup (Ticket 02).
|
|
488
539
|
await syncAgentSymlinks(options.agentDir, runtimeDir);
|
|
540
|
+
|
|
541
|
+
return { warnings };
|
|
489
542
|
}
|
|
490
543
|
|
|
491
544
|
/**
|
|
@@ -595,12 +648,13 @@ export async function generateRuntimeDir(
|
|
|
595
648
|
// their switches) rewrite each other's files (ADR-0010).
|
|
596
649
|
const runtimeDir = await mkdtemp(path.join(runtimeRoot, "launch-"));
|
|
597
650
|
|
|
598
|
-
await writeRuntimeFiles(runtimeDir, plan, options);
|
|
651
|
+
const { warnings } = await writeRuntimeFiles(runtimeDir, plan, options);
|
|
599
652
|
|
|
600
653
|
return {
|
|
601
654
|
runtimeDir,
|
|
602
655
|
env: {
|
|
603
656
|
PI_CODING_AGENT_DIR: runtimeDir,
|
|
604
657
|
},
|
|
658
|
+
warnings,
|
|
605
659
|
};
|
|
606
660
|
}
|
package/src/startup-notifier.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two independent sources, checked once per Pi process launch and presented
|
|
5
5
|
* through a caller-supplied NoticeSurface:
|
|
6
6
|
*
|
|
7
|
-
* - npm
|
|
7
|
+
* - npm dist-tags: compare the running package version against the
|
|
8
8
|
* registry's installable `latest` dist-tag and remind once per target
|
|
9
9
|
* version (design decision 2: authority and cache).
|
|
10
10
|
* - a single maintainer-reviewed `announcements.json` feed (design decision
|
|
@@ -30,9 +30,9 @@ import { isRecord } from "./json-file.ts";
|
|
|
30
30
|
export const ANNOUNCEMENTS_URL =
|
|
31
31
|
"https://raw.githubusercontent.com/VincentFF/pi-profile-switch/main/announcements.json";
|
|
32
32
|
|
|
33
|
-
/** npm registry
|
|
34
|
-
*
|
|
35
|
-
const
|
|
33
|
+
/** npm registry dist-tags; the installable stable version is read from
|
|
34
|
+
* top-level `latest`, never from the largest published version. */
|
|
35
|
+
const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/pi-profile-switch/dist-tags";
|
|
36
36
|
|
|
37
37
|
/** Hard ceiling on one remote check; the timer is unref'd so a pending
|
|
38
38
|
* check never keeps a short-lived Pi process alive. */
|
|
@@ -261,10 +261,10 @@ function parseNpmLatest(text: string): string {
|
|
|
261
261
|
} catch {
|
|
262
262
|
throw new InvalidContentError("response is not valid JSON");
|
|
263
263
|
}
|
|
264
|
-
if (!isRecord(raw) ||
|
|
265
|
-
throw new InvalidContentError("missing
|
|
264
|
+
if (!isRecord(raw) || typeof raw.latest !== "string") {
|
|
265
|
+
throw new InvalidContentError("missing latest");
|
|
266
266
|
}
|
|
267
|
-
const latest = raw
|
|
267
|
+
const latest = raw.latest;
|
|
268
268
|
if (parseVersion(latest) === undefined) {
|
|
269
269
|
throw new InvalidContentError(`malformed latest version: ${JSON.stringify(latest)}`);
|
|
270
270
|
}
|
|
@@ -574,7 +574,7 @@ async function run(options: StartupNotifierOptions): Promise<void> {
|
|
|
574
574
|
const refreshedNpm = await refreshSource<{ latest: string }>({
|
|
575
575
|
dir,
|
|
576
576
|
name: "npm-latest",
|
|
577
|
-
url:
|
|
577
|
+
url: NPM_DIST_TAGS_URL,
|
|
578
578
|
label: "npm registry",
|
|
579
579
|
cache: npmCache,
|
|
580
580
|
parse: (text) => ({ latest: parseNpmLatest(text) }),
|
|
@@ -49,6 +49,11 @@ export interface LaunchPlanFile {
|
|
|
49
49
|
disabledTools?: string[];
|
|
50
50
|
mcps?: string[];
|
|
51
51
|
mcpTools?: Record<string, string[]>;
|
|
52
|
+
/** Marks a narrowed `tools` profile whose effective MCP set still has an
|
|
53
|
+
* enabled server; session start must retain Pi's native MCP discovery
|
|
54
|
+
* entry points (codemode / tool_search) if they were natively
|
|
55
|
+
* registered (D4). */
|
|
56
|
+
mcpGateways?: boolean;
|
|
52
57
|
switchedFrom?: string;
|
|
53
58
|
persistSelection?: boolean;
|
|
54
59
|
clearOverlay?: boolean;
|
|
@@ -99,6 +104,14 @@ export function isMcpOwnedTool(tool: {
|
|
|
99
104
|
return info.path === "builtin:mcp";
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
/** Pi's native MCP discovery entry points and their built-in source paths
|
|
108
|
+
* (see installed Pi `dist/extensions/index.js`). A same-named tool from any
|
|
109
|
+
* other source is never treated as an MCP gateway. */
|
|
110
|
+
const MCP_GATEWAYS = [
|
|
111
|
+
{ name: "codemode", sourcePath: "builtin:codemode" },
|
|
112
|
+
{ name: "tool_search", sourcePath: "builtin:tool-search" },
|
|
113
|
+
] as const;
|
|
114
|
+
|
|
102
115
|
/** Applies the plan carried by the runtime dir's pi-profile.json. */
|
|
103
116
|
export async function applyLaunchPlan(input: {
|
|
104
117
|
runtimeDir: string;
|
|
@@ -116,7 +129,7 @@ export async function applyLaunchPlan(input: {
|
|
|
116
129
|
|
|
117
130
|
// --- tools ---
|
|
118
131
|
const hasOverlayDisables = plan.disabledTools !== undefined && plan.disabledTools.length > 0;
|
|
119
|
-
if (plan.toolReferences !== undefined || hasOverlayDisables) {
|
|
132
|
+
if (plan.toolReferences !== undefined || hasOverlayDisables || plan.mcpGateways === true) {
|
|
120
133
|
const allTools = surface.getAllTools();
|
|
121
134
|
const mcpToolNames: string[] = [];
|
|
122
135
|
const nonMcpToolNames: string[] = [];
|
|
@@ -155,6 +168,7 @@ export async function applyLaunchPlan(input: {
|
|
|
155
168
|
active = allTools.map((tool) => tool.name);
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
const disabledSet = new Set<string>();
|
|
158
172
|
if (hasOverlayDisables) {
|
|
159
173
|
const allLiveNames = allTools.map((tool) => tool.name);
|
|
160
174
|
const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
|
|
@@ -167,9 +181,33 @@ export async function applyLaunchPlan(input: {
|
|
|
167
181
|
`profile "${plan.profile}": overlay tool entries ${vanishedEntries.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
|
|
168
182
|
);
|
|
169
183
|
}
|
|
170
|
-
const
|
|
184
|
+
for (const name of disabled) disabledSet.add(name);
|
|
171
185
|
active = active.filter((name) => !disabledSet.has(name));
|
|
172
186
|
}
|
|
187
|
+
|
|
188
|
+
// Native MCP discovery entry points stay reachable for narrowed `tools`
|
|
189
|
+
// profiles when the plan marked them (D4). Only the built-in
|
|
190
|
+
// registration counts; an explicit overlay disable still wins.
|
|
191
|
+
if (plan.mcpGateways === true) {
|
|
192
|
+
for (const gateway of MCP_GATEWAYS) {
|
|
193
|
+
if (disabledSet.has(gateway.name)) continue;
|
|
194
|
+
const registered = allTools.find((tool) => tool.name === gateway.name);
|
|
195
|
+
if (registered === undefined) {
|
|
196
|
+
warnings.push(
|
|
197
|
+
`profile "${plan.profile}": MCP entry point ${JSON.stringify(gateway.name)} is unavailable because its built-in extension did not register it`,
|
|
198
|
+
);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (registered.sourceInfo?.path === gateway.sourcePath) {
|
|
202
|
+
if (!active.includes(gateway.name)) active.push(gateway.name);
|
|
203
|
+
} else {
|
|
204
|
+
warnings.push(
|
|
205
|
+
`profile "${plan.profile}": MCP entry point ${JSON.stringify(gateway.name)} is registered by ${registered.sourceInfo?.path ?? "an unknown source"}, not the built-in extension; it was not used as an MCP gateway`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
173
211
|
surface.setActiveTools(active);
|
|
174
212
|
}
|
|
175
213
|
|
|
@@ -204,22 +204,7 @@ export async function switchProfile(
|
|
|
204
204
|
...(previousPlan.mcps !== undefined ? { mcps: previousPlan.mcps } : {}),
|
|
205
205
|
}
|
|
206
206
|
: undefined;
|
|
207
|
-
|
|
208
|
-
agentDir: deps.realAgentDir,
|
|
209
|
-
projectDir: resolved.projectDir,
|
|
210
|
-
discovery: resolved.discovery,
|
|
211
|
-
planExtras: {
|
|
212
|
-
...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
|
|
213
|
-
// `/profile use` persists; `/profile reload` keeps the current
|
|
214
|
-
// profile's existing persistence (launch selections stay transient).
|
|
215
|
-
persistSelection: options?.reloadCurrent === true ? current.persistSelection : true,
|
|
216
|
-
// A switch discards the previous profile's overlay; the post-reload
|
|
217
|
-
// instance drops it from the state file. Customize/reset manage the
|
|
218
|
-
// overlay directly and never set this.
|
|
219
|
-
...(options?.clearOverlay === true ? { clearOverlay: true } : {}),
|
|
220
|
-
...(previousResolved !== undefined ? { previousResolved } : {}),
|
|
221
|
-
},
|
|
222
|
-
});
|
|
207
|
+
const warnings = [...resolved.warnings];
|
|
223
208
|
|
|
224
209
|
const rollback = async (cause: string): Promise<never> => {
|
|
225
210
|
// Restore the verified snapshot and reload again — the runtime must
|
|
@@ -236,7 +221,26 @@ export async function switchProfile(
|
|
|
236
221
|
);
|
|
237
222
|
};
|
|
238
223
|
|
|
224
|
+
// The write-and-reload interval is one rollback boundary: a failure in any
|
|
225
|
+
// write after the first, or in reload, restores every managed file (D3).
|
|
239
226
|
try {
|
|
227
|
+
const written = await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
|
|
228
|
+
agentDir: deps.realAgentDir,
|
|
229
|
+
projectDir: resolved.projectDir,
|
|
230
|
+
discovery: resolved.discovery,
|
|
231
|
+
planExtras: {
|
|
232
|
+
...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
|
|
233
|
+
// `/profile use` persists; `/profile reload` keeps the current
|
|
234
|
+
// profile's existing persistence (launch selections stay transient).
|
|
235
|
+
persistSelection: options?.reloadCurrent === true ? current.persistSelection : true,
|
|
236
|
+
// A switch discards the previous profile's overlay; the post-reload
|
|
237
|
+
// instance drops it from the state file. Customize/reset manage the
|
|
238
|
+
// overlay directly and never set this.
|
|
239
|
+
...(options?.clearOverlay === true ? { clearOverlay: true } : {}),
|
|
240
|
+
...(previousResolved !== undefined ? { previousResolved } : {}),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
warnings.push(...written.warnings);
|
|
240
244
|
await deps.reload();
|
|
241
245
|
} catch (error) {
|
|
242
246
|
await rollback(error instanceof Error ? error.message : String(error));
|
|
@@ -257,5 +261,5 @@ export async function switchProfile(
|
|
|
257
261
|
}
|
|
258
262
|
}
|
|
259
263
|
|
|
260
|
-
return { profile: resolved.plan.profile, warnings
|
|
264
|
+
return { profile: resolved.plan.profile, warnings };
|
|
261
265
|
}
|