pi-profile-switch 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,27 +97,19 @@ How fields resolve:
97
97
 
98
98
  The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
99
99
 
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
100
  ## Commands
109
101
 
110
- In the TUI, the `/profile` command family manages everything in-session:
102
+ The `/profile` command family manages everything in-session:
111
103
 
112
104
  | Command | What it does |
113
105
  | --- | --- |
114
- | `/profile` | Interactive profile picker |
115
- | `/profile list` / `/profile status` | Show profiles / active profile details |
106
+ | `/profile` | Interactive profile picker; without interactive UI it prints the profile list instead |
116
107
  | `/profile use <name>` / `/profile reload` | Switch / reload without restarting (rollback on failure) |
117
- | `/profile create\|edit\|delete\|duplicate` | Guided profile CRUD (TUI only) |
118
- | `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
108
+ | `/profile status` | Active profile details: resolved resources and paths, stored overlay, MCP server tri-state |
109
+ | `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | Narrow / un-narrow the active profile for this session only; `disable` entries accept names or globs |
110
+ | `/profile overlay clear` | Discard the overlay and reactivate the profile exactly as declared |
119
111
 
120
- All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizards are TUI-only.
112
+ 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
113
 
122
114
  ## Docs
123
115
 
package/README.zh-CN.md CHANGED
@@ -97,27 +97,19 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
97
97
 
98
98
  [`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
99
99
 
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
100
  ## 命令
109
101
 
110
- 在 TUI 中,`/profile` 命令族完成所有会话内操作:
102
+ `/profile` 命令族完成所有会话内操作:
111
103
 
112
104
  | 命令 | 作用 |
113
105
  | --- | --- |
114
- | `/profile` | 交互式选择 profile |
115
- | `/profile list` / `/profile status` | 列出 profile / 查看活动 profile 详情 |
106
+ | `/profile` | 交互式选择 profile;无交互界面时打印 profile 列表 |
116
107
  | `/profile use <name>` / `/profile reload` | 会话内切换 / 重载(失败自动回滚) |
117
- | `/profile create\|edit\|delete\|duplicate` | 向导式 profile 增删改(仅 TUI) |
118
- | `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
108
+ | `/profile status` | 查看活动 profile 详情:解析后的资源与路径、已存 overlay、MCP server 三态 |
109
+ | `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | 仅本次会话收窄 / 恢复活动 profile;`disable` 接受名称或 glob |
110
+ | `/profile overlay clear` | 丢弃 overlay,完全按定义重新激活 profile |
119
111
 
120
- 非交互模式(`--mode rpc|print|json`)下命令同样生效;CRUD 向导仅 TUI 可用。
112
+ 所有命令在非交互模式(`--mode rpc|print|json`)下同样生效;裸 `/profile` 在无交互界面时降级为 profile 列表。overlay 是会话级收窄:绝不写入 catalog 文件,也不会跨重启保留。tool 与其他资源类别使用相同的 disable/enable 模型:tool `disable` 条目收窄 profile 解析出的工具引用;profile 未声明 `tools` 时,则收窄运行时的全部可用工具集。
121
113
 
122
114
  ## 文档
123
115
 
@@ -1,29 +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
9
  import { discoverAdapterServerNames } 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
- import { CUSTOMIZE_USAGE, customizeOverlay, parseCustomizeArgs, resetOverlay } from "../../src/switching/customize.ts";
13
+ import { OVERLAY_USAGE, applyOverlayMutation, clearOverlay, parseOverlayArgs } from "../../src/switching/overlay.ts";
10
14
  import { formatProfileList, listProfiles } from "../../src/switching/list-profiles.ts";
11
- import {
12
- createProfile,
13
- deleteProfile,
14
- duplicateProfile,
15
- editProfile,
16
- readCatalogScope,
17
- } from "../../src/switching/profile-crud.ts";
18
- import type { ProfileDefinition } from "../../src/profile-catalog.ts";
19
- import {
20
- runProfileCreateWizard,
21
- runProfileDuplicateWizard,
22
- runProfileEditWizard,
23
- } from "../../src/switching/profile-wizard.ts";
24
15
  import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
25
16
  import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
26
- import { getGlobalStateDir } from "../../src/workspace.ts";
17
+ import { getGlobalStateDir, getProfileSwitchDir } from "../../src/workspace.ts";
27
18
 
28
19
  /**
29
20
  * pi-profile extension entry.
@@ -37,23 +28,73 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
37
28
  * via `before_agent_start`.
38
29
  * - `/profile use <name>` / `/profile reload`: in-session switching without
39
30
  * restarting the Pi process (src/switching/switch-profile.ts).
40
- * - `/profile customize` / `/profile reset`: runtime overlay (ticket 06).
41
- * - `/profile` (selector), `/profile list`, `/profile status`:
42
- * observability surface (ticket 07). Status combines the active launch
43
- * plan, the stored overlay, fresh MCP discovery, and Pi's actual command
44
- * registrations (the winner evidence for same-name conflicts).
45
- * - `/profile create|edit|delete|duplicate`: catalog CRUD wizards (ticket 09).
46
- * CRUD is TUI-only (ticket 11): gated on `ctx.mode === "tui"` with a
47
- * mode-aware refusal. Mutations apply via the standard reload path;
48
- * mutation success is notified BEFORE the reload — the command context
49
- * is stale afterwards.
50
- * - list/status ship structured `details` payloads (`{kind, profiles}` /
51
- * `{kind, report}`) for RPC consumers (ticket 11).
31
+ * - `/profile overlay ...`: runtime overlay (ticket 06; renamed with the
32
+ * command word — disable/enable/tools/clear).
33
+ * - `/profile` (selector; degrades to the list without dialog-capable UI)
34
+ * and `/profile status`: observability surface (ticket 07). Status
35
+ * combines the active launch plan, the stored overlay, fresh MCP
36
+ * discovery, and Pi's actual command registrations (the winner evidence
37
+ * for same-name conflicts).
38
+ * - The degraded list and status ship structured `details` payloads
39
+ * (`{kind, profiles}` / `{kind, report}`) for RPC consumers (ticket 11).
52
40
  *
53
41
  * Pi re-executes this module on reload, so post-reload state is established
54
42
  * exclusively through `session_start` — nothing stale survives.
55
43
  */
56
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
+
57
98
  function setProfileStatus(ui: unknown, profile: string | undefined): void {
58
99
  if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
59
100
  (ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
@@ -67,6 +108,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
67
108
  let pendingSummary: string | undefined;
68
109
 
69
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);
70
115
  const plan = await readLaunchPlanFile(runtimeDir);
71
116
  setProfileStatus(ctx.ui, plan?.profile);
72
117
  const result = await applyLaunchPlan({
@@ -80,6 +125,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
80
125
  },
81
126
  });
82
127
  pendingSummary = result.summary;
128
+ if (event.reason === "startup") {
129
+ void startStartupNotices(noticeSurface);
130
+ }
83
131
  });
84
132
 
85
133
  pi.on("before_agent_start", async (event) => {
@@ -92,7 +140,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
92
140
  });
93
141
 
94
142
  pi.registerCommand("profile", {
95
- description: "pi-profile: /profile [use|reload|customize|reset|list|status|create|edit|delete|duplicate]",
143
+ description: "pi-profile: /profile [use|reload|status|overlay]",
96
144
  handler: async (args, ctx) => {
97
145
  const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
98
146
  const subcommand = subcommandRaw ?? ""; // bare /profile → selector
@@ -107,35 +155,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
107
155
  // stale context after reload — see above
108
156
  }
109
157
  };
110
- const usage = `usage: /profile [use <name> | reload | ${CUSTOMIZE_USAGE} | reset | list | status | create | edit <name> | delete <name> | duplicate]`;
158
+ const usage = `usage: /profile [use <name> | reload | status | ${OVERLAY_USAGE}]`;
111
159
  if (subcommand === "use" && rest.length === 0) {
112
160
  notify("usage: /profile use <name>", "error");
113
161
  return;
114
162
  }
115
- if (
116
- subcommand !== "" &&
117
- ![
118
- "use",
119
- "reload",
120
- "customize",
121
- "reset",
122
- "list",
123
- "status",
124
- "create",
125
- "edit",
126
- "delete",
127
- "duplicate",
128
- ].includes(
129
- subcommand,
130
- )
131
- ) {
163
+ if (subcommand !== "" && !["use", "reload", "status", "overlay"].includes(subcommand)) {
132
164
  notify(usage, "error");
133
165
  return;
134
166
  }
135
- if (["edit", "delete"].includes(subcommand) && rest[0] === undefined) {
136
- notify(`usage: /profile ${subcommand} <name>`, "error");
137
- return;
138
- }
139
167
  try {
140
168
  const plan = await readLaunchPlanFile(runtimeDir);
141
169
  if (plan?.agentDir === undefined) {
@@ -148,6 +176,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
148
176
  runtimeDir,
149
177
  realAgentDir: plan.agentDir,
150
178
  cwd: ctx.cwd,
179
+ getAllTools: () => pi.getAllTools(),
151
180
  waitForIdle: () => ctx.waitForIdle(),
152
181
  reload: () => ctx.reload(),
153
182
  // A real reload invalidates this context (Pi re-executes
@@ -172,148 +201,22 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
172
201
  notify(`profile reloaded: ${result.profile}`, "info");
173
202
  return;
174
203
  }
175
- if (subcommand === "customize") {
176
- const result = await customizeOverlay(deps, parseCustomizeArgs(rest.join(" ")));
177
- for (const warning of result.warnings) notify(warning, "warning");
178
- notify(`overlay updated: ${result.profile}`, "info");
179
- return;
180
- }
181
- if (subcommand === "reset") {
182
- const result = await resetOverlay(deps);
183
- for (const warning of result.warnings) notify(warning, "warning");
184
- notify(`overlay cleared: ${result.profile}`, "info");
185
- return;
186
- }
187
- // Profile catalog CRUD (ticket 09): TUI-only wizards; mutations
188
- // land in the chosen scope file. Editing the active profile
189
- // reloads immediately; deleting the active profile requires a
190
- // replacement chosen up front, then switches to it.
191
- if (["create", "edit", "delete", "duplicate"].includes(subcommand)) {
192
- if (ctx.mode !== "tui") {
193
- notify(`/profile ${subcommand} requires TUI mode (current mode: ${ctx.mode})`, "error");
204
+ if (subcommand === "overlay") {
205
+ const command = parseOverlayArgs(rest.join(" "));
206
+ if (command.kind === "clear") {
207
+ const result = await clearOverlay(deps);
208
+ for (const warning of result.warnings) notify(warning, "warning");
209
+ notify(`overlay cleared: ${result.profile}`, "info");
194
210
  return;
195
211
  }
196
- const scopeInput = { realAgentDir: plan.agentDir, cwd: ctx.cwd };
197
- if (subcommand === "create") {
198
- const { projectTrusted: canWriteProject } = await readTrustInputs({
199
- agentDir: plan.agentDir,
200
- cwd: ctx.cwd,
201
- });
202
- const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: canWriteProject });
203
- if (wizard === undefined) return;
204
- await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
205
- notify(`created profile "${wizard.name}" (${wizard.scope}) — activate with /profile use ${wizard.name}`, "info");
206
- return;
207
- }
208
- if (subcommand === "duplicate") {
209
- const entries = await listProfiles(scopeInput);
210
- // Read each scope once (trust-gated — never reads an
211
- // untrusted project's catalog).
212
- const byScope = {
213
- global: await readCatalogScope(scopeInput, "global"),
214
- project: await readCatalogScope(scopeInput, "project"),
215
- };
216
- const candidates: Array<{ name: string; source: "global" | "project"; definition: ProfileDefinition }> = [];
217
- for (const entry of entries) {
218
- if (entry.source === "builtin") continue;
219
- const definition = byScope[entry.source].get(entry.name);
220
- if (definition !== undefined) {
221
- candidates.push({ name: entry.name, source: entry.source, definition });
222
- }
223
- }
224
- const wizard = await runProfileDuplicateWizard(ctx.ui, { candidates });
225
- if (wizard === undefined) return;
226
- await duplicateProfile(scopeInput, wizard.scope, wizard.sourceName, wizard.newName);
227
- notify(
228
- `duplicated "${wizard.sourceName}" → "${wizard.newName}" (${wizard.scope}) — the full definition was copied`,
229
- "info",
230
- );
231
- return;
232
- }
233
- const name = rest[0] as string;
234
- if (subcommand === "edit") {
235
- // Edit the WINNING definition in its source scope.
236
- const entries = await listProfiles(scopeInput);
237
- const existing = entries.find((entry) => entry.name === name);
238
- if (existing === undefined || existing.source === "builtin") {
239
- notify(`profile "${name}" not found in a writable catalog`, "error");
240
- return;
241
- }
242
- const definition = (await readCatalogScope(scopeInput, existing.source)).get(name);
243
- if (definition === undefined) {
244
- notify(`profile "${name}" not found in the ${existing.source} catalog`, "error");
245
- return;
246
- }
247
- const wizard = await runProfileEditWizard(ctx.ui, {
248
- existing: { name, source: existing.source, definition },
249
- });
250
- if (wizard === undefined) return;
251
- await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
252
- if (name === plan.profile) {
253
- notify(`saved profile "${name}"; reloading`, "info");
254
- await switchProfile(plan.profile, deps, { reloadCurrent: true });
255
- } else {
256
- notify(`saved profile "${name}" (inactive — runtime untouched)`, "info");
257
- }
258
- return;
259
- }
260
- // delete
261
- const entries = await listProfiles(scopeInput);
262
- const existing = entries.find((entry) => entry.name === name);
263
- if (existing === undefined || existing.source === "builtin") {
264
- notify(`profile "${name}" not found in a writable catalog`, "error");
265
- return;
266
- }
267
- // Both scopes hold the name: choose which record to delete.
268
- let scope = existing.source as "global" | "project";
269
- const projectCatalog = await readCatalogScope(scopeInput, "project");
270
- const globalCatalog = await readCatalogScope(scopeInput, "global");
271
- if (projectCatalog.has(name) && globalCatalog.has(name)) {
272
- const chosen = await ctx.ui.select(`delete "${name}" from which catalog?`, ["global", "project"]);
273
- if (chosen === undefined) return;
274
- scope = chosen as "global" | "project";
275
- }
276
- // If the OTHER scope keeps the name alive, deletion reveals
277
- // it and the session can stay on the revealed same-name
278
- // definition (symmetric: project→global and global→project).
279
- const survivesElsewhere =
280
- (scope === "project" && globalCatalog.has(name)) || (scope === "global" && projectCatalog.has(name));
281
- let replacement: string | undefined;
282
- if (name === plan.profile && !survivesElsewhere) {
283
- const survivors = entries.filter((entry) => entry.name !== name);
284
- const chosen = await ctx.ui.select(
285
- `"${name}" is active — switch to which profile?`,
286
- survivors.map((entry) => `${entry.name} [${entry.source}]`),
287
- );
288
- if (chosen === undefined) return;
289
- replacement = chosen.split(" [")[0];
290
- }
291
- await deleteProfile(scopeInput, scope, name, { activeProfile: plan.profile, replacement });
292
- if (name === plan.profile) {
293
- if (replacement !== undefined) {
294
- notify(`deleted "${name}" (${scope}); switching to ${replacement}`, "info");
295
- await switchProfile(replacement, deps, { clearOverlay: true });
296
- } else {
297
- notify(`deleted the ${scope} record of "${name}"; reloading the revealed definition`, "info");
298
- await switchProfile(plan.profile, deps, { reloadCurrent: true });
299
- }
300
- } else {
301
- notify(`deleted profile "${name}" (${scope})`, "info");
302
- }
212
+ const result = await applyOverlayMutation(deps, command.mutate);
213
+ for (const warning of result.warnings) notify(warning, "warning");
214
+ notify(`overlay updated: ${result.profile}`, "info");
303
215
  return;
304
216
  }
305
217
  // Observability surface (ticket 07): bare /profile opens the
306
- // selector; list/status render via a displayed custom message.
218
+ // selector; status renders via a displayed custom message.
307
219
  const entries = await listProfiles({ realAgentDir: plan.agentDir, cwd: ctx.cwd });
308
- if (subcommand === "list") {
309
- pi.sendMessage({
310
- customType: "pi-profile",
311
- content: formatProfileList(entries, plan.profile),
312
- display: true,
313
- details: { kind: "list", profiles: entries },
314
- });
315
- return;
316
- }
317
220
  if (subcommand === "status") {
318
221
  const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
319
222
  const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
@@ -338,13 +241,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
338
241
  });
339
242
  return;
340
243
  }
341
- // Bare /profile: the interactive selector. Without dialog-capable
342
- // UI (print mode), fall back to the list.
343
- if (!ctx.hasUI) {
244
+ // Bare /profile: the interactive selector. Degradation boundary
245
+ // (delta "Observability surface"): outside TUI mode the selector
246
+ // does not run — the bare invocation degrades to the trust-gated
247
+ // list, carrying the structured payload the removed `list`
248
+ // subcommand emitted.
249
+ if (!ctx.hasUI || ctx.mode !== "tui") {
344
250
  pi.sendMessage({
345
251
  customType: "pi-profile",
346
252
  content: formatProfileList(entries, plan.profile),
347
253
  display: true,
254
+ details: { kind: "list", profiles: entries },
348
255
  });
349
256
  return;
350
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.9.2",
3
+ "version": "0.11.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": [
@@ -94,7 +94,7 @@ export async function readTrustInputs(context: LauncherContext): Promise<{
94
94
  export async function resolveInitialProfile(
95
95
  name: string | undefined,
96
96
  context: LauncherContext,
97
- options?: { overlay?: RuntimeOverlay },
97
+ options?: { overlay?: RuntimeOverlay; liveToolNames?: string[] },
98
98
  ): Promise<InitialProfile> {
99
99
  const { projectTrusted } = await readTrustInputs(context);
100
100
  const projectDir = projectTrusted ? context.cwd : undefined;
@@ -133,7 +133,7 @@ export async function resolveInitialProfile(
133
133
  ((overlay.disabledSkills?.length ?? 0) > 0 ||
134
134
  (overlay.disabledExtensions?.length ?? 0) > 0 ||
135
135
  (overlay.disabledMcps?.length ?? 0) > 0 ||
136
- overlay.tools !== undefined);
136
+ (overlay.disabledTools?.length ?? 0) > 0);
137
137
  if (!narrowed) {
138
138
  return { plan: defaultPlan(), projectTrusted, warnings };
139
139
  }
@@ -153,6 +153,7 @@ export async function resolveInitialProfile(
153
153
  skills: discovery.skills,
154
154
  extensions: discovery.extensions,
155
155
  overlay,
156
+ liveToolNames: options?.liveToolNames,
156
157
  });
157
158
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
158
159
  return { plan, discovery, projectDir, projectTrusted, warnings };
@@ -168,6 +169,7 @@ export async function resolveInitialProfile(
168
169
  ? await discoverAdapterServerNames(context.agentDir, projectDir)
169
170
  : undefined,
170
171
  overlay: options?.overlay,
172
+ liveToolNames: options?.liveToolNames,
171
173
  });
172
174
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
173
175
  if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
@@ -89,8 +89,8 @@ function readOptionalString(value: unknown, field: string, profileName: string,
89
89
  return value;
90
90
  }
91
91
 
92
- /** Parses one raw profile definition; exported for the write-side store
93
- * (profile-catalog-store.ts) so anything written is loadable. */
92
+ /** Parses one raw profile definition; the single read-time validator so
93
+ * catalog files and any external writer stay loadable. */
94
94
  export function parseProfileDefinition(name: string, raw: unknown, filePath?: string): ProfileDefinition {
95
95
  if (!isRecord(raw)) {
96
96
  const prefix = filePath ? `${filePath}: ` : "";