pi-profile-switch 0.10.0 → 0.12.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 +14 -22
- package/README.zh-CN.md +14 -22
- package/examples/example.json +8 -4
- package/extensions/pi-profile/index.ts +76 -6
- package/package.json +2 -1
- package/schemas/profiles.schema.json +15 -1
- package/skills/profile-config/SKILL.md +16 -7
- package/src/launcher/initial-profile.ts +22 -9
- package/src/mcp-config.ts +24 -12
- package/src/profile-catalog.ts +47 -0
- package/src/profile-resolver.ts +190 -0
- package/src/settings-generator.ts +19 -31
- package/src/startup-notifier.ts +636 -0
- package/src/switching/apply-plan.ts +113 -18
- package/src/switching/status.ts +66 -9
- package/src/switching/tool-references.ts +26 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named set of resources you define: skills, extensions, MCP servers, tools
|
|
5
|
+
Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named set of resources you define: skills, extensions, MCP servers, tools, per-server MCP tool selections (`mcp_tools`), model defaults, and extra system-prompt instructions. Switch profiles inside a running Pi session — no restart.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -51,7 +51,7 @@ pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`*
|
|
|
51
51
|
}
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
One profile can use every field at once. This example `impl` profile (`impl.json`) loads the TDD skill, the mcp-scripting skill (shipped by pi-mcp-adapter), and your internal skills; wires up two MCP servers; allows the built-in tools
|
|
54
|
+
One profile can use every field at once. This example `impl` profile (`impl.json`) loads the TDD skill, the mcp-scripting skill (shipped by pi-mcp-adapter), 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:
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
@@ -76,11 +76,15 @@ One profile can use every field at once. This example `impl` profile (`impl.json
|
|
|
76
76
|
"ls",
|
|
77
77
|
"bash",
|
|
78
78
|
"edit",
|
|
79
|
-
"write"
|
|
80
|
-
"mcp__*",
|
|
81
|
-
"github_*",
|
|
82
|
-
"linear_*"
|
|
79
|
+
"write"
|
|
83
80
|
],
|
|
81
|
+
"mcp_tools": {
|
|
82
|
+
"github": [
|
|
83
|
+
"search",
|
|
84
|
+
"get_issue"
|
|
85
|
+
],
|
|
86
|
+
"linear": []
|
|
87
|
+
},
|
|
84
88
|
"defaultProvider": "anthropic",
|
|
85
89
|
"defaultModel": "claude-sonnet-4-5",
|
|
86
90
|
"defaultThinkingLevel": "high",
|
|
@@ -91,20 +95,15 @@ One profile can use every field at once. This example `impl` profile (`impl.json
|
|
|
91
95
|
How fields resolve:
|
|
92
96
|
|
|
93
97
|
- `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 against Pi's
|
|
98
|
+
- `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-provided tools, attributed by registration ownership (`sourceInfo`). Available MCP tools remain usable independently of `tools`.
|
|
99
|
+
- `mcp_tools` defines per-server MCP tool filtering: keys are literal configured server names and values are literal pi-mcp-adapter selectors (original or prefixed names; either form can select the same tool). 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 adapter/server before writing them.
|
|
100
|
+
- A nonempty `mcp_tools` requires `pi-mcp-adapter` even when `mcps` is omitted. Existing adapter `includeTools` filters are combined only when the profile uses identical selectors or the existing filter is `"*"`; otherwise activation fails before writing runtime files. Existing `excludeTools` restrictions continue to apply.
|
|
101
|
+
- **Migration note:** Former MCP references in `tools` (e.g. `mcp__*`, `<server>_*`) no longer govern MCP access. Move desired MCP tool restrictions to `mcp_tools`.
|
|
95
102
|
- `mcps` references servers from your pi-mcp-adapter configuration; connection details stay in the adapter's own config.
|
|
96
103
|
- Any field you omit keeps plain Pi behavior.
|
|
97
104
|
|
|
98
105
|
The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
|
|
99
106
|
|
|
100
|
-
### Migrating from earlier versions
|
|
101
|
-
|
|
102
|
-
If you used an earlier version that stored all profiles in a single `profiles.json` (`schemaVersion: 1`), migrate manually by creating a file for each profile under the `profiles/` directory:
|
|
103
|
-
|
|
104
|
-
1. Create directory `~/.pi-profile-switch/profiles/` (or `<project>/.pi/profiles/`).
|
|
105
|
-
2. For each key `<name>` in your old `profiles.json`'s `profiles` object, save its value directly as `<name>.json`.
|
|
106
|
-
3. Drop the outer `schemaVersion` and `profiles` envelope.
|
|
107
|
-
|
|
108
107
|
## Commands
|
|
109
108
|
|
|
110
109
|
The `/profile` command family manages everything in-session:
|
|
@@ -119,13 +118,6 @@ The `/profile` command family manages everything in-session:
|
|
|
119
118
|
|
|
120
119
|
All forms work in every mode, including non-interactive ones (`--mode rpc|print|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`.
|
|
121
120
|
|
|
122
|
-
Removed subcommands and their replacements:
|
|
123
|
-
|
|
124
|
-
| Removed | Replacement |
|
|
125
|
-
| --- | --- |
|
|
126
|
-
| `/profile create` / `/profile edit` / `/profile delete` / `/profile duplicate` | edit the catalog JSON files directly, or configure profiles conversationally with the [`profile-config`](skills/profile-config/SKILL.md) skill |
|
|
127
|
-
| the `tools` replace-form of `/profile overlay` | `/profile overlay disable tool <name-or-glob>` (disable the complement, with globs) or declare the fixed set in the profile's `tools` field |
|
|
128
|
-
|
|
129
121
|
## Docs
|
|
130
122
|
|
|
131
123
|
- [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 是你自定义的命名能力组合:skills、extensions、MCP server、tools
|
|
5
|
+
[Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 是你自定义的命名能力组合:skills、extensions、MCP server、tools、按 server 细化的 MCP 工具控制(`mcp_tools`)、默认模型,以及追加到系统提示词的 instructions。在同一个运行中的 Pi 会话里切换这些组合,无需重启。
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
@@ -51,7 +51,7 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
51
51
|
}
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例(`impl.json`)加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server
|
|
54
|
+
一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例(`impl.json`)加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;允许内建工具并按 server 细化 MCP 工具;同时钉住模型与常驻 instructions:
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
@@ -76,11 +76,15 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
76
76
|
"ls",
|
|
77
77
|
"bash",
|
|
78
78
|
"edit",
|
|
79
|
-
"write"
|
|
80
|
-
"mcp__*",
|
|
81
|
-
"github_*",
|
|
82
|
-
"linear_*"
|
|
79
|
+
"write"
|
|
83
80
|
],
|
|
81
|
+
"mcp_tools": {
|
|
82
|
+
"github": [
|
|
83
|
+
"search",
|
|
84
|
+
"get_issue"
|
|
85
|
+
],
|
|
86
|
+
"linear": []
|
|
87
|
+
},
|
|
84
88
|
"defaultProvider": "anthropic",
|
|
85
89
|
"defaultModel": "claude-sonnet-4-5",
|
|
86
90
|
"defaultThinkingLevel": "high",
|
|
@@ -91,20 +95,15 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
91
95
|
字段解析规则:
|
|
92
96
|
|
|
93
97
|
- `skills`、`extensions`、`mcps`、`tools` 接受名称或 glob(如 `"internal-*"`),引用你已安装或已配置的资源——profile 从不复制资源。已安装的包和标准目录下的文件会被自动发现,无需注册。
|
|
94
|
-
- `tools`
|
|
98
|
+
- `tools` 仅针对 Pi 的非 MCP 工具展开(内建工具与 extension 提供的工具,根据注册归属 `sourceInfo` 判定)。可用的 MCP 工具独立于 `tools` 保持可用。
|
|
99
|
+
- `mcp_tools` 按 server 细化 MCP 工具策略:键为字面 MCP server 名称,值为 pi-mcp-adapter 的字面 selector(原始名或带前缀名;两种写法都可能选中同一个工具)。此字段不接受 glob。省略的 server 保留其全部原生工具访问;非空数组仅允许匹配到的工具;空数组(`[]`)禁用该 server 的全部工具,同时保持 server 处于启用状态。未匹配的 selector 仍保持限制且不会收到诊断,因此编写前请通过 adapter/server 确认可用写法。
|
|
100
|
+
- 非空的 `mcp_tools` 即使在省略 `mcps` 时也需要 `pi-mcp-adapter`。只有当 profile selector 与已有的 `includeTools` 字面值完全相同,或已有列表是 `"*"` 时才能安全组合;否则会在写入 runtime 文件前报错。已有的 `excludeTools` 限制仍然生效。
|
|
101
|
+
- **迁移提示**:旧 profile 中写入 `tools` 的 MCP 工具名称或 glob(如 `mcp__*`、`<server>_*`)不再控制 MCP 访问;如有需要请迁移至 `mcp_tools`。
|
|
95
102
|
- `mcps` 引用 pi-mcp-adapter 配置中的 server;连接细节留在 adapter 自己的配置里。
|
|
96
103
|
- 未写的字段保持原生 Pi 行为。
|
|
97
104
|
|
|
98
105
|
[`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
|
|
99
106
|
|
|
100
|
-
### 从旧版本迁移
|
|
101
|
-
|
|
102
|
-
如果你之前使用了把全部 profile 存在单个 `profiles.json`(含 `schemaVersion: 1`)的旧版本,请手动把每个 profile 拆分到 `profiles/` 目录:
|
|
103
|
-
|
|
104
|
-
1. 创建 `~/.pi-profile-switch/profiles/`(或 `<项目>/.pi/profiles/`)目录。
|
|
105
|
-
2. 将旧 `profiles.json` 中 `profiles` 下的每个 `<name>` 键值提取为独立的 `<name>.json` 文件。
|
|
106
|
-
3. 去除外层的 `schemaVersion` 与 `profiles` 信封,文件顶层即为裸 profile 定义。
|
|
107
|
-
|
|
108
107
|
## 命令
|
|
109
108
|
|
|
110
109
|
`/profile` 命令族完成所有会话内操作:
|
|
@@ -119,13 +118,6 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
119
118
|
|
|
120
119
|
所有命令在非交互模式(`--mode rpc|print|json`)下同样生效;裸 `/profile` 在无交互界面时降级为 profile 列表。overlay 是会话级收窄:绝不写入 catalog 文件,也不会跨重启保留。tool 与其他资源类别使用相同的 disable/enable 模型:tool `disable` 条目收窄 profile 解析出的工具引用;profile 未声明 `tools` 时,则收窄运行时的全部可用工具集。
|
|
121
120
|
|
|
122
|
-
已移除子命令及其替代:
|
|
123
|
-
|
|
124
|
-
| 移除 | 替代 |
|
|
125
|
-
| --- | --- |
|
|
126
|
-
| `/profile create` / `/profile edit` / `/profile delete` / `/profile duplicate` | 直接编辑 catalog JSON 文件,或通过 [`profile-config`](skills/profile-config/SKILL.md) skill 对话式配置 |
|
|
127
|
-
| `/profile overlay` 的 `tools` 替换式 | `/profile overlay disable tool <name-or-glob>`(用 glob 禁用其余工具),或在 profile 的 `tools` 字段中声明固定集合 |
|
|
128
|
-
|
|
129
121
|
## 文档
|
|
130
122
|
|
|
131
123
|
- [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
|
package/examples/example.json
CHANGED
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
"ls",
|
|
21
21
|
"bash",
|
|
22
22
|
"edit",
|
|
23
|
-
"write"
|
|
24
|
-
"mcp__*",
|
|
25
|
-
"github_*",
|
|
26
|
-
"linear_*"
|
|
23
|
+
"write"
|
|
27
24
|
],
|
|
25
|
+
"mcp_tools": {
|
|
26
|
+
"github": [
|
|
27
|
+
"github_search",
|
|
28
|
+
"get_issue"
|
|
29
|
+
],
|
|
30
|
+
"linear": []
|
|
31
|
+
},
|
|
28
32
|
"defaultProvider": "anthropic",
|
|
29
33
|
"defaultModel": "claude-sonnet-4-5",
|
|
30
34
|
"defaultThinkingLevel": "high",
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
|
|
7
|
+
import { isRecord } from "../../src/json-file.ts";
|
|
5
8
|
import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
|
|
6
|
-
import {
|
|
9
|
+
import { loadMergedMcpServers } from "../../src/mcp-config.ts";
|
|
7
10
|
import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
|
|
11
|
+
import { runStartupNotifications, type NoticeSurface } from "../../src/startup-notifier.ts";
|
|
8
12
|
import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
|
|
9
13
|
import { OVERLAY_USAGE, applyOverlayMutation, clearOverlay, parseOverlayArgs } from "../../src/switching/overlay.ts";
|
|
10
14
|
import { formatProfileList, listProfiles } from "../../src/switching/list-profiles.ts";
|
|
11
15
|
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
12
16
|
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
13
|
-
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
17
|
+
import { getGlobalStateDir, getProfileSwitchDir } from "../../src/workspace.ts";
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* pi-profile extension entry.
|
|
@@ -38,6 +42,59 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
38
42
|
* exclusively through `session_start` — nothing stale survives.
|
|
39
43
|
*/
|
|
40
44
|
|
|
45
|
+
/** The bundled package's own metadata: the notifier compares the RUNNING
|
|
46
|
+
* package version (never Pi's version or a repository checkout). */
|
|
47
|
+
const OWN_PACKAGE_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
48
|
+
|
|
49
|
+
async function readOwnVersion(): Promise<string | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
const raw: unknown = JSON.parse(await readFile(OWN_PACKAGE_JSON, "utf8"));
|
|
52
|
+
if (isRecord(raw) && typeof raw.version === "string") return raw.version;
|
|
53
|
+
} catch {
|
|
54
|
+
// Unresolvable package metadata: startup notices are skipped quietly.
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Notice presentation surface: the TUI notification channel when available,
|
|
60
|
+
* stderr in every other mode (never stdout, never the agent's prompts). */
|
|
61
|
+
function createNoticeSurface(ctx: unknown): NoticeSurface {
|
|
62
|
+
const context = ctx as {
|
|
63
|
+
hasUI?: boolean;
|
|
64
|
+
mode?: string;
|
|
65
|
+
ui?: { notify?: (message: string, level: "info" | "warning" | "error") => void };
|
|
66
|
+
};
|
|
67
|
+
if (context.hasUI === true && context.mode === "tui" && typeof context.ui?.notify === "function") {
|
|
68
|
+
return { display: (message, level) => context.ui!.notify!(message, level) };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
display: (message) => {
|
|
72
|
+
process.stderr.write(`${message}\n`);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Startup notices run once per Pi process on the initial session_start,
|
|
78
|
+
* without awaiting remote IO, in an isolated failure domain: a notifier
|
|
79
|
+
* problem can never block profile activation, change Pi's exit code, or
|
|
80
|
+
* reach the activation/switch error path. Pi re-executes this module on
|
|
81
|
+
* reload, and reload/new/resume/fork never pass reason "startup", so no
|
|
82
|
+
* second check is possible within one process. */
|
|
83
|
+
async function startStartupNotices(surface: NoticeSurface): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const version = await readOwnVersion();
|
|
86
|
+
if (version === undefined) return;
|
|
87
|
+
await runStartupNotifications({
|
|
88
|
+
installedVersion: version,
|
|
89
|
+
workspaceDir: getProfileSwitchDir(),
|
|
90
|
+
offline: process.env.PI_OFFLINE === "1",
|
|
91
|
+
surface,
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
// Best-effort by contract: swallow everything the notifier missed.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
41
98
|
function setProfileStatus(ui: unknown, profile: string | undefined): void {
|
|
42
99
|
if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
|
|
43
100
|
(ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
|
|
@@ -51,6 +108,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
51
108
|
let pendingSummary: string | undefined;
|
|
52
109
|
|
|
53
110
|
pi.on("session_start", async (event, ctx) => {
|
|
111
|
+
// Capture the notice surface synchronously: pi may replace the session
|
|
112
|
+
// right after this handler (one-shot modes), after which a captured
|
|
113
|
+
// ctx throws on access. The surface freezes the channel now.
|
|
114
|
+
const noticeSurface = createNoticeSurface(ctx);
|
|
54
115
|
const plan = await readLaunchPlanFile(runtimeDir);
|
|
55
116
|
setProfileStatus(ctx.ui, plan?.profile);
|
|
56
117
|
const result = await applyLaunchPlan({
|
|
@@ -64,6 +125,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
64
125
|
},
|
|
65
126
|
});
|
|
66
127
|
pendingSummary = result.summary;
|
|
128
|
+
if (event.reason === "startup") {
|
|
129
|
+
void startStartupNotices(noticeSurface);
|
|
130
|
+
}
|
|
67
131
|
});
|
|
68
132
|
|
|
69
133
|
pi.on("before_agent_start", async (event) => {
|
|
@@ -157,13 +221,19 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
157
221
|
const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
|
|
158
222
|
const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
159
223
|
const state = await new RuntimeStateStore(stateDir).read();
|
|
224
|
+
const mcpDiscovery = await loadMergedMcpServers(
|
|
225
|
+
plan.agentDir,
|
|
226
|
+
projectTrusted ? ctx.cwd : undefined,
|
|
227
|
+
);
|
|
228
|
+
const discoveredMcpServers = Object.keys(mcpDiscovery.servers).sort();
|
|
229
|
+
const disabledMcpServers = discoveredMcpServers.filter(
|
|
230
|
+
(server) => mcpDiscovery.servers[server]?.disabled === true,
|
|
231
|
+
);
|
|
160
232
|
const report = buildStatusReport({
|
|
161
233
|
plan,
|
|
162
234
|
overlay: state.overlay,
|
|
163
|
-
discoveredMcpServers
|
|
164
|
-
|
|
165
|
-
projectTrusted ? ctx.cwd : undefined,
|
|
166
|
-
),
|
|
235
|
+
discoveredMcpServers,
|
|
236
|
+
disabledMcpServers,
|
|
167
237
|
commands: pi.getCommands(),
|
|
168
238
|
tools: pi.getAllTools(),
|
|
169
239
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": [
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@earendil-works/pi-coding-agent": "*",
|
|
37
37
|
"@types/node": "^24.0.0",
|
|
38
38
|
"ajv": "^8.20.0",
|
|
39
|
+
"pi-mcp-adapter": "^2.38.0",
|
|
39
40
|
"typescript": "^5.8.0",
|
|
40
41
|
"vitest": "^3.0.0"
|
|
41
42
|
},
|
|
@@ -38,7 +38,21 @@
|
|
|
38
38
|
"items": {
|
|
39
39
|
"type": "string"
|
|
40
40
|
},
|
|
41
|
-
"description": "
|
|
41
|
+
"description": "Names or globs for non-MCP Pi built-ins and non-MCP extension tools; MCP tools are selected separately with mcp_tools."
|
|
42
|
+
},
|
|
43
|
+
"mcp_tools": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"propertyNames": {
|
|
46
|
+
"pattern": "^[^[*?\\]{}]+$"
|
|
47
|
+
},
|
|
48
|
+
"additionalProperties": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"pattern": "^[^[*?\\]{}]+$"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"description": "Per-server tool lists mapping literal MCP server names to arrays of literal pi-mcp-adapter tool selectors. Original and prefixed names can select the same tool; globs are not allowed. An omitted server allows all its tools; an empty list denies all its tools."
|
|
42
56
|
},
|
|
43
57
|
"defaultProvider": {
|
|
44
58
|
"type": "string",
|
|
@@ -54,8 +54,9 @@ All fields are optional. Undeclared fields keep native Pi behavior or current st
|
|
|
54
54
|
| `description` | `string` | Short description of the profile (e.g. `"Read-only review profile"`). |
|
|
55
55
|
| `skills` | `string[]` | Skill names or globs to reference. When undeclared, available skills are not narrowed. |
|
|
56
56
|
| `extensions` | `string[]` | Extension identifiers or globs to reference. When undeclared, available extensions are not narrowed. |
|
|
57
|
-
| `mcps` | `string[]` | MCP server names or globs to reference. When undeclared,
|
|
58
|
-
| `tools` | `string[]` | Whitelisted tool names or globs. When undeclared, tools are not narrowed and Pi's native tool set is kept. |
|
|
57
|
+
| `mcps` | `string[]` | MCP server names or globs to reference. When undeclared, `mcps` itself adds no adapter requirement; a nonempty `mcp_tools` still requires `pi-mcp-adapter`. |
|
|
58
|
+
| `tools` | `string[]` | Whitelisted non-MCP tool names or globs (built-in and extension tools only). Live MCP tools remain usable independently of `tools`. When undeclared, tools are not narrowed and Pi's native tool set is kept. |
|
|
59
|
+
| `mcp_tools` | `Record<string, string[]>` | Per-server MCP tool selection: literal server names mapped to literal adapter selectors (original or prefixed names; both forms may select the same tool). Globs are rejected. An omitted server allows all tools; a nonempty array allows only matches; an empty array (`[]`) denies all tools while keeping the server enabled. An empty object (`{}`) behaves like omission. Nonempty declarations require `pi-mcp-adapter`, even when `mcps` is omitted. |
|
|
59
60
|
| `defaultProvider` | `string` | Default model provider (e.g. `"anthropic"`, `"openai"`). Effective only when declared together with `defaultModel`. |
|
|
60
61
|
| `defaultModel` | `string` | Default model name (e.g. `"claude-sonnet-4-5"`). Effective only when declared together with `defaultProvider`. |
|
|
61
62
|
| `defaultThinkingLevel` | `string` | Default thinking level; allowed values: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. Effective only when the model declaration holds. |
|
|
@@ -105,12 +106,20 @@ When helping the user configure a profile, check or consult the following locati
|
|
|
105
106
|
3. **MCP servers**:
|
|
106
107
|
- Discovery locations: the standard configuration locations recognized by `pi-mcp-adapter` — on the global side `~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, `<agentDir>/mcp.json`; trusted projects additionally have `<projectDir>/.mcp.json` and `<projectDir>/.pi/mcp.json`.
|
|
107
108
|
- Reference identity: the server key names under the `mcpServers` object in those configuration files.
|
|
108
|
-
- A profile declaring `mcps` must
|
|
109
|
+
- A profile declaring nonempty `mcps` or nonempty `mcp_tools` must ensure the `pi-mcp-adapter` extension is available; omitting `mcps` does not remove the requirement for nonempty `mcp_tools`.
|
|
109
110
|
4. **Tools**:
|
|
110
|
-
- Reference identity: tool names in Pi's live tool registry.
|
|
111
|
-
- Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.)
|
|
112
|
-
-
|
|
113
|
-
5. **
|
|
111
|
+
- Reference identity: non-MCP tool names in Pi's live tool registry.
|
|
112
|
+
- Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.) and extension-contributed tools.
|
|
113
|
+
- MCP-owned tools are **not** controlled by `tools`; legacy references such as `"mcp__*"` or `"github_*"` in `tools` must be migrated to `mcp_tools`.
|
|
114
|
+
5. **MCP Tools (`mcp_tools`)**:
|
|
115
|
+
- Reference identity: keys are literal configured MCP server names; values are literal pi-mcp-adapter selectors. Original and prefixed selectors can both match the same tool; globs are not accepted.
|
|
116
|
+
- Selectors are not checked against a live tool catalog. A selector matching no tool stays restrictive and produces no name diagnostic; verify selector forms with the adapter or MCP server before writing the profile.
|
|
117
|
+
- If the server already has nonempty `includeTools`, each requested selector must be identical to an existing literal or the existing list must include `"*"`; otherwise activation fails before runtime files are written. Existing `excludeTools` still applies.
|
|
118
|
+
- Omitting a server key preserves unrestricted access to that server's tools.
|
|
119
|
+
- An empty list (`[]`) denies all tools for that server while keeping the server enabled.
|
|
120
|
+
- A nonempty list (e.g. `["search", "get_issue"]`) allows only those tools across direct tools, gateway proxies, and scripts.
|
|
121
|
+
- Narrowing applies only to user-level MCP servers; project-level MCP servers cannot be narrowed by profiles.
|
|
122
|
+
6. **Narrowing boundary of project-level resources (important)**:
|
|
114
123
|
- A profile's resource selection (`skills`, `extensions`) **applies only to user-level resources** (the real agentDir and `~/.agents/skills`).
|
|
115
124
|
- The visibility of project-level resources (project `.pi/skills`, project `.pi/extensions`, ancestor `.agents/skills`) is decided by Pi's project-trust determination: in a trusted project they are always visible under **any** profile; in an untrusted project they are never visible.
|
|
116
125
|
- Therefore project-level resource visibility **does not narrow with profiles** — there is no need, and no guidance, to declare project-level resources in a profile.
|
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
|
|
16
16
|
import { isRecord, readJsonFile } from "../json-file.ts";
|
|
17
|
-
import {
|
|
18
|
-
import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-config.ts";
|
|
17
|
+
import { isAdapterExtension, loadMergedMcpServers, MissingMcpAdapterError } from "../mcp-config.ts";
|
|
19
18
|
import { ProfileCatalog, type ResolvedProfile } from "../profile-catalog.ts";
|
|
20
19
|
import { ActivationError, defaultPlan, resolveProfile, type ActivationPlan } from "../profile-resolver.ts";
|
|
21
20
|
import { resolveProjectTrust } from "../project-trust.ts";
|
|
@@ -159,24 +158,38 @@ export async function resolveInitialProfile(
|
|
|
159
158
|
return { plan, discovery, projectDir, projectTrusted, warnings };
|
|
160
159
|
}
|
|
161
160
|
|
|
161
|
+
const mcpToolsDef = (profile.definition as { mcp_tools?: Record<string, string[]> }).mcp_tools;
|
|
162
|
+
const hasMcpTools = mcpToolsDef !== undefined && Object.keys(mcpToolsDef).length > 0;
|
|
163
|
+
const needsMcp = Boolean(
|
|
164
|
+
profile.definition.mcps?.length ||
|
|
165
|
+
hasMcpTools ||
|
|
166
|
+
(options?.overlay?.disabledMcps?.length ?? 0) > 0,
|
|
167
|
+
);
|
|
168
|
+
const mcpDiscovery = needsMcp
|
|
169
|
+
? await loadMergedMcpServers(context.agentDir, projectDir)
|
|
170
|
+
: undefined;
|
|
171
|
+
|
|
162
172
|
const discovery = await discoverLauncherResources({ ...context, projectTrusted });
|
|
163
173
|
const plan = await resolveProfile({
|
|
164
174
|
profile,
|
|
165
175
|
skills: discovery.skills,
|
|
166
176
|
extensions: discovery.extensions,
|
|
167
177
|
validateModel: (model) => checkDeclaredModel(context.agentDir, model),
|
|
168
|
-
discoveredMcpServers:
|
|
169
|
-
|
|
170
|
-
: undefined,
|
|
178
|
+
discoveredMcpServers: mcpDiscovery ? Object.keys(mcpDiscovery.servers).sort() : undefined,
|
|
179
|
+
mcpDiscovery,
|
|
171
180
|
overlay: options?.overlay,
|
|
172
181
|
liveToolNames: options?.liveToolNames,
|
|
173
182
|
});
|
|
174
183
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
175
184
|
if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
throw new MissingMcpAdapterError(plan.profile, "mcps");
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
plan.mcpTools !== undefined &&
|
|
189
|
+
Object.keys(plan.mcpTools).length > 0 &&
|
|
190
|
+
!plan.extensions.some(isAdapterExtension)
|
|
191
|
+
) {
|
|
192
|
+
throw new MissingMcpAdapterError(plan.profile, "mcp_tools");
|
|
180
193
|
}
|
|
181
194
|
return { plan, discovery, projectDir, projectTrusted, warnings };
|
|
182
195
|
}
|
package/src/mcp-config.ts
CHANGED
|
@@ -43,9 +43,16 @@ export interface MergedMcpResult {
|
|
|
43
43
|
/** Servers defined in a trusted project's own config (`.mcp.json`,
|
|
44
44
|
* `.pi/mcp.json`). They are not the profile's to narrow. */
|
|
45
45
|
projectServers: Set<string>;
|
|
46
|
+
/** Winning origin of each discovered server: "project" if defined or
|
|
47
|
+
* shadowed by project-level configuration, "user" otherwise. */
|
|
48
|
+
serverOwners: Record<string, "user" | "project">;
|
|
46
49
|
baseConfig?: Record<string, unknown>;
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
|
|
53
|
+
Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
export interface McpConfigSource {
|
|
50
57
|
path: string;
|
|
51
58
|
isShared: boolean;
|
|
@@ -93,6 +100,7 @@ export async function loadMergedMcpServers(
|
|
|
93
100
|
const servers: Record<string, Record<string, unknown>> = {};
|
|
94
101
|
const sharedServers = new Set<string>();
|
|
95
102
|
const projectServers = new Set<string>();
|
|
103
|
+
const serverOwners: Record<string, "user" | "project"> = {};
|
|
96
104
|
let baseConfig: Record<string, unknown> | undefined;
|
|
97
105
|
|
|
98
106
|
for (const source of sources) {
|
|
@@ -116,21 +124,22 @@ export async function loadMergedMcpServers(
|
|
|
116
124
|
throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
|
|
117
125
|
}
|
|
118
126
|
for (const [name, def] of Object.entries(result.value.mcpServers)) {
|
|
127
|
+
// The real adapter copies server entries into an ordinary {} and
|
|
128
|
+
// cannot represent "__proto__" as a discoverable server name; align
|
|
129
|
+
// discovery so inherited prototype keys are never treated as servers.
|
|
130
|
+
if (name === "__proto__") continue;
|
|
119
131
|
if (source.isShared) {
|
|
120
132
|
sharedServers.add(name);
|
|
121
133
|
}
|
|
122
|
-
if (source.isProject === true)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
} else {
|
|
128
|
-
servers[name] = { ...(servers[name] ?? {}) };
|
|
129
|
-
}
|
|
134
|
+
if (source.isProject === true) projectServers.add(name);
|
|
135
|
+
setOwnRecordValue(serverOwners, name, source.isProject === true ? "project" : "user");
|
|
136
|
+
const previous = Object.hasOwn(servers, name) ? servers[name] : undefined;
|
|
137
|
+
const merged = isRecord(def) ? { ...(previous ?? {}), ...def } : { ...(previous ?? {}) };
|
|
138
|
+
setOwnRecordValue(servers, name, merged);
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
return { servers, sharedServers, projectServers, baseConfig };
|
|
142
|
+
return { servers, sharedServers, projectServers, serverOwners, baseConfig };
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
/** Server names the adapter would discover: standard global MCP configs,
|
|
@@ -146,10 +155,13 @@ export async function discoverAdapterServerNames(
|
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
export class MissingMcpAdapterError extends Error {
|
|
149
|
-
constructor(profile: string) {
|
|
158
|
+
constructor(profile: string, reason: "mcps" | "mcp_tools" = "mcps") {
|
|
150
159
|
super(
|
|
151
|
-
|
|
152
|
-
`
|
|
160
|
+
reason === "mcp_tools"
|
|
161
|
+
? `profile "${profile}" declares MCP tools but pi-mcp-adapter is not active. ` +
|
|
162
|
+
`Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcp_tools" declaration.`
|
|
163
|
+
: `profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
|
|
164
|
+
`Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
|
|
153
165
|
);
|
|
154
166
|
this.name = "MissingMcpAdapterError";
|
|
155
167
|
}
|
package/src/profile-catalog.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface ProfileDefinition {
|
|
|
44
44
|
extensions?: string[];
|
|
45
45
|
mcps?: string[];
|
|
46
46
|
tools?: string[];
|
|
47
|
+
/** Literal adapter selectors (original or prefixed names); globs are rejected. */
|
|
48
|
+
mcp_tools?: Record<string, string[]>;
|
|
47
49
|
defaultProvider?: string;
|
|
48
50
|
defaultModel?: string;
|
|
49
51
|
defaultThinkingLevel?: string;
|
|
@@ -89,6 +91,49 @@ function readOptionalString(value: unknown, field: string, profileName: string,
|
|
|
89
91
|
return value;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function isGlobPattern(value: string): boolean {
|
|
95
|
+
return (
|
|
96
|
+
value.includes("*") ||
|
|
97
|
+
value.includes("?") ||
|
|
98
|
+
value.includes("[") ||
|
|
99
|
+
value.includes("]") ||
|
|
100
|
+
value.includes("{") ||
|
|
101
|
+
value.includes("}")
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readMcpTools(
|
|
106
|
+
value: unknown,
|
|
107
|
+
profileName: string,
|
|
108
|
+
filePath?: string,
|
|
109
|
+
): Record<string, string[]> | undefined {
|
|
110
|
+
if (value === undefined) return undefined;
|
|
111
|
+
const prefix = filePath ? `${filePath}: ` : "";
|
|
112
|
+
if (!isRecord(value)) {
|
|
113
|
+
throw new CatalogError(`${prefix}profile "${profileName}": "mcp_tools" must be an object of string arrays`);
|
|
114
|
+
}
|
|
115
|
+
const entries: Array<[string, string[]]> = [];
|
|
116
|
+
for (const [server, tools] of Object.entries(value)) {
|
|
117
|
+
if (isGlobPattern(server)) {
|
|
118
|
+
throw new CatalogError(
|
|
119
|
+
`${prefix}profile "${profileName}": "mcp_tools" server "${server}" is a glob pattern; literal MCP server names are required`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(tools) || tools.some((entry) => typeof entry !== "string")) {
|
|
123
|
+
throw new CatalogError(`${prefix}profile "${profileName}": "mcp_tools" must be an object of string arrays`);
|
|
124
|
+
}
|
|
125
|
+
for (const tool of tools) {
|
|
126
|
+
if (isGlobPattern(tool)) {
|
|
127
|
+
throw new CatalogError(
|
|
128
|
+
`${prefix}profile "${profileName}": "mcp_tools" entry "${tool}" in server "${server}" is a glob pattern; literal adapter tool selectors are required`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
entries.push([server, [...tools]]);
|
|
133
|
+
}
|
|
134
|
+
return Object.fromEntries(entries);
|
|
135
|
+
}
|
|
136
|
+
|
|
92
137
|
/** Parses one raw profile definition; the single read-time validator so
|
|
93
138
|
* catalog files and any external writer stay loadable. */
|
|
94
139
|
export function parseProfileDefinition(name: string, raw: unknown, filePath?: string): ProfileDefinition {
|
|
@@ -105,6 +150,8 @@ export function parseProfileDefinition(name: string, raw: unknown, filePath?: st
|
|
|
105
150
|
const entries = readStringArray(raw[field], field, name, filePath);
|
|
106
151
|
if (entries !== undefined) definition[field] = entries;
|
|
107
152
|
}
|
|
153
|
+
const mcpTools = readMcpTools(raw.mcp_tools, name, filePath);
|
|
154
|
+
if (mcpTools !== undefined) definition.mcp_tools = mcpTools;
|
|
108
155
|
const defaultProvider = readOptionalString(raw.defaultProvider, "defaultProvider", name, filePath);
|
|
109
156
|
if (defaultProvider !== undefined) definition.defaultProvider = defaultProvider;
|
|
110
157
|
const defaultModel = readOptionalString(raw.defaultModel, "defaultModel", name, filePath);
|