pi-profile-switch 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/README.zh-CN.md +8 -4
- package/examples/profiles.example.json +2 -2
- package/extensions/pi-profile-switch/index.ts +40 -6
- package/package.json +1 -1
- package/schemas/profiles.schema.json +2 -2
- package/src/default-profiles.ts +59 -0
- package/src/profile-catalog.ts +31 -2
- package/src/profile-resolver.ts +3 -3
- package/src/runtime-state-store.ts +6 -0
- package/src/startup-mcp-scope.ts +13 -4
- package/src/startup-selection.ts +59 -2
- package/src/switching/activate-profile.ts +33 -4
- package/src/switching/mcp-toggle.ts +17 -9
- package/src/switching/profile-wizard.ts +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile selects
|
|
|
6
6
|
|
|
7
7
|
A shipped `read-only` preset covers the read-and-report workflow; other workflows you write yourself — a profile can narrow the tool set, hide skills, pin a model, or add instructions.
|
|
8
8
|
|
|
9
|
-
`pi-profile-switch` is a plain Pi package (ADR-0007). It installs like any other extension,
|
|
9
|
+
`pi-profile-switch` is a plain Pi package (ADR-0007). It installs like any other extension, keeps Pi's configuration directory as the single source of truth, and keeps sessions, packages, project trust, and every other installed extension native. The only file it adds is the default catalog below.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -16,6 +16,8 @@ pi install npm:pi-profile-switch
|
|
|
16
16
|
|
|
17
17
|
Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (installed automatically as a peer dependency).
|
|
18
18
|
|
|
19
|
+
Pi packages have no install hook, so the default catalog is written the first time the extension loads (in any mode, including `--mode rpc`): if `~/.pi/agent/profiles.json` does not exist, it is created from the shipped `read-only` profile. An existing file is never read, rewritten, or backed up — delete it to get the default back.
|
|
20
|
+
|
|
19
21
|
## Quick start
|
|
20
22
|
|
|
21
23
|
```bash
|
|
@@ -29,7 +31,9 @@ pi --profile read-only
|
|
|
29
31
|
pi --profile default
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
A fresh install already holds one profile, `read-only`; activate it with `pi --profile read-only` or `/profile use read-only`.
|
|
35
|
+
|
|
36
|
+
Create more 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
37
|
|
|
34
38
|
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.
|
|
35
39
|
|
|
@@ -42,7 +46,7 @@ A preset is a one-time copy into your catalog: it is not tracked, so a package u
|
|
|
42
46
|
| `instructions` | Text appended to the system prompt every turn |
|
|
43
47
|
| `model` | Session-start model preset; an explicit `--model`/`--thinking` or a model recorded in the session wins |
|
|
44
48
|
| `skills` | What the model sees in the prompt's skills list. Every installed skill stays loaded and callable by the user through `/skill:name` |
|
|
45
|
-
| `
|
|
49
|
+
| `mcps` | 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. The legacy key `mcp` is still read and becomes `mcps` the next time the profile is saved |
|
|
46
50
|
| `tools` | Active tool set: declared names/globs become the active set; names that register later (MCP, extensions) are applied when they appear |
|
|
47
51
|
|
|
48
52
|
Anything a profile does not declare keeps Pi's native behavior, and `default` declares nothing.
|
|
@@ -69,7 +73,7 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
|
|
|
69
73
|
- **Reference, never copy** — profiles point at resources you already own and maintain.
|
|
70
74
|
- **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.
|
|
71
75
|
- **Fail safe** — untrusted project directories are never read; a failed activation applies nothing and reports the cause.
|
|
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.
|
|
76
|
+
- **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, and the switch survives that rebuild: the rebuild continues the selection you made, it does not fall back to the profile you started with.
|
|
73
77
|
|
|
74
78
|
## Docs
|
|
75
79
|
|
package/README.zh-CN.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
随包提供一个 `read-only` 预设覆盖「读代码 + 报告」的工作流;其余工作流由你自己定义——profile 可以收窄工具集、隐藏 skill、固定模型或附加指令。
|
|
8
8
|
|
|
9
|
-
`pi-profile-switch` 是普通 Pi package(ADR-0007
|
|
9
|
+
`pi-profile-switch` 是普通 Pi package(ADR-0007):像其他扩展一样安装,配置目录始终是 Pi 自己的那一份,session、packages、项目信任和其他扩展全部保持原生。它只在配置目录里新增一个文件,就是下面这个默认 catalog。
|
|
10
10
|
|
|
11
11
|
## 安装
|
|
12
12
|
|
|
@@ -16,6 +16,8 @@ pi install npm:pi-profile-switch
|
|
|
16
16
|
|
|
17
17
|
依赖 [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)(作为 peer dependency 自动安装)。
|
|
18
18
|
|
|
19
|
+
Pi package 没有安装钩子,因此默认 catalog 在扩展首次加载时写入(任何模式都会执行,包括 `--mode rpc`):`~/.pi/agent/profiles.json` 不存在时,用随包的 `read-only` profile 创建它。已存在的文件不会被读取、改写或备份——删掉它即可拿回默认值。
|
|
20
|
+
|
|
19
21
|
## 快速上手
|
|
20
22
|
|
|
21
23
|
```bash
|
|
@@ -29,7 +31,9 @@ pi --profile read-only
|
|
|
29
31
|
pi --profile default
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
新安装已经带有一个 `read-only` profile,用 `pi --profile read-only` 或 `/profile use read-only` 激活。
|
|
35
|
+
|
|
36
|
+
用 `/profile create` 创建更多 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始。
|
|
33
37
|
|
|
34
38
|
预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.example.json`](examples/profiles.example.json) 是覆盖全部字段的完整 catalog,示例中的资源名请替换成你本机已有的名字。
|
|
35
39
|
|
|
@@ -42,7 +46,7 @@ pi --profile default
|
|
|
42
46
|
| `instructions` | 每个 turn 追加到 system prompt 末尾 |
|
|
43
47
|
| `model` | 会话启动的模型预设;显式 `--model`/`--thinking` 或 session 历史中记录的模型优先 |
|
|
44
48
|
| `skills` | 模型在 prompt skills 列表中看到的内容;所有已安装 skill 仍保持加载,用户可用 `/skill:name` 手动调用 |
|
|
45
|
-
| `
|
|
49
|
+
| `mcps` | 本次会话暴露哪些 MCP server:白名单写入 adapter 自己的配置(`~/.pi/agent/mcp.json`),其余 server 一律标为 disabled。你原本放在该文件里的 server 会迁移到 `mcp.user.json`;连接参数只在你自己的配置里,从不进入 profile。旧键 `mcp` 仍可读取,下次保存 profile 时变为 `mcps` |
|
|
46
50
|
| `tools` | 活动工具集:声明的名字/glob 成为活动集合;之后才注册的工具(MCP、扩展)在出现时补上 |
|
|
47
51
|
|
|
48
52
|
未声明的字段保持 Pi 原生行为,`default` 什么都不声明。
|
|
@@ -69,7 +73,7 @@ pi --profile default
|
|
|
69
73
|
- **引用而非复制**——profile 指向你自己拥有和维护的资源。
|
|
70
74
|
- **Pi 原生**——配置目录就是 Pi 自己的目录,session、扩展配置、packages、context 文件和信任行为与原生 Pi 完全一致。
|
|
71
75
|
- **失败安全**——未信任的项目目录从不读取;激活失败时不应用任何设置并报出原因。
|
|
72
|
-
- **无需手动 reload**——切换在原位重新应用运行时状态,下一个 turn 的 prompt 直接带上新选择;只有 MCP 选择变化时才会自动重建运行时(同一 session
|
|
76
|
+
- **无需手动 reload**——切换在原位重新应用运行时状态,下一个 turn 的 prompt 直接带上新选择;只有 MCP 选择变化时才会自动重建运行时(同一 session),且切换结果在重建后保持不变:重建沿用你刚做的选择,不会退回启动时的 profile
|
|
73
77
|
|
|
74
78
|
## 文档
|
|
75
79
|
|
|
@@ -12,6 +12,7 @@ import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
|
12
12
|
import { readSessionChoices } from "../../src/model-selection.ts";
|
|
13
13
|
import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
|
|
14
14
|
import type { ProfileDefinition } from "../../src/profile-catalog.ts";
|
|
15
|
+
import { seedDefaultProfilesSync } from "../../src/default-profiles.ts";
|
|
15
16
|
import {
|
|
16
17
|
formatSelectionWarnings,
|
|
17
18
|
formatSkillWarnings,
|
|
@@ -26,8 +27,10 @@ import {
|
|
|
26
27
|
type SkillsFilterOutcome,
|
|
27
28
|
} from "../../src/skill-selection.ts";
|
|
28
29
|
import {
|
|
30
|
+
appliedProfile,
|
|
29
31
|
detectExplicitDeclarations,
|
|
30
32
|
readProfileFlag,
|
|
33
|
+
recordAppliedProfile,
|
|
31
34
|
registerProfileFlag,
|
|
32
35
|
resolveStartupProfile,
|
|
33
36
|
} from "../../src/startup-selection.ts";
|
|
@@ -69,10 +72,14 @@ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/sta
|
|
|
69
72
|
* all native.
|
|
70
73
|
*
|
|
71
74
|
* Responsibilities:
|
|
75
|
+
* - load: seed `<agentDir>/profiles.json` from the shipped default catalog
|
|
76
|
+
* when the file does not exist yet (Pi packages have no install hook), and
|
|
77
|
+
* report a failure once at `session_start`.
|
|
72
78
|
* - `session_start`: resolve the startup profile (`--profile <flag>`, else
|
|
73
|
-
* the saved selection, else `default
|
|
74
|
-
* the selection
|
|
75
|
-
*
|
|
79
|
+
* the saved selection, else `default`; every start after the first
|
|
80
|
+
* continues the selection this process already applied), then apply the
|
|
81
|
+
* runtime parts of the selection — model preset, active tools, MCP
|
|
82
|
+
* allowlist. A failed activation applies nothing and reports loudly.
|
|
76
83
|
* - `before_agent_start`: rebuild the system prompt each turn — replace the
|
|
77
84
|
* skills section with the profile's visible set and append the profile's
|
|
78
85
|
* instructions. Unselected skills stay loaded and `/skill:`-invocable.
|
|
@@ -123,11 +130,21 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
123
130
|
registerProfileFlag(pi);
|
|
124
131
|
const argv = process.argv.slice(2);
|
|
125
132
|
const explicit = detectExplicitDeclarations(argv);
|
|
133
|
+
const loadAgentDir = getAgentDir();
|
|
134
|
+
// Install-time default: Pi packages have no install hook, so the first
|
|
135
|
+
// load seeds <agentDir>/profiles.json from the shipped catalog. The write
|
|
136
|
+
// is idempotent, never overwrites a user catalog, and a failure is
|
|
137
|
+
// reported at session_start instead of blocking the load.
|
|
138
|
+
let seedWarning: string | undefined;
|
|
139
|
+
try {
|
|
140
|
+
seedDefaultProfilesSync(loadAgentDir);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
seedWarning = `pi-profile-switch: could not write the default profiles.json — ${error instanceof Error ? error.message : String(error)}`;
|
|
143
|
+
}
|
|
126
144
|
// pi-mcp-adapter reads its config before any session event fires (and, for
|
|
127
145
|
// eager servers, at its own load time), so the startup profile's overlay
|
|
128
146
|
// is generated here, synchronously. Pi applies CLI flag values only after
|
|
129
147
|
// extension loading, hence argv.
|
|
130
|
-
const loadAgentDir = getAgentDir();
|
|
131
148
|
const adapterInstalled = adapterPresent({
|
|
132
149
|
agentDir: loadAgentDir,
|
|
133
150
|
argv,
|
|
@@ -135,11 +152,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
135
152
|
});
|
|
136
153
|
if (adapterInstalled) {
|
|
137
154
|
const requestedConfigPath = readFlagFromArgv(argv, "mcp-config");
|
|
155
|
+
// A reload's overlay belongs to the run's current selection: the same
|
|
156
|
+
// continuation `session_start` applies right after this pass.
|
|
157
|
+
const runProfile = appliedProfile();
|
|
138
158
|
syncStartupMcpOverlay({
|
|
139
159
|
agentDir: loadAgentDir,
|
|
140
160
|
cwd: process.cwd(),
|
|
141
161
|
argv,
|
|
142
162
|
...(requestedConfigPath === undefined ? {} : { overridePath: requestedConfigPath }),
|
|
163
|
+
...(runProfile === undefined ? {} : { continuation: runProfile }),
|
|
143
164
|
});
|
|
144
165
|
}
|
|
145
166
|
let current: Activation | undefined;
|
|
@@ -243,6 +264,8 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
243
264
|
overlay: options?.overlay ?? null,
|
|
244
265
|
persist: options?.persist ?? true,
|
|
245
266
|
});
|
|
267
|
+
// The run's current selection, for the reload continuation.
|
|
268
|
+
recordAppliedProfile(result.selection.name);
|
|
246
269
|
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
247
270
|
reportWarnings(ctx, formatSelectionWarnings(result.selection));
|
|
248
271
|
return result;
|
|
@@ -461,11 +484,20 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
461
484
|
}
|
|
462
485
|
}
|
|
463
486
|
|
|
464
|
-
pi.on("session_start", async (
|
|
487
|
+
pi.on("session_start", async (event, ctx) => {
|
|
465
488
|
setCurrent(ctx, undefined);
|
|
466
489
|
filterWarningShown = false;
|
|
490
|
+
if (seedWarning !== undefined) {
|
|
491
|
+
notify(ctx, seedWarning, "warning");
|
|
492
|
+
seedWarning = undefined;
|
|
493
|
+
}
|
|
467
494
|
const agentDir = getAgentDir();
|
|
468
495
|
const projectTrusted = ctx.isProjectTrusted();
|
|
496
|
+
// Every session start after the first (reload, new, resume, fork)
|
|
497
|
+
// continues the selection this process already applied. The `--profile`
|
|
498
|
+
// flag is a startup directive: re-reading it here would silently
|
|
499
|
+
// resurrect the profile the user just left.
|
|
500
|
+
const continuation = event.reason === "startup" ? undefined : appliedProfile();
|
|
469
501
|
const requested = readProfileFlag(pi);
|
|
470
502
|
let startup;
|
|
471
503
|
try {
|
|
@@ -474,6 +506,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
474
506
|
cwd: ctx.cwd,
|
|
475
507
|
projectTrusted,
|
|
476
508
|
...(requested !== undefined ? { requested } : {}),
|
|
509
|
+
...(continuation !== undefined ? { continuation } : {}),
|
|
477
510
|
});
|
|
478
511
|
} catch (error) {
|
|
479
512
|
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
@@ -492,6 +525,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
492
525
|
});
|
|
493
526
|
}
|
|
494
527
|
} catch (error) {
|
|
528
|
+
recordAppliedProfile(undefined);
|
|
495
529
|
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
496
530
|
reportWarnings(ctx, startup.warnings);
|
|
497
531
|
}
|
|
@@ -687,7 +721,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
687
721
|
const reactivated = await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
|
|
688
722
|
notify(
|
|
689
723
|
ctx,
|
|
690
|
-
`${action}d MCP server "${server}" in profile "${profile.name}" (
|
|
724
|
+
`${action}d MCP server "${server}" in profile "${profile.name}" (mcps: [${result.mcps.join(", ")}])`,
|
|
691
725
|
"info",
|
|
692
726
|
);
|
|
693
727
|
await reloadForMcpOverlay(ctx, reactivated.selection);
|
package/package.json
CHANGED
|
@@ -44,12 +44,12 @@
|
|
|
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
|
-
"
|
|
47
|
+
"mcps": {
|
|
48
48
|
"type": "array",
|
|
49
49
|
"items": {
|
|
50
50
|
"type": "string"
|
|
51
51
|
},
|
|
52
|
-
"description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
|
|
52
|
+
"description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config. The legacy key \"mcp\" is still accepted on read and migrates to \"mcps\" on the next save."
|
|
53
53
|
},
|
|
54
54
|
"tools": {
|
|
55
55
|
"type": "array",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DefaultProfiles: the catalog a fresh install starts from.
|
|
3
|
+
*
|
|
4
|
+
* Pi packages have no install hook (docs/packages.md) — `pi install` only
|
|
5
|
+
* unpacks the package and runs `npm install` — so "installing the default
|
|
6
|
+
* profiles" can only happen the first time the extension actually loads.
|
|
7
|
+
* `seedDefaultProfilesSync` therefore runs at load time, before any session
|
|
8
|
+
* event, and is idempotent: an existing catalog file is never read, rewritten,
|
|
9
|
+
* or backed up, and the package never seeds again once the file exists.
|
|
10
|
+
*
|
|
11
|
+
* The seeded content is the shipped preset catalog, exactly what
|
|
12
|
+
* `examples/profiles.json` publishes, reached through the same
|
|
13
|
+
* `PROFILE_PRESETS` data `/profile create` offers — one definition, so the
|
|
14
|
+
* three cannot drift. The `read-only` profile declares built-in tools and
|
|
15
|
+
* instructions only: it activates on a machine with no skills, no
|
|
16
|
+
* `pi-mcp-adapter`, and no credentials, and it changes nothing until the
|
|
17
|
+
* user selects it.
|
|
18
|
+
*
|
|
19
|
+
* `profile-presets.test.ts` pins the content contract: this catalog equals
|
|
20
|
+
* the published `examples/profiles.json` and every definition is
|
|
21
|
+
* resource-free.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { DEFAULT_PROFILE_NAME, PROFILE_SCHEMA_VERSION } from "./profile-catalog.ts";
|
|
28
|
+
import { PROFILE_PRESETS } from "./profile-presets.ts";
|
|
29
|
+
|
|
30
|
+
/** The catalog written to `<agentDir>/profiles.json` on first load. */
|
|
31
|
+
export const DEFAULT_PROFILE_CATALOG = {
|
|
32
|
+
schemaVersion: PROFILE_SCHEMA_VERSION,
|
|
33
|
+
profiles: Object.fromEntries(
|
|
34
|
+
PROFILE_PRESETS.filter((preset) => preset.name !== DEFAULT_PROFILE_NAME).map((preset) => [
|
|
35
|
+
preset.name,
|
|
36
|
+
preset.definition,
|
|
37
|
+
]),
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DefaultSeedResult =
|
|
42
|
+
/** The agent dir had no catalog and now holds the default one. */
|
|
43
|
+
| "seeded"
|
|
44
|
+
/** A catalog file was already there; it was not touched. */
|
|
45
|
+
| "present";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Writes `<agentDir>/profiles.json` when it does not exist yet. Synchronous
|
|
49
|
+
* on purpose: the load-time call site cannot await, and the write is one small
|
|
50
|
+
* file. Existing files win unconditionally — an empty or hand-written catalog
|
|
51
|
+
* is a user decision, not a missing default.
|
|
52
|
+
*/
|
|
53
|
+
export function seedDefaultProfilesSync(agentDir: string, catalog: unknown = DEFAULT_PROFILE_CATALOG): DefaultSeedResult {
|
|
54
|
+
const target = path.join(agentDir, "profiles.json");
|
|
55
|
+
if (existsSync(target)) return "present";
|
|
56
|
+
mkdirSync(agentDir, { recursive: true });
|
|
57
|
+
writeFileSync(target, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
58
|
+
return "seeded";
|
|
59
|
+
}
|
package/src/profile-catalog.ts
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* - Unknown fields (an `extensions` declaration left over from v0.1.0, an
|
|
13
13
|
* inheritance key) are ignored silently; saving drops them, so a written
|
|
14
14
|
* definition always matches the current shape.
|
|
15
|
+
* - The catalog key for MCP servers is `mcps` (plural, like `skills` and
|
|
16
|
+
* `tools`). The legacy name `mcp` is still READ as an alias so an
|
|
17
|
+
* existing catalog keeps its allowlist; `mcps` wins when both keys are
|
|
18
|
+
* present, and a value of the wrong type fails loudly either way.
|
|
19
|
+
* Definitions always carry the canonical `mcps` field in memory, and
|
|
20
|
+
* profile-catalog-store.ts drops a file's legacy key on its next write.
|
|
15
21
|
*
|
|
16
22
|
* Invariants:
|
|
17
23
|
* - The built-in `default` profile never exists in either file and cannot be
|
|
@@ -44,7 +50,7 @@ export interface ProfileDefinition {
|
|
|
44
50
|
label?: string;
|
|
45
51
|
description?: string;
|
|
46
52
|
skills?: string[];
|
|
47
|
-
|
|
53
|
+
mcps?: string[];
|
|
48
54
|
tools?: string[];
|
|
49
55
|
model?: ProfileModel;
|
|
50
56
|
instructions?: string;
|
|
@@ -79,6 +85,27 @@ function readStringArray(value: unknown, field: string, profileName: string): st
|
|
|
79
85
|
return value as string[];
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/** Legacy catalog key names, still accepted on read (canonical name wins).
|
|
89
|
+
* The write paths (/profile CRUD, /mcp enable|disable) delete the alias, so
|
|
90
|
+
* a file migrates to the canonical spelling the next time it is saved. */
|
|
91
|
+
export const LEGACY_FIELD_ALIASES = { mcps: "mcp" } as const;
|
|
92
|
+
|
|
93
|
+
/** Reads an array-of-strings field under its canonical name or a legacy
|
|
94
|
+
* alias. Both spellings are validated when present, so a value of the
|
|
95
|
+
* wrong type never passes silently through either key. */
|
|
96
|
+
function readAliasedStringArray(
|
|
97
|
+
raw: Record<string, unknown>,
|
|
98
|
+
canonical: string,
|
|
99
|
+
legacy: string,
|
|
100
|
+
profileName: string,
|
|
101
|
+
): string[] | undefined {
|
|
102
|
+
for (const key of [canonical, legacy]) {
|
|
103
|
+
if (raw[key] !== undefined) readStringArray(raw[key], key, profileName);
|
|
104
|
+
}
|
|
105
|
+
const source = raw[canonical] !== undefined ? canonical : legacy;
|
|
106
|
+
return readStringArray(raw[source], source, profileName);
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
|
|
83
110
|
if (value === undefined) return undefined;
|
|
84
111
|
if (typeof value !== "string") {
|
|
@@ -100,10 +127,12 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
|
|
|
100
127
|
if (label !== undefined) definition.label = label;
|
|
101
128
|
const description = readOptionalString(raw.description, "description", name);
|
|
102
129
|
if (description !== undefined) definition.description = description;
|
|
103
|
-
for (const field of ["skills", "
|
|
130
|
+
for (const field of ["skills", "tools"] as const) {
|
|
104
131
|
const entries = readStringArray(raw[field], field, name);
|
|
105
132
|
if (entries !== undefined) definition[field] = entries;
|
|
106
133
|
}
|
|
134
|
+
const mcps = readAliasedStringArray(raw, "mcps", LEGACY_FIELD_ALIASES.mcps, name);
|
|
135
|
+
if (mcps !== undefined) definition.mcps = mcps;
|
|
107
136
|
if (raw.model !== undefined) {
|
|
108
137
|
if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
|
|
109
138
|
throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
|
package/src/profile-resolver.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* ADR-0007 semantics:
|
|
6
6
|
* - `skills` resolves to a visibility filter (see skill-selection.ts), not
|
|
7
7
|
* to loaded resources: every skill stays loaded and user-invocable.
|
|
8
|
-
* - `
|
|
8
|
+
* - `mcps` resolves to a runtime server allowlist; a declared MCP intent that
|
|
9
9
|
* cannot be satisfied (adapter absent, literal server unknown) fails the
|
|
10
10
|
* activation before anything is applied.
|
|
11
11
|
* - `tools` resolves to an active tool set; literals the live registry does
|
|
@@ -136,7 +136,7 @@ function resolveMcp(
|
|
|
136
136
|
if (!live.adapterPresent) {
|
|
137
137
|
throw new SelectionError(
|
|
138
138
|
`profile "${profileName}" declares MCP servers but pi-mcp-adapter is not active in this session — ` +
|
|
139
|
-
`install the adapter or remove the "
|
|
139
|
+
`install the adapter or remove the "mcps" declaration`,
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
const selected: string[] = [];
|
|
@@ -201,7 +201,7 @@ export function resolveSelection(input: {
|
|
|
201
201
|
const definition = profile.definition;
|
|
202
202
|
|
|
203
203
|
const skills = resolveSkills(definition.skills, overlay?.disabledSkills ?? [], live.skills);
|
|
204
|
-
const mcp = resolveMcp(definition.
|
|
204
|
+
const mcp = resolveMcp(definition.mcps, overlay?.disabledMcp ?? [], live.mcp, profile.name);
|
|
205
205
|
const tools = input.suppressTools === true
|
|
206
206
|
? { pendingTools: [], warning: { toolsUnmatched: [] } }
|
|
207
207
|
: resolveTools(overlay?.tools ?? definition.tools, live.toolNames);
|
|
@@ -33,6 +33,11 @@ export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd
|
|
|
33
33
|
export interface RuntimeState {
|
|
34
34
|
activeProfile?: string;
|
|
35
35
|
overlay?: RuntimeOverlay;
|
|
36
|
+
/** Patch-only marker: clears `activeProfile` from the store it is sent
|
|
37
|
+
* to, and to that store alone. A profile switch sends it to the OTHER
|
|
38
|
+
* scope's store, so a stale project selection cannot shadow the new
|
|
39
|
+
* global choice on the next startup. Never stored, never read back. */
|
|
40
|
+
otherActiveProfile?: undefined;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface RuntimeOverlay {
|
|
@@ -112,6 +117,7 @@ export class RuntimeStateStore {
|
|
|
112
117
|
if (patch.overlay === undefined) delete next.overlay;
|
|
113
118
|
else next.overlay = patch.overlay;
|
|
114
119
|
}
|
|
120
|
+
if ("otherActiveProfile" in patch) delete next.activeProfile;
|
|
115
121
|
await this.write(next);
|
|
116
122
|
return next;
|
|
117
123
|
}
|
package/src/startup-mcp-scope.ts
CHANGED
|
@@ -73,14 +73,19 @@ export function resolveProjectTrustedSync(agentDir: string, cwd: string): boolea
|
|
|
73
73
|
return readDefaultProjectTrustSync(agentDir) === "always";
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/** The
|
|
77
|
-
* (
|
|
76
|
+
/** The profile this pass generates the overlay for: this process's already
|
|
77
|
+
* applied selection (`continuation`) first — a reload must not regress to
|
|
78
|
+
* the `--profile` flag — then the flag, then the saved selection: project
|
|
79
|
+
* state wins over global state (project state only when trusted), then the
|
|
80
|
+
* built-in default. */
|
|
78
81
|
export function resolveStartupProfileNameSync(input: {
|
|
79
82
|
agentDir: string;
|
|
80
83
|
cwd: string;
|
|
81
84
|
projectTrusted: boolean;
|
|
82
85
|
argv: readonly string[];
|
|
86
|
+
continuation?: string;
|
|
83
87
|
}): string {
|
|
88
|
+
if (input.continuation !== undefined && input.continuation.length > 0) return input.continuation;
|
|
84
89
|
const requested = readFlagFromArgv(input.argv, "profile");
|
|
85
90
|
if (requested !== undefined && requested.length > 0) return requested;
|
|
86
91
|
const project = input.projectTrusted
|
|
@@ -97,6 +102,9 @@ export interface McpOverlaySyncInput {
|
|
|
97
102
|
projectTrusted?: boolean;
|
|
98
103
|
/** Effective `--mcp-config`; undefined means the managed overlay path. */
|
|
99
104
|
overridePath?: string;
|
|
105
|
+
/** The selection this process already applied: a reload's overlay belongs
|
|
106
|
+
* to it, not to the `--profile` flag. */
|
|
107
|
+
continuation?: string;
|
|
100
108
|
/** The command line, for the load pass (Pi applies flag values only after
|
|
101
109
|
* extension loading, so `--profile` is read from here). */
|
|
102
110
|
argv?: readonly string[];
|
|
@@ -126,6 +134,7 @@ export function syncStartupMcpOverlay(
|
|
|
126
134
|
cwd: input.cwd,
|
|
127
135
|
projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
|
|
128
136
|
argv: input.argv ?? [],
|
|
137
|
+
...(input.continuation === undefined ? {} : { continuation: input.continuation }),
|
|
129
138
|
});
|
|
130
139
|
const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
|
|
131
140
|
return writeOverlayForRefs({ ...input, refs });
|
|
@@ -228,7 +237,7 @@ function readActiveProfile(statePath: string): string | undefined {
|
|
|
228
237
|
return typeof name === "string" && name.length > 0 ? name : undefined;
|
|
229
238
|
}
|
|
230
239
|
|
|
231
|
-
/** The profile's raw `
|
|
240
|
+
/** The profile's raw `mcps` references; `"unknown"` when the profile or its
|
|
232
241
|
* catalog cannot be read (caller falls back to "no filtering"). */
|
|
233
242
|
function readProfileMcpRefsSync(
|
|
234
243
|
input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
|
|
@@ -241,7 +250,7 @@ function readProfileMcpRefsSync(
|
|
|
241
250
|
: new Map<string, ProfileDefinition>();
|
|
242
251
|
if (global === "error" || project === "error") return "unknown";
|
|
243
252
|
const definition = project.get(input.name) ?? global.get(input.name);
|
|
244
|
-
return definition === undefined ? "unknown" : definition.
|
|
253
|
+
return definition === undefined ? "unknown" : definition.mcps;
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
|
package/src/startup-selection.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* one-run selection: it is never written back to runtime state.
|
|
6
6
|
* - Without the flag the saved selection applies: the trusted project's
|
|
7
7
|
* state wins over the global state, then the built-in `default`.
|
|
8
|
+
* - Every session start after the first continues THIS process's selection
|
|
9
|
+
* (`continuation`): the flag is a startup directive, and re-reading it
|
|
10
|
+
* after an in-session switch would resurrect the profile the user just
|
|
11
|
+
* left. A continuation that no longer resolves falls back like a saved
|
|
12
|
+
* selection, with a warning, because a deleted profile must not strand the
|
|
13
|
+
* runtime.
|
|
8
14
|
* - A saved selection that no longer resolves falls back to `default` with
|
|
9
15
|
* a warning — restore is a convenience, not a commitment. An explicit
|
|
10
16
|
* flag value never falls back: an unknown name is a loud error.
|
|
@@ -94,19 +100,70 @@ async function legacyRegistryWarnings(input: {
|
|
|
94
100
|
return warnings;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
/**
|
|
98
|
-
*
|
|
103
|
+
/**
|
|
104
|
+
* The profile this process last applied. Kept on `globalThis` because Pi
|
|
105
|
+
* re-imports extension modules on every reload (jiti) — module state resets
|
|
106
|
+
* exactly when it is needed. It is a run-scoped fact, not saved state: the
|
|
107
|
+
* `--profile` flag must not resurrect itself over an in-session switch when
|
|
108
|
+
* the MCP overlay change rebuilds the runtime.
|
|
109
|
+
*/
|
|
110
|
+
const RUN_SELECTION_KEY = "pi-profile-switch:applied-profile";
|
|
111
|
+
|
|
112
|
+
interface RunSelection {
|
|
113
|
+
appliedProfile?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runSelection(): RunSelection {
|
|
117
|
+
const holder = globalThis as unknown as Record<string, RunSelection | undefined>;
|
|
118
|
+
const current = holder[RUN_SELECTION_KEY] ?? {};
|
|
119
|
+
holder[RUN_SELECTION_KEY] = current;
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Records the profile this process applied (startup or in-session).
|
|
124
|
+
* `undefined` clears the record; a fresh process starts empty. */
|
|
125
|
+
export function recordAppliedProfile(name: string | undefined): void {
|
|
126
|
+
runSelection().appliedProfile = name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The profile this process is running, or undefined before the first
|
|
130
|
+
* activation. */
|
|
131
|
+
export function appliedProfile(): string | undefined {
|
|
132
|
+
return runSelection().appliedProfile;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Resolves the profile name for this session: the run's current selection
|
|
136
|
+
* (`continuation`) → explicit flag → trusted project state → global state →
|
|
137
|
+
* default. Never writes state.
|
|
138
|
+
*
|
|
139
|
+
* `requested` is the CLI flag: an unknown name is a loud error.
|
|
140
|
+
* `continuation` is what this process already applied, passed on every
|
|
141
|
+
* session start after the first: it keeps the in-session switch, and falls
|
|
142
|
+
* back (with a warning) when the name no longer resolves. */
|
|
99
143
|
export async function resolveStartupProfile(input: {
|
|
100
144
|
agentDir: string;
|
|
101
145
|
cwd: string;
|
|
102
146
|
projectTrusted: boolean;
|
|
103
147
|
requested?: string;
|
|
148
|
+
continuation?: string;
|
|
104
149
|
}): Promise<StartupProfile> {
|
|
105
150
|
const catalog = await ProfileCatalog.load(input.agentDir, {
|
|
106
151
|
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
107
152
|
});
|
|
108
153
|
const warnings: string[] = await legacyRegistryWarnings(input);
|
|
109
154
|
|
|
155
|
+
// The continuation first: this process already applied it, so the flag
|
|
156
|
+
// (a startup directive) has had its say.
|
|
157
|
+
if (input.continuation !== undefined) {
|
|
158
|
+
if (catalog.resolve(input.continuation) !== undefined) {
|
|
159
|
+
return { name: input.continuation, warnings };
|
|
160
|
+
}
|
|
161
|
+
warnings.push(
|
|
162
|
+
`profile ${JSON.stringify(input.continuation)} no longer exists — restoring the saved selection; ` +
|
|
163
|
+
`available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
110
167
|
if (input.requested !== undefined) {
|
|
111
168
|
if (catalog.resolve(input.requested) === undefined) {
|
|
112
169
|
throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
|
|
@@ -82,6 +82,18 @@ export async function resolveProfileSelection(
|
|
|
82
82
|
return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Drops a stale saved selection from the scope that no longer owns it. A
|
|
86
|
+
* project state file left behind by a previous project profile shadows the
|
|
87
|
+
* new global choice on the next startup (project state wins), so the switch
|
|
88
|
+
* path clears it; the scope's overlay stays, because that belongs to the
|
|
89
|
+
* profile it was created for. */
|
|
90
|
+
async function clearOtherSelection(stateDir: string): Promise<void> {
|
|
91
|
+
const store = new RuntimeStateStore(stateDir);
|
|
92
|
+
if ((await store.read()).activeProfile !== undefined) {
|
|
93
|
+
await store.update({ otherActiveProfile: undefined });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
/** Activates a profile: resolve, validate, optionally persist, apply. */
|
|
86
98
|
export async function activateProfile(
|
|
87
99
|
name: string,
|
|
@@ -100,10 +112,27 @@ export async function activateProfile(
|
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
if (options?.persist !== false) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
// Exactly one scope holds the saved selection: the project state file
|
|
116
|
+
// for a project profile, the global one otherwise (including the
|
|
117
|
+
// built-in `default`). A project profile is only reachable in a
|
|
118
|
+
// trusted project, so an untrusted project is skipped exactly like
|
|
119
|
+
// its catalog read — its state file stays untouched (ADR-0007).
|
|
120
|
+
const ownDir = stateDirFor(selection.source, deps);
|
|
121
|
+
const projectDir = stateDirFor("project", deps);
|
|
122
|
+
const writable = selection.source !== "project" || deps.projectTrusted;
|
|
123
|
+
if (writable) {
|
|
124
|
+
await new RuntimeStateStore(ownDir).update({
|
|
125
|
+
activeProfile: selection.name,
|
|
126
|
+
overlay: overlay ?? undefined,
|
|
127
|
+
});
|
|
128
|
+
// The scope that owned the previous selection must let go of it,
|
|
129
|
+
// or the next startup restores it: project state wins over global.
|
|
130
|
+
// An untrusted project is never read or written here either.
|
|
131
|
+
const otherDir = selection.source === "project" ? stateDirFor("global", deps) : projectDir;
|
|
132
|
+
if (otherDir !== ownDir && (otherDir !== projectDir || deps.projectTrusted)) {
|
|
133
|
+
await clearOtherSelection(otherDir);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
const result = await applySelection({ selection, surface: deps.surface, preset });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* McpToggle: persistent, profile-scoped `/mcp enable|disable` (ticket 10).
|
|
3
3
|
*
|
|
4
|
-
* The profile's `
|
|
4
|
+
* The profile's `mcps` array in its OWNING catalog is the profile-scoped
|
|
5
5
|
* state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
|
|
6
6
|
* allowlist/profile-state API — ADR-0002's assumed store does not exist;
|
|
7
7
|
* pi-profile-switch owns the contract). The runtime effect is the caller's
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { discoverAdapterServerNames } from "../mcp-config.ts";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
CatalogError,
|
|
25
|
+
DEFAULT_PROFILE_NAME,
|
|
26
|
+
LEGACY_FIELD_ALIASES,
|
|
27
|
+
type ProfileDefinition,
|
|
28
|
+
type ProfileSource,
|
|
29
|
+
} from "../profile-catalog.ts";
|
|
24
30
|
import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
|
|
25
31
|
|
|
26
32
|
export async function setMcpServerEnabled(
|
|
@@ -32,7 +38,7 @@ export async function setMcpServerEnabled(
|
|
|
32
38
|
},
|
|
33
39
|
server: string,
|
|
34
40
|
enabled: boolean,
|
|
35
|
-
): Promise<{
|
|
41
|
+
): Promise<{ mcps: string[]; changed: boolean }> {
|
|
36
42
|
const { name, source } = input.profile;
|
|
37
43
|
if (name === DEFAULT_PROFILE_NAME || source === "builtin") {
|
|
38
44
|
throw new CatalogError(
|
|
@@ -60,16 +66,18 @@ export async function setMcpServerEnabled(
|
|
|
60
66
|
throw new CatalogError(`profile "${input.profile.name}" not found in the ${scope} catalog`);
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
const current = definition.
|
|
69
|
+
const current = definition.mcps ?? [];
|
|
64
70
|
if (enabled === current.includes(server)) {
|
|
65
|
-
return {
|
|
71
|
+
return { mcps: current, changed: false }; // already in the requested state
|
|
66
72
|
}
|
|
67
73
|
const next = enabled ? [...current, server] : current.filter((name) => name !== server);
|
|
68
74
|
// Drop the key entirely when empty (exactOptionalPropertyTypes; a
|
|
69
|
-
// written `
|
|
75
|
+
// written `mcps: undefined` would also misrepresent the definition) and
|
|
76
|
+
// drop the legacy alias, so one save migrates the file to `mcps`.
|
|
70
77
|
const rest = { ...definition };
|
|
71
|
-
delete rest.
|
|
72
|
-
const updated: ProfileDefinition = next.length > 0 ? { ...rest,
|
|
78
|
+
delete rest.mcps;
|
|
79
|
+
const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcps: next } : rest;
|
|
80
|
+
delete (updated as Record<string, unknown>)[LEGACY_FIELD_ALIASES.mcps];
|
|
73
81
|
await catalogStore(input, scope).upsert(input.profile.name, updated);
|
|
74
|
-
return {
|
|
82
|
+
return { mcps: next, changed: true };
|
|
75
83
|
}
|
|
@@ -75,7 +75,7 @@ async function captureDefinition(
|
|
|
75
75
|
|
|
76
76
|
const listFields = [
|
|
77
77
|
["skills", "skills (comma-separated names or globs, empty = all visible)"],
|
|
78
|
-
["
|
|
78
|
+
["mcps", "MCP servers (names or globs, empty = none)"],
|
|
79
79
|
["tools", "tools (names or globs, empty = pi default set)"],
|
|
80
80
|
] as const;
|
|
81
81
|
for (const [field, prompt] of listFields) {
|