pi-profile-switch 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,10 @@ How fields resolve:
100
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
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`.
102
102
  - `mcps` references servers from your pi-mcp-adapter configuration; connection details stay in the adapter's own config.
103
+ - **Omitting `mcps`** leaves all discovered user-level servers at their normal adapter-defined availability.
104
+ - **`mcps: []` with `pi-mcp-adapter` selected** disables every discovered user-level server (shared locations are explicitly marked `disabled`; agentDir-only servers are omitted from the generated instance `mcp.json`).
105
+ - **`mcps: []` without `pi-mcp-adapter`** is inert: it does not read MCP configuration, change server availability, or fail because of a malformed config file.
106
+ - Trusted project-level MCP servers are always kept enabled and are never narrowed by `mcps`.
103
107
  - Any field you omit keeps plain Pi behavior.
104
108
 
105
109
  The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
package/README.zh-CN.md CHANGED
@@ -100,6 +100,10 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
100
100
  - 非空的 `mcp_tools` 即使在省略 `mcps` 时也需要 `pi-mcp-adapter`。只有当 profile selector 与已有的 `includeTools` 字面值完全相同,或已有列表是 `"*"` 时才能安全组合;否则会在写入 runtime 文件前报错。已有的 `excludeTools` 限制仍然生效。
101
101
  - **迁移提示**:旧 profile 中写入 `tools` 的 MCP 工具名称或 glob(如 `mcp__*`、`<server>_*`)不再控制 MCP 访问;如有需要请迁移至 `mcp_tools`。
102
102
  - `mcps` 引用 pi-mcp-adapter 配置中的 server;连接细节留在 adapter 自己的配置里。
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` 收窄。
103
107
  - 未写的字段保持原生 Pi 行为。
104
108
 
105
109
  [`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
@@ -234,6 +234,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
234
234
  overlay: state.overlay,
235
235
  discoveredMcpServers,
236
236
  disabledMcpServers,
237
+ projectMcpServers: [...mcpDiscovery.projectServers].sort(),
237
238
  commands: pi.getCommands(),
238
239
  tools: pi.getAllTools(),
239
240
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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": [
@@ -54,7 +54,7 @@ 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, `mcps` itself adds no adapter requirement; a nonempty `mcp_tools` still requires `pi-mcp-adapter`. |
57
+ | `mcps` | `string[]` | MCP server names or globs to reference. When undeclared, all discovered user-level servers keep their normal adapter availability and `mcps` adds no adapter requirement; a nonempty `mcp_tools` still requires `pi-mcp-adapter`. An explicit `mcps: []` disables every discovered user-level server when `pi-mcp-adapter` is selected, and is inert when the adapter is not selected. Project-level servers are never narrowed. |
58
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
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. |
60
60
  | `defaultProvider` | `string` | Default model provider (e.g. `"anthropic"`, `"openai"`). Effective only when declared together with `defaultModel`. |
@@ -70,7 +70,9 @@ All fields are optional. Undeclared fields keep native Pi behavior or current st
70
70
  "skills": [
71
71
  "profile-config"
72
72
  ],
73
- "extensions": [],
73
+ "extensions": [
74
+ "pi-mcp-adapter"
75
+ ],
74
76
  "mcps": [],
75
77
  "tools": [
76
78
  "read",
@@ -158,10 +158,20 @@ export async function resolveInitialProfile(
158
158
  return { plan, discovery, projectDir, projectTrusted, warnings };
159
159
  }
160
160
 
161
+ const discovery = await discoverLauncherResources({ ...context, projectTrusted });
162
+
161
163
  const mcpToolsDef = (profile.definition as { mcp_tools?: Record<string, string[]> }).mcp_tools;
162
164
  const hasMcpTools = mcpToolsDef !== undefined && Object.keys(mcpToolsDef).length > 0;
165
+ const declaredMcps = profile.definition.mcps;
166
+ const isEmptyMcps = declaredMcps !== undefined && declaredMcps.length === 0;
167
+ let adapterSelectedForEmptyMcps = false;
168
+ if (isEmptyMcps) {
169
+ const extSelection = await discovery.extensions.select(profile.definition.extensions ?? []);
170
+ adapterSelectedForEmptyMcps = extSelection.entries.some(isAdapterExtension);
171
+ }
163
172
  const needsMcp = Boolean(
164
- profile.definition.mcps?.length ||
173
+ (declaredMcps?.length ?? 0) > 0 ||
174
+ (isEmptyMcps && adapterSelectedForEmptyMcps) ||
165
175
  hasMcpTools ||
166
176
  (options?.overlay?.disabledMcps?.length ?? 0) > 0,
167
177
  );
@@ -169,7 +179,6 @@ export async function resolveInitialProfile(
169
179
  ? await loadMergedMcpServers(context.agentDir, projectDir)
170
180
  : undefined;
171
181
 
172
- const discovery = await discoverLauncherResources({ ...context, projectTrusted });
173
182
  const plan = await resolveProfile({
174
183
  profile,
175
184
  skills: discovery.skills,
@@ -228,23 +228,14 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
228
228
  input.discoveredMcpServers = Object.keys(input.mcpDiscovery.servers).sort();
229
229
  }
230
230
 
231
- let mcps: string[] | undefined;
232
- if (definition.mcps !== undefined && definition.mcps.length > 0) {
233
- if (input.discoveredMcpServers === undefined) {
234
- throw new ActivationError(
235
- `profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
236
- );
237
- }
238
- mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
239
- onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
240
- });
241
- }
242
-
243
231
  let selectedSkills = expandReferences(definition.skills ?? [], skills, (skill) => skill.name, "skill", {
244
232
  onZeroMatch: (reference) => unmatched.push(`skill:${reference}`),
245
233
  });
246
234
 
247
235
  // Extension references resolve directly against discovered extensions.
236
+ // Resolve extensions before MCP so an explicitly empty `mcps` array can
237
+ // be distinguished from omission based on whether the profile selects the
238
+ // adapter (honor-empty-mcp-allowlist).
248
239
  const selection = await extensions.select(definition.extensions ?? []);
249
240
  for (const reference of selection.unmatched) unmatched.push(`extension:${reference}`);
250
241
  let planExtensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }> = selection.entries.map(
@@ -255,6 +246,29 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
255
246
  }),
256
247
  );
257
248
 
249
+ const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
250
+
251
+ let mcps: string[] | undefined;
252
+ if (definition.mcps !== undefined) {
253
+ if (definition.mcps.length > 0) {
254
+ if (input.discoveredMcpServers === undefined) {
255
+ throw new ActivationError(
256
+ `profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
257
+ );
258
+ }
259
+ mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
260
+ onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
261
+ });
262
+ } else if (hasAdapter) {
263
+ if (input.mcpDiscovery === undefined) {
264
+ throw new ActivationError(
265
+ `profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
266
+ );
267
+ }
268
+ mcps = [];
269
+ }
270
+ }
271
+
258
272
  // --- overlay narrowing (ticket 06) ---
259
273
  // Overlay disable entries are names or globs stored as written and
260
274
  // re-expanded at every resolution; unmatched literals fail and identify
@@ -21,8 +21,7 @@
21
21
  * (`workspaceDir`, i.e. `getProfileSwitchDir()`).
22
22
  */
23
23
 
24
- import { createHash } from "node:crypto";
25
- import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
24
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
26
25
  import path from "node:path";
27
26
 
28
27
  import { isRecord } from "./json-file.ts";
@@ -61,9 +60,6 @@ const MAX_ANNOUNCEMENTS = 50;
61
60
  const MAX_ID_LENGTH = 128;
62
61
  const MAX_MESSAGE_LENGTH = 400;
63
62
  const MAX_ACTION_LENGTH = 200;
64
- /** A pending display claim older than this is considered abandoned (the
65
- * presenting process died mid-display) and may be reclaimed. */
66
- const CLAIM_STALE_MS = 10 * 60 * 1000;
67
63
  const HISTORY_SCHEMA_VERSION = 1;
68
64
 
69
65
  export interface NoticeSurface {
@@ -276,7 +272,7 @@ function parseNpmLatest(text: string): string {
276
272
  }
277
273
 
278
274
  // ---------------------------------------------------------------------------
279
- // Global history and exclusive display claims
275
+ // Global history
280
276
  // ---------------------------------------------------------------------------
281
277
 
282
278
  function notificationsDir(workspaceDir: string): string {
@@ -316,41 +312,6 @@ async function recordDisplayed(dir: string, key: string): Promise<void> {
316
312
  await atomicWriteJson(path.join(dir, "displayed.json"), { schemaVersion: HISTORY_SCHEMA_VERSION, keys: [...keys] });
317
313
  }
318
314
 
319
- function claimFile(dir: string, key: string): string {
320
- const digest = createHash("sha256").update(key).digest("hex");
321
- return path.join(dir, "claims", `${digest}.json`);
322
- }
323
-
324
- /** Exclusive per-key claim around synchronous presentation: concurrent
325
- * launches (same process or separate ones) cannot both display the same
326
- * notice. Abandoned claims go stale and become reclaimable. */
327
- async function tryAcquireClaim(dir: string, key: string, nowMs: number): Promise<boolean> {
328
- const file = claimFile(dir, key);
329
- await mkdir(path.dirname(file), { recursive: true });
330
- for (let attempt = 0; attempt < 2; attempt++) {
331
- try {
332
- const handle = await open(file, "wx");
333
- try {
334
- await handle.writeFile(JSON.stringify({ key, at: nowMs }));
335
- } finally {
336
- await handle.close();
337
- }
338
- return true;
339
- } catch (error) {
340
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
341
- const existing = await readJsonQuiet(file);
342
- const at = isRecord(existing) && typeof existing.at === "number" ? existing.at : 0;
343
- if (nowMs - at <= CLAIM_STALE_MS) return false;
344
- await rm(file, { force: true });
345
- }
346
- }
347
- return false;
348
- }
349
-
350
- async function releaseClaim(dir: string, key: string): Promise<void> {
351
- await rm(claimFile(dir, key), { force: true });
352
- }
353
-
354
315
  // ---------------------------------------------------------------------------
355
316
  // Candidate evaluation and presentation
356
317
  // ---------------------------------------------------------------------------
@@ -425,16 +386,8 @@ function report(surface: NoticeSurface, message: string): void {
425
386
  }
426
387
 
427
388
  async function present(dir: string, candidate: Candidate, surface: NoticeSurface, nowMs: number): Promise<void> {
428
- if (!(await tryAcquireClaim(dir, candidate.key, nowMs))) return;
429
- try {
430
- surface.display(candidate.message, candidate.level);
431
- await recordDisplayed(dir, candidate.key);
432
- } catch (error) {
433
- // A failed presentation releases its claim so a later launch retries.
434
- await releaseClaim(dir, candidate.key);
435
- throw error;
436
- }
437
- await releaseClaim(dir, candidate.key);
389
+ surface.display(candidate.message, candidate.level);
390
+ await recordDisplayed(dir, candidate.key);
438
391
  }
439
392
 
440
393
  // ---------------------------------------------------------------------------
@@ -73,6 +73,9 @@ export function buildStatusReport(input: {
73
73
  overlay?: RuntimeOverlay;
74
74
  discoveredMcpServers: string[];
75
75
  disabledMcpServers: string[];
76
+ /** Project-owned server names from trust-gated MCP discovery; kept enabled
77
+ * when the profile declares a defined `mcps` selection. */
78
+ projectMcpServers?: string[];
76
79
  commands: RegisteredCommand[];
77
80
  tools: RegisteredTool[];
78
81
  }): StatusReport {
@@ -89,9 +92,15 @@ export function buildStatusReport(input: {
89
92
  disabled = input.discoveredMcpServers.filter((name) => adapterDisabled.has(name));
90
93
  missing = [];
91
94
  } else {
92
- enabled = plan.mcps;
95
+ const projectMcpServers = new Set(input.projectMcpServers ?? []);
96
+ const adapterDisabled = new Set(input.disabledMcpServers);
97
+ const explicitMcps = new Set(plan.mcps);
98
+ const projectEnabled = input.discoveredMcpServers.filter(
99
+ (name) => projectMcpServers.has(name) && !explicitMcps.has(name) && !adapterDisabled.has(name),
100
+ );
101
+ enabled = [...plan.mcps, ...projectEnabled].sort();
93
102
  disabled = input.discoveredMcpServers.filter((name) => !enabled.includes(name));
94
- missing = enabled.filter((name) => !discovered.has(name));
103
+ missing = plan.mcps.filter((name) => !discovered.has(name));
95
104
  }
96
105
 
97
106
  const mcp = { enabled, disabled, missing };