pi-profile-switch 0.9.2 → 0.10.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 +13 -6
- package/README.zh-CN.md +13 -6
- package/extensions/pi-profile/index.ts +31 -188
- package/package.json +1 -1
- package/src/launcher/initial-profile.ts +4 -2
- package/src/profile-catalog.ts +2 -2
- package/src/profile-resolver.ts +114 -27
- package/src/runtime-state-store.ts +3 -4
- package/src/settings-generator.ts +1 -0
- package/src/switching/apply-plan.ts +36 -8
- package/src/switching/list-profiles.ts +2 -2
- package/src/switching/{customize.ts → overlay.ts} +67 -35
- package/src/switching/status.ts +1 -1
- package/src/switching/switch-profile.ts +7 -3
- package/src/profile-catalog-store.ts +0 -86
- package/src/switching/profile-crud.ts +0 -136
- package/src/switching/profile-wizard.ts +0 -163
package/README.md
CHANGED
|
@@ -107,17 +107,24 @@ If you used an earlier version that stored all profiles in a single `profiles.js
|
|
|
107
107
|
|
|
108
108
|
## Commands
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
The `/profile` command family manages everything in-session:
|
|
111
111
|
|
|
112
112
|
| Command | What it does |
|
|
113
113
|
| --- | --- |
|
|
114
|
-
| `/profile` | Interactive profile picker |
|
|
115
|
-
| `/profile list` / `/profile status` | Show profiles / active profile details |
|
|
114
|
+
| `/profile` | Interactive profile picker; without interactive UI it prints the profile list instead |
|
|
116
115
|
| `/profile use <name>` / `/profile reload` | Switch / reload without restarting (rollback on failure) |
|
|
117
|
-
| `/profile
|
|
118
|
-
| `/profile
|
|
116
|
+
| `/profile status` | Active profile details: resolved resources and paths, stored overlay, MCP server tri-state |
|
|
117
|
+
| `/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 |
|
|
118
|
+
| `/profile overlay clear` | Discard the overlay and reactivate the profile exactly as declared |
|
|
119
119
|
|
|
120
|
-
All
|
|
120
|
+
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
|
+
|
|
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 |
|
|
121
128
|
|
|
122
129
|
## Docs
|
|
123
130
|
|
package/README.zh-CN.md
CHANGED
|
@@ -107,17 +107,24 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
107
107
|
|
|
108
108
|
## 命令
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
`/profile` 命令族完成所有会话内操作:
|
|
111
111
|
|
|
112
112
|
| 命令 | 作用 |
|
|
113
113
|
| --- | --- |
|
|
114
|
-
| `/profile` | 交互式选择 profile |
|
|
115
|
-
| `/profile list` / `/profile status` | 列出 profile / 查看活动 profile 详情 |
|
|
114
|
+
| `/profile` | 交互式选择 profile;无交互界面时打印 profile 列表 |
|
|
116
115
|
| `/profile use <name>` / `/profile reload` | 会话内切换 / 重载(失败自动回滚) |
|
|
117
|
-
| `/profile
|
|
118
|
-
| `/profile
|
|
116
|
+
| `/profile status` | 查看活动 profile 详情:解析后的资源与路径、已存 overlay、MCP server 三态 |
|
|
117
|
+
| `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | 仅本次会话收窄 / 恢复活动 profile;`disable` 接受名称或 glob |
|
|
118
|
+
| `/profile overlay clear` | 丢弃 overlay,完全按定义重新激活 profile |
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
所有命令在非交互模式(`--mode rpc|print|json`)下同样生效;裸 `/profile` 在无交互界面时降级为 profile 列表。overlay 是会话级收窄:绝不写入 catalog 文件,也不会跨重启保留。tool 与其他资源类别使用相同的 disable/enable 模型:tool `disable` 条目收窄 profile 解析出的工具引用;profile 未声明 `tools` 时,则收窄运行时的全部可用工具集。
|
|
121
|
+
|
|
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` 字段中声明固定集合 |
|
|
121
128
|
|
|
122
129
|
## 文档
|
|
123
130
|
|
|
@@ -6,21 +6,8 @@ import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
|
|
|
6
6
|
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
7
7
|
import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
|
|
8
8
|
import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { OVERLAY_USAGE, applyOverlayMutation, clearOverlay, parseOverlayArgs } from "../../src/switching/overlay.ts";
|
|
10
10
|
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
11
|
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
25
12
|
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
26
13
|
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
@@ -37,18 +24,15 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
37
24
|
* via `before_agent_start`.
|
|
38
25
|
* - `/profile use <name>` / `/profile reload`: in-session switching without
|
|
39
26
|
* restarting the Pi process (src/switching/switch-profile.ts).
|
|
40
|
-
* - `/profile
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* is stale afterwards.
|
|
50
|
-
* - list/status ship structured `details` payloads (`{kind, profiles}` /
|
|
51
|
-
* `{kind, report}`) for RPC consumers (ticket 11).
|
|
27
|
+
* - `/profile overlay ...`: runtime overlay (ticket 06; renamed with the
|
|
28
|
+
* command word — disable/enable/tools/clear).
|
|
29
|
+
* - `/profile` (selector; degrades to the list without dialog-capable UI)
|
|
30
|
+
* and `/profile status`: observability surface (ticket 07). Status
|
|
31
|
+
* combines the active launch plan, the stored overlay, fresh MCP
|
|
32
|
+
* discovery, and Pi's actual command registrations (the winner evidence
|
|
33
|
+
* for same-name conflicts).
|
|
34
|
+
* - The degraded list and status ship structured `details` payloads
|
|
35
|
+
* (`{kind, profiles}` / `{kind, report}`) for RPC consumers (ticket 11).
|
|
52
36
|
*
|
|
53
37
|
* Pi re-executes this module on reload, so post-reload state is established
|
|
54
38
|
* exclusively through `session_start` — nothing stale survives.
|
|
@@ -92,7 +76,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
92
76
|
});
|
|
93
77
|
|
|
94
78
|
pi.registerCommand("profile", {
|
|
95
|
-
description: "pi-profile: /profile [use|reload|
|
|
79
|
+
description: "pi-profile: /profile [use|reload|status|overlay]",
|
|
96
80
|
handler: async (args, ctx) => {
|
|
97
81
|
const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
98
82
|
const subcommand = subcommandRaw ?? ""; // bare /profile → selector
|
|
@@ -107,35 +91,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
107
91
|
// stale context after reload — see above
|
|
108
92
|
}
|
|
109
93
|
};
|
|
110
|
-
const usage = `usage: /profile [use <name> | reload |
|
|
94
|
+
const usage = `usage: /profile [use <name> | reload | status | ${OVERLAY_USAGE}]`;
|
|
111
95
|
if (subcommand === "use" && rest.length === 0) {
|
|
112
96
|
notify("usage: /profile use <name>", "error");
|
|
113
97
|
return;
|
|
114
98
|
}
|
|
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
|
-
) {
|
|
99
|
+
if (subcommand !== "" && !["use", "reload", "status", "overlay"].includes(subcommand)) {
|
|
132
100
|
notify(usage, "error");
|
|
133
101
|
return;
|
|
134
102
|
}
|
|
135
|
-
if (["edit", "delete"].includes(subcommand) && rest[0] === undefined) {
|
|
136
|
-
notify(`usage: /profile ${subcommand} <name>`, "error");
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
103
|
try {
|
|
140
104
|
const plan = await readLaunchPlanFile(runtimeDir);
|
|
141
105
|
if (plan?.agentDir === undefined) {
|
|
@@ -148,6 +112,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
148
112
|
runtimeDir,
|
|
149
113
|
realAgentDir: plan.agentDir,
|
|
150
114
|
cwd: ctx.cwd,
|
|
115
|
+
getAllTools: () => pi.getAllTools(),
|
|
151
116
|
waitForIdle: () => ctx.waitForIdle(),
|
|
152
117
|
reload: () => ctx.reload(),
|
|
153
118
|
// A real reload invalidates this context (Pi re-executes
|
|
@@ -172,148 +137,22 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
172
137
|
notify(`profile reloaded: ${result.profile}`, "info");
|
|
173
138
|
return;
|
|
174
139
|
}
|
|
175
|
-
if (subcommand === "
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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");
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
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
|
-
}
|
|
140
|
+
if (subcommand === "overlay") {
|
|
141
|
+
const command = parseOverlayArgs(rest.join(" "));
|
|
142
|
+
if (command.kind === "clear") {
|
|
143
|
+
const result = await clearOverlay(deps);
|
|
144
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
145
|
+
notify(`overlay cleared: ${result.profile}`, "info");
|
|
258
146
|
return;
|
|
259
147
|
}
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
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
|
-
}
|
|
148
|
+
const result = await applyOverlayMutation(deps, command.mutate);
|
|
149
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
150
|
+
notify(`overlay updated: ${result.profile}`, "info");
|
|
303
151
|
return;
|
|
304
152
|
}
|
|
305
153
|
// Observability surface (ticket 07): bare /profile opens the
|
|
306
|
-
// selector;
|
|
154
|
+
// selector; status renders via a displayed custom message.
|
|
307
155
|
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
156
|
if (subcommand === "status") {
|
|
318
157
|
const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
|
|
319
158
|
const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
@@ -338,13 +177,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
338
177
|
});
|
|
339
178
|
return;
|
|
340
179
|
}
|
|
341
|
-
// Bare /profile: the interactive selector.
|
|
342
|
-
//
|
|
343
|
-
|
|
180
|
+
// Bare /profile: the interactive selector. Degradation boundary
|
|
181
|
+
// (delta "Observability surface"): outside TUI mode the selector
|
|
182
|
+
// does not run — the bare invocation degrades to the trust-gated
|
|
183
|
+
// list, carrying the structured payload the removed `list`
|
|
184
|
+
// subcommand emitted.
|
|
185
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
344
186
|
pi.sendMessage({
|
|
345
187
|
customType: "pi-profile",
|
|
346
188
|
content: formatProfileList(entries, plan.profile),
|
|
347
189
|
display: true,
|
|
190
|
+
details: { kind: "list", profiles: entries },
|
|
348
191
|
});
|
|
349
192
|
return;
|
|
350
193
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.
|
|
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)) {
|
package/src/profile-catalog.ts
CHANGED
|
@@ -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;
|
|
93
|
-
*
|
|
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}: ` : "";
|
package/src/profile-resolver.ts
CHANGED
|
@@ -67,6 +67,10 @@ export interface ActivationPlan {
|
|
|
67
67
|
extensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }>;
|
|
68
68
|
/** Expanded tool allowlist; undefined when the profile declares no tools. */
|
|
69
69
|
tools?: string[];
|
|
70
|
+
/** Overlay tool disable entries (names or globs, verbatim); undefined
|
|
71
|
+
* when the overlay disables no tools. The session-start application
|
|
72
|
+
* subtracts their live matches from the base expansion. */
|
|
73
|
+
disabledTools?: string[];
|
|
70
74
|
/** The raw tool references (globs included) for extension-side expansion
|
|
71
75
|
* against Pi's live tool registry, which includes extension-provided
|
|
72
76
|
* tools the pre-spawn expansion cannot know. Set iff `tools` is set. */
|
|
@@ -114,12 +118,54 @@ export interface ResolveInput {
|
|
|
114
118
|
* any resolved reference may be narrowed or disabled.
|
|
115
119
|
*/
|
|
116
120
|
overlay?: RuntimeOverlay;
|
|
121
|
+
/**
|
|
122
|
+
* Pi's live tool names (`pi.getAllTools()`), supplied by the in-session
|
|
123
|
+
* switch path. Required when the overlay disables tools on a profile
|
|
124
|
+
* without declared `tools` — the live registry is then the base set the
|
|
125
|
+
* entries disable from. The launcher never passes an overlay, so it never
|
|
126
|
+
* needs this.
|
|
127
|
+
*/
|
|
128
|
+
liveToolNames?: string[];
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
function isGlob(reference: string): boolean {
|
|
120
132
|
return reference.includes("*") || reference.includes("?");
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
/** Expands overlay disable entries against the names the profile resolved.
|
|
136
|
+
* Entries are names or globs stored as written, re-expanded at every
|
|
137
|
+
* resolution with the same matcher as profile references (ADR-0009 tiering):
|
|
138
|
+
* an unmatched literal fails and identifies the entry; a zero-match glob is
|
|
139
|
+
* reported through `onZeroMatch` instead of failing. Returns the resolved
|
|
140
|
+
* names to disable. */
|
|
141
|
+
function expandDisableEntries(
|
|
142
|
+
entries: string[],
|
|
143
|
+
activeNames: readonly string[],
|
|
144
|
+
kind: string,
|
|
145
|
+
profileName: string,
|
|
146
|
+
onZeroMatch: (entry: string) => void,
|
|
147
|
+
): Set<string> {
|
|
148
|
+
const disabled = new Set<string>();
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (isGlob(entry)) {
|
|
151
|
+
let matched = 0;
|
|
152
|
+
for (const name of activeNames) {
|
|
153
|
+
if (minimatch(name, entry)) {
|
|
154
|
+
disabled.add(name);
|
|
155
|
+
matched += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (matched === 0) onZeroMatch(entry);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!activeNames.includes(entry)) {
|
|
162
|
+
throw new ActivationError(`profile "${profileName}": overlay disables unknown ${kind} "${entry}"`);
|
|
163
|
+
}
|
|
164
|
+
disabled.add(entry);
|
|
165
|
+
}
|
|
166
|
+
return disabled;
|
|
167
|
+
}
|
|
168
|
+
|
|
123
169
|
/** Expands one reference list against a named universe. Literal misses fail
|
|
124
170
|
* when `literalMustExist`; globs expand to zero or more matches, and a
|
|
125
171
|
* zero-match glob is reported through `onZeroMatch`. */
|
|
@@ -195,42 +241,48 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
195
241
|
);
|
|
196
242
|
|
|
197
243
|
// --- overlay narrowing (ticket 06) ---
|
|
198
|
-
// Overlay
|
|
199
|
-
//
|
|
200
|
-
|
|
244
|
+
// Overlay disable entries are names or globs stored as written and
|
|
245
|
+
// re-expanded at every resolution; unmatched literals fail and identify
|
|
246
|
+
// the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
|
|
247
|
+
// Overlays can disable any resolved extension.
|
|
248
|
+
const toolReferences = definition.tools;
|
|
201
249
|
if (overlay !== undefined) {
|
|
202
250
|
if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
251
|
+
const disabled = expandDisableEntries(
|
|
252
|
+
overlay.disabledSkills,
|
|
253
|
+
selectedSkills.map((skill) => skill.name),
|
|
254
|
+
"skill",
|
|
255
|
+
profile.name,
|
|
256
|
+
(entry) => unmatched.push(`overlay skill:${entry}`),
|
|
257
|
+
);
|
|
210
258
|
selectedSkills = selectedSkills.filter((skill) => !disabled.has(skill.name));
|
|
211
259
|
}
|
|
212
260
|
if (overlay.disabledExtensions !== undefined && overlay.disabledExtensions.length > 0) {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
261
|
+
const disabled = expandDisableEntries(
|
|
262
|
+
overlay.disabledExtensions,
|
|
263
|
+
planExtensions.map((entry) => entry.id),
|
|
264
|
+
"extension",
|
|
265
|
+
profile.name,
|
|
266
|
+
(entry) => unmatched.push(`overlay extension:${entry}`),
|
|
267
|
+
);
|
|
220
268
|
planExtensions = planExtensions.filter((entry) => !disabled.has(entry.id));
|
|
221
269
|
}
|
|
222
270
|
if (overlay.disabledMcps !== undefined && overlay.disabledMcps.length > 0) {
|
|
223
|
-
const active =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
271
|
+
const active = mcps ?? [];
|
|
272
|
+
const disabled = expandDisableEntries(
|
|
273
|
+
overlay.disabledMcps,
|
|
274
|
+
active,
|
|
275
|
+
"MCP server",
|
|
276
|
+
profile.name,
|
|
277
|
+
(entry) => unmatched.push(`overlay mcp:${entry}`),
|
|
278
|
+
);
|
|
279
|
+
// A profile that declared no `mcps` has no MCP restriction; overlay
|
|
280
|
+
// narrowing must not turn that into an empty whitelist. Literals
|
|
281
|
+
// still fail and zero-match globs still warn — the expansion above
|
|
282
|
+
// runs either way — only the reassignment is guarded.
|
|
283
|
+
if (mcps !== undefined) {
|
|
284
|
+
mcps = active.filter((name) => !disabled.has(name));
|
|
228
285
|
}
|
|
229
|
-
const disabled = new Set(overlay.disabledMcps);
|
|
230
|
-
mcps = (mcps ?? []).filter((name) => !disabled.has(name));
|
|
231
|
-
}
|
|
232
|
-
if (overlay.tools !== undefined) {
|
|
233
|
-
toolReferences = overlay.tools;
|
|
234
286
|
}
|
|
235
287
|
}
|
|
236
288
|
|
|
@@ -241,6 +293,40 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
241
293
|
});
|
|
242
294
|
}
|
|
243
295
|
|
|
296
|
+
// Tool disable entries join the uniform grammar: the base set is the
|
|
297
|
+
// profile's resolved tool references when declared (expanded against the
|
|
298
|
+
// live registry, which the in-session switch path always supplies) and
|
|
299
|
+
// the live registry itself when the profile declares no tools. The
|
|
300
|
+
// plan carries the entries verbatim; session-start application subtracts
|
|
301
|
+
// their live matches from the base expansion at that moment.
|
|
302
|
+
let disabledTools: string[] | undefined;
|
|
303
|
+
if (overlay?.disabledTools !== undefined && overlay.disabledTools.length > 0) {
|
|
304
|
+
if (input.liveToolNames === undefined) {
|
|
305
|
+
throw new ActivationError(
|
|
306
|
+
`profile "${profile.name}": overlay disables tools but no live tool registry is available`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const base =
|
|
310
|
+
toolReferences !== undefined
|
|
311
|
+
? expandReferences(toolReferences, input.liveToolNames, (name) => name, "tool", {
|
|
312
|
+
literalMustExist: false,
|
|
313
|
+
})
|
|
314
|
+
: input.liveToolNames;
|
|
315
|
+
const disabled = expandDisableEntries(
|
|
316
|
+
overlay.disabledTools,
|
|
317
|
+
base,
|
|
318
|
+
"tool",
|
|
319
|
+
profile.name,
|
|
320
|
+
(entry) => unmatched.push(`overlay tool:${entry}`),
|
|
321
|
+
);
|
|
322
|
+
disabledTools = [...overlay.disabledTools];
|
|
323
|
+
// The pre-computed boot baseline additionally subtracts the disabled
|
|
324
|
+
// matches (a no-op for tools only the live registry knows).
|
|
325
|
+
if (tools !== undefined) {
|
|
326
|
+
tools = tools.filter((name) => !disabled.has(name));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
244
330
|
let model: ProfileModel | undefined;
|
|
245
331
|
const declaredModel = extractModel(definition);
|
|
246
332
|
if (declaredModel !== undefined) {
|
|
@@ -267,6 +353,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
267
353
|
skills: selectedSkills,
|
|
268
354
|
extensions: planExtensions.map((entry) => ({ id: entry.id, entry: entry.entry })),
|
|
269
355
|
...(tools !== undefined && toolReferences !== undefined ? { tools, toolReferences: [...toolReferences] } : {}),
|
|
356
|
+
...(disabledTools !== undefined ? { disabledTools } : {}),
|
|
270
357
|
...(model !== undefined ? { model } : {}),
|
|
271
358
|
...(definition.instructions !== undefined ? { instructions: definition.instructions } : {}),
|
|
272
359
|
...(mcps !== undefined ? { mcps } : {}),
|
|
@@ -32,14 +32,13 @@ export interface RuntimeOverlay {
|
|
|
32
32
|
disabledSkills?: string[];
|
|
33
33
|
disabledExtensions?: string[];
|
|
34
34
|
disabledMcps?: string[];
|
|
35
|
-
|
|
36
|
-
tools?: string[];
|
|
35
|
+
disabledTools?: string[];
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
function parseOverlay(value: unknown): RuntimeOverlay | undefined {
|
|
40
39
|
if (!isRecord(value)) return undefined;
|
|
41
40
|
const overlay: RuntimeOverlay = {};
|
|
42
|
-
for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "
|
|
41
|
+
for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "disabledTools"] as const) {
|
|
43
42
|
const list = value[key];
|
|
44
43
|
if (Array.isArray(list) && list.every((entry) => typeof entry === "string")) {
|
|
45
44
|
overlay[key] = list;
|
|
@@ -77,7 +76,7 @@ export class RuntimeStateStore {
|
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
/** Read-modify-write merge. A field set to `undefined` is deleted; absent
|
|
80
|
-
* fields keep their stored value. Used by the switch/
|
|
79
|
+
* fields keep their stored value. Used by the switch/overlay paths so
|
|
81
80
|
* one concern (selection, anchor, overlay) never clobbers another. */
|
|
82
81
|
async update(patch: Partial<RuntimeState>): Promise<RuntimeState> {
|
|
83
82
|
const current = await this.read();
|
|
@@ -401,6 +401,7 @@ export async function writeRuntimeFiles(
|
|
|
401
401
|
agentDir: options.agentDir,
|
|
402
402
|
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
403
403
|
...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
|
|
404
|
+
...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
|
|
404
405
|
...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
|
|
405
406
|
// The resolved sets feed /profile status (absolute paths) and the
|
|
406
407
|
// glob-delta diff against the previous activation.
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* `defaultTools` provides only the boot baseline for built-ins.
|
|
16
16
|
* Literals that no tool provides are dropped with a warning — Pi
|
|
17
17
|
* silently ignores unknown names, so the warning is the only signal.
|
|
18
|
+
* When the plan carries overlay disabled tool entries, the active set
|
|
19
|
+
* is the base expansion — the profile references, or the whole live
|
|
20
|
+
* registry when the profile declares none — minus the entries' live
|
|
21
|
+
* matches, re-expanded at this moment so registry drift re-applies
|
|
22
|
+
* correctly after reload.
|
|
18
23
|
* 2. persistence: when the plan is marked `persistSelection` and this is a
|
|
19
24
|
* reload, save the selection (activeProfile = plan.profile) to the
|
|
20
25
|
* profile's scope state file. Launch-transient selections never write.
|
|
@@ -40,6 +45,9 @@ export interface LaunchPlanFile {
|
|
|
40
45
|
agentDir?: string;
|
|
41
46
|
tools?: string[];
|
|
42
47
|
toolReferences?: string[];
|
|
48
|
+
/** Overlay tool disable entries (names or globs, verbatim); subtracted
|
|
49
|
+
* from the base expansion at session start. */
|
|
50
|
+
disabledTools?: string[];
|
|
43
51
|
mcps?: string[];
|
|
44
52
|
switchedFrom?: string;
|
|
45
53
|
persistSelection?: boolean;
|
|
@@ -95,22 +103,42 @@ export async function applyLaunchPlan(input: {
|
|
|
95
103
|
const warnings: string[] = [];
|
|
96
104
|
|
|
97
105
|
// --- tools ---
|
|
98
|
-
if (plan.toolReferences !== undefined) {
|
|
106
|
+
if (plan.toolReferences !== undefined || plan.disabledTools !== undefined) {
|
|
99
107
|
const liveNames = surface.getAllTools().map((tool) => tool.name);
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
let active: string[];
|
|
109
|
+
if (plan.toolReferences !== undefined) {
|
|
110
|
+
const { expanded, droppedLiterals } = expandToolReferences(plan.toolReferences, liveNames);
|
|
111
|
+
if (droppedLiterals.length > 0) {
|
|
112
|
+
warnings.push(
|
|
113
|
+
`profile "${plan.profile}": tools ${droppedLiterals.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
active = expanded;
|
|
117
|
+
} else {
|
|
118
|
+
// No declared tools: the base is the whole live registry.
|
|
119
|
+
active = [...liveNames];
|
|
120
|
+
}
|
|
121
|
+
if (plan.disabledTools !== undefined && plan.disabledTools.length > 0) {
|
|
122
|
+
const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
|
|
123
|
+
plan.disabledTools,
|
|
124
|
+
liveNames,
|
|
104
125
|
);
|
|
126
|
+
if (vanishedEntries.length > 0) {
|
|
127
|
+
warnings.push(
|
|
128
|
+
`profile "${plan.profile}": overlay tool entries ${vanishedEntries.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const disabledSet = new Set(disabled);
|
|
132
|
+
active = active.filter((name) => !disabledSet.has(name));
|
|
105
133
|
}
|
|
106
|
-
surface.setActiveTools(
|
|
134
|
+
surface.setActiveTools(active);
|
|
107
135
|
}
|
|
108
136
|
|
|
109
137
|
// --- persistence (post-reload only) ---
|
|
110
138
|
if (plan.persistSelection === true && input.reason === "reload" && plan.agentDir !== undefined) {
|
|
111
139
|
const stateDir = plan.source === "project" ? path.join(input.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
112
|
-
// Merge: the overlay belongs to
|
|
113
|
-
// A switch (clearOverlay) explicitly drops it.
|
|
140
|
+
// Merge: the overlay belongs to the overlay commands, not to this
|
|
141
|
+
// write. A switch (clearOverlay) explicitly drops it.
|
|
114
142
|
await new RuntimeStateStore(stateDir).update({
|
|
115
143
|
activeProfile: plan.profile,
|
|
116
144
|
...(plan.clearOverlay === true ? { overlay: undefined } : {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ProfileListing: the
|
|
3
|
-
* (ticket 07).
|
|
2
|
+
* ProfileListing: the profile selector's and the degraded bare `/profile`
|
|
3
|
+
* list's data surface (ticket 07).
|
|
4
4
|
*
|
|
5
5
|
* Trust-gated exactly like activation: an untrusted project's profiles are
|
|
6
6
|
* invisible. The listing reports each visible profile with the source of
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Overlay
|
|
2
|
+
* Overlay command orchestration (ticket 06; module renamed with the
|
|
3
|
+
* `/profile overlay` subcommand).
|
|
3
4
|
*
|
|
4
5
|
* The overlay narrows the ACTIVE profile for this runtime only. It is
|
|
5
6
|
* written to the scope state file (never a catalog) purely as the
|
|
@@ -8,12 +9,12 @@
|
|
|
8
9
|
* ignores stored overlays, so an overlay never outlives its runtime.
|
|
9
10
|
*
|
|
10
11
|
* Ordering invariants:
|
|
11
|
-
* -
|
|
12
|
-
* unknown references fail here, before
|
|
13
|
-
* is written), then switch+reload, then persist the overlay to
|
|
14
|
-
* failed switch leaves the stored overlay untouched, consistent
|
|
15
|
-
* rolled-back runtime.
|
|
16
|
-
* -
|
|
12
|
+
* - disable|enable: re-resolve with the candidate overlay FIRST
|
|
13
|
+
* (validation: unknown references and enable misses fail here, before
|
|
14
|
+
* anything is written), then switch+reload, then persist the overlay to
|
|
15
|
+
* state. A failed switch leaves the stored overlay untouched, consistent
|
|
16
|
+
* with the rolled-back runtime.
|
|
17
|
+
* - clear: switch+reload WITHOUT the overlay first, then delete it from
|
|
17
18
|
* state. If the switch rolls back, the stored overlay still matches the
|
|
18
19
|
* restored runtime.
|
|
19
20
|
*/
|
|
@@ -31,7 +32,7 @@ async function currentStateTarget(
|
|
|
31
32
|
): Promise<{ profile: string; store: RuntimeStateStore; state: RuntimeState }> {
|
|
32
33
|
const plan = await readLaunchPlanFile(deps.runtimeDir);
|
|
33
34
|
if (plan === undefined) {
|
|
34
|
-
throw new SwitchError("no active profile — nothing to
|
|
35
|
+
throw new SwitchError("no active profile — nothing to overlay");
|
|
35
36
|
}
|
|
36
37
|
const stateDir = plan.source === "project" ? path.join(deps.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
37
38
|
if (stateDir === undefined) {
|
|
@@ -42,7 +43,7 @@ async function currentStateTarget(
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** Applies a mutation to the active profile's overlay and re-activates. */
|
|
45
|
-
export async function
|
|
46
|
+
export async function applyOverlayMutation(
|
|
46
47
|
deps: SwitchDeps,
|
|
47
48
|
mutate: (overlay: RuntimeOverlay) => RuntimeOverlay,
|
|
48
49
|
): Promise<SwitchResult> {
|
|
@@ -59,7 +60,7 @@ export async function customizeOverlay(
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** Discards the overlay and reactivates the profile exactly as declared. */
|
|
62
|
-
export async function
|
|
63
|
+
export async function clearOverlay(deps: SwitchDeps): Promise<SwitchResult> {
|
|
63
64
|
const { profile, store } = await currentStateTarget(deps);
|
|
64
65
|
|
|
65
66
|
// overlay: null — explicit "none"; without it the reload path would
|
|
@@ -70,47 +71,78 @@ export async function resetOverlay(deps: SwitchDeps): Promise<SwitchResult> {
|
|
|
70
71
|
return result;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
export const
|
|
74
|
-
"/profile
|
|
74
|
+
export const OVERLAY_USAGE =
|
|
75
|
+
"/profile overlay disable|enable skill|extension|mcp|tool <name-or-glob> · /profile overlay clear" as const;
|
|
76
|
+
|
|
77
|
+
/** The parsed form of `/profile overlay` arguments: either a mutation to
|
|
78
|
+
* validate and apply against the stored overlay, or `clear`. */
|
|
79
|
+
export type OverlayCommand =
|
|
80
|
+
| { kind: "mutate"; mutate: (overlay: RuntimeOverlay) => RuntimeOverlay }
|
|
81
|
+
| { kind: "clear" };
|
|
75
82
|
|
|
76
83
|
const DISABLED_FIELDS = {
|
|
77
84
|
skill: "disabledSkills",
|
|
78
85
|
extension: "disabledExtensions",
|
|
79
86
|
mcp: "disabledMcps",
|
|
87
|
+
tool: "disabledTools",
|
|
80
88
|
} as const;
|
|
81
89
|
|
|
82
|
-
/** Parses `/profile
|
|
90
|
+
/** Parses `/profile overlay` arguments into an overlay command.
|
|
83
91
|
* Grammar:
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
92
|
+
* overlay disable skill|extension|mcp|tool <name-or-glob>
|
|
93
|
+
* overlay enable skill|extension|mcp|tool <name-or-glob> (remove a stored entry)
|
|
94
|
+
* overlay clear (discard the overlay)
|
|
95
|
+
*
|
|
96
|
+
* One uniform grammar for all four resource kinds (the tools-only
|
|
97
|
+
* replace-form is gone — tools are disabled like the other kinds). The
|
|
98
|
+
* removed `tools` action falls through to the usage error above.
|
|
99
|
+
*
|
|
100
|
+
* `enable` removes a stored entry by exact string match — no hole-punching
|
|
101
|
+
* through globs — and fails when nothing equals the given name, listing the
|
|
102
|
+
* current entries of that kind.
|
|
88
103
|
*/
|
|
89
|
-
export function
|
|
104
|
+
export function parseOverlayArgs(args: string): OverlayCommand {
|
|
90
105
|
const [action, kind, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
91
106
|
|
|
92
|
-
if (action === "
|
|
93
|
-
|
|
94
|
-
return
|
|
95
|
-
const next = { ...overlay };
|
|
96
|
-
if (refs.length === 0) delete next.tools;
|
|
97
|
-
else next.tools = refs;
|
|
98
|
-
return next;
|
|
99
|
-
};
|
|
107
|
+
if (action === "clear") {
|
|
108
|
+
if (rest.length > 0 || kind !== undefined) throw new SwitchError(`usage: ${OVERLAY_USAGE}`);
|
|
109
|
+
return { kind: "clear" };
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
const field = DISABLED_FIELDS[kind as keyof typeof DISABLED_FIELDS];
|
|
103
113
|
if ((action !== "disable" && action !== "enable") || field === undefined || rest.length !== 1) {
|
|
104
|
-
throw new SwitchError(`usage: ${
|
|
114
|
+
throw new SwitchError(`usage: ${OVERLAY_USAGE}`);
|
|
105
115
|
}
|
|
106
116
|
const [name] = rest;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
|
|
118
|
+
if (action === "disable") {
|
|
119
|
+
return {
|
|
120
|
+
kind: "mutate",
|
|
121
|
+
mutate: (overlay) => {
|
|
122
|
+
const current = overlay[field] ?? [];
|
|
123
|
+
// `name` is always appended, so the list never empties here (the
|
|
124
|
+
// enable closure below owns the delete-when-empty branch).
|
|
125
|
+
return { ...overlay, [field]: [...new Set([...current, name])] };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
kind: "mutate",
|
|
132
|
+
mutate: (overlay) => {
|
|
133
|
+
const current = overlay[field] ?? [];
|
|
134
|
+
if (!current.includes(name)) {
|
|
135
|
+
throw new SwitchError(
|
|
136
|
+
`no stored ${kind} disable entry equals "${name}" — current entries: ${
|
|
137
|
+
current.length > 0 ? current.join(", ") : "(none)"
|
|
138
|
+
}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const nextList = current.filter((entry) => entry !== name);
|
|
142
|
+
const next = { ...overlay };
|
|
143
|
+
if (nextList.length === 0) delete next[field];
|
|
144
|
+
else next[field] = nextList;
|
|
145
|
+
return next;
|
|
146
|
+
},
|
|
115
147
|
};
|
|
116
148
|
}
|
package/src/switching/status.ts
CHANGED
|
@@ -145,7 +145,7 @@ export function formatStatusMarkdown(report: StatusReport): string {
|
|
|
145
145
|
...(report.overlay.disabledSkills ?? []).map((name) => `-skill:${name}`),
|
|
146
146
|
...(report.overlay.disabledExtensions ?? []).map((id) => `-extension:${id}`),
|
|
147
147
|
...(report.overlay.disabledMcps ?? []).map((name) => `-mcp:${name}`),
|
|
148
|
-
...(report.overlay.
|
|
148
|
+
...(report.overlay.disabledTools ?? []).map((name) => `-tool:${name}`),
|
|
149
149
|
];
|
|
150
150
|
lines.push(`overlay: ${parts.length > 0 ? parts.join(" ") : "(empty)"}`);
|
|
151
151
|
}
|
|
@@ -55,6 +55,10 @@ export interface SwitchDeps {
|
|
|
55
55
|
realAgentDir: string;
|
|
56
56
|
/** The project working directory. */
|
|
57
57
|
cwd: string;
|
|
58
|
+
/** Pi's live tool registry (pi.getAllTools()): the base set overlay tool
|
|
59
|
+
* disable entries narrow when the profile declares no tools, and the
|
|
60
|
+
* in-session expansion universe for declared references. */
|
|
61
|
+
getAllTools(): Array<{ name: string }>;
|
|
58
62
|
/** Pi's native idle wait (ctx.waitForIdle): resolves when the current
|
|
59
63
|
* turn/compaction finishes. */
|
|
60
64
|
waitForIdle(): Promise<void>;
|
|
@@ -162,8 +166,8 @@ export async function switchProfile(
|
|
|
162
166
|
throw new SwitchError("no active profile to reload");
|
|
163
167
|
}
|
|
164
168
|
|
|
165
|
-
// Overlay resolution: explicit `overlay` wins (
|
|
166
|
-
// `null` suppresses (
|
|
169
|
+
// Overlay resolution: explicit `overlay` wins (overlay mutation),
|
|
170
|
+
// explicit `null` suppresses (clear/switch), and a plain `/profile reload`
|
|
167
171
|
// re-applies the stored overlay so runtime and state never diverge.
|
|
168
172
|
let overlay = options?.overlay;
|
|
169
173
|
if (overlay === undefined && options?.reloadCurrent === true && current.profile !== undefined) {
|
|
@@ -185,7 +189,7 @@ export async function switchProfile(
|
|
|
185
189
|
const resolved = await resolveInitialProfile(
|
|
186
190
|
target,
|
|
187
191
|
{ agentDir: deps.realAgentDir, cwd: deps.cwd },
|
|
188
|
-
{ overlay: overlay ?? undefined },
|
|
192
|
+
{ overlay: overlay ?? undefined, liveToolNames: deps.getAllTools().map((tool) => tool.name) },
|
|
189
193
|
);
|
|
190
194
|
|
|
191
195
|
const isSwitch = !options?.reloadCurrent && target !== current.profile;
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ProfileCatalogStore: the WRITE side of profile catalog files,
|
|
3
|
-
* storing each profile in its own `<name>.json` file.
|
|
4
|
-
*
|
|
5
|
-
* Invariants:
|
|
6
|
-
* - Single-file operations (pretty-printed JSON without schemaVersion envelope).
|
|
7
|
-
* - Writes land in `<dir>/<name>.json` via a temporary file + rename.
|
|
8
|
-
* - Names must match `PROFILE_NAME_PATTERN` and cannot be "default".
|
|
9
|
-
* - Definitions are complete and self-contained: no inheritance fields exist.
|
|
10
|
-
* - Definitions are re-parsed through the catalog's own
|
|
11
|
-
* `parseProfileDefinition`, so anything written is loadable.
|
|
12
|
-
* - Writes land in exactly one scope directory (global or project).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { mkdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
CatalogError,
|
|
20
|
-
DEFAULT_PROFILE_NAME,
|
|
21
|
-
loadCatalogDirectory,
|
|
22
|
-
parseProfileDefinition,
|
|
23
|
-
PROFILE_NAME_PATTERN,
|
|
24
|
-
type ProfileDefinition,
|
|
25
|
-
} from "./profile-catalog.ts";
|
|
26
|
-
|
|
27
|
-
export class ProfileCatalogStore {
|
|
28
|
-
readonly #dirPath: string;
|
|
29
|
-
|
|
30
|
-
constructor(catalogDir: string) {
|
|
31
|
-
this.#dirPath = catalogDir;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Validated definitions: missing directory → empty; malformed → CatalogError
|
|
35
|
-
* (catalog errors never pass silently, even on the write path). */
|
|
36
|
-
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
37
|
-
return loadCatalogDirectory(this.#dirPath);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Inserts or replaces one complete definition (via atomic rename). */
|
|
41
|
-
async upsert(name: string, definition: ProfileDefinition): Promise<void> {
|
|
42
|
-
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
43
|
-
throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
|
|
44
|
-
}
|
|
45
|
-
if (name === DEFAULT_PROFILE_NAME) {
|
|
46
|
-
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Validate before mutating: the definition must parse.
|
|
50
|
-
const validated = parseProfileDefinition(name, definition);
|
|
51
|
-
|
|
52
|
-
await mkdir(this.#dirPath, { recursive: true });
|
|
53
|
-
const targetPath = path.join(this.#dirPath, `${name}.json`);
|
|
54
|
-
const tempPath = path.join(
|
|
55
|
-
this.#dirPath,
|
|
56
|
-
`.${name}.json.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
await writeFile(tempPath, `${JSON.stringify(validated, null, 2)}\n`);
|
|
61
|
-
await rename(tempPath, targetPath);
|
|
62
|
-
} catch (error) {
|
|
63
|
-
await rm(tempPath, { force: true });
|
|
64
|
-
throw error;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Removes one profile; unknown names are a loud error, not a no-op. */
|
|
69
|
-
async remove(name: string): Promise<void> {
|
|
70
|
-
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
71
|
-
throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
|
|
72
|
-
}
|
|
73
|
-
if (name === DEFAULT_PROFILE_NAME) {
|
|
74
|
-
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
|
|
75
|
-
}
|
|
76
|
-
const targetPath = path.join(this.#dirPath, `${name}.json`);
|
|
77
|
-
try {
|
|
78
|
-
await unlink(targetPath);
|
|
79
|
-
} catch (error) {
|
|
80
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
81
|
-
throw new CatalogError(`profile "${name}" not found in ${this.#dirPath}`);
|
|
82
|
-
}
|
|
83
|
-
throw error;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ProfileCrud: `/profile create|edit|delete|duplicate` semantics (ticket 09)
|
|
3
|
-
* on top of ProfileCatalogStore.
|
|
4
|
-
*
|
|
5
|
-
* Invariants:
|
|
6
|
-
* - Trust-gated exactly like activation: an untrusted project's catalog is
|
|
7
|
-
* never read or written.
|
|
8
|
-
* - Deleting the ACTIVE profile requires a replacement up front — the
|
|
9
|
-
* session must land somewhere defined; the caller switches to it.
|
|
10
|
-
* - Deleting one scope's record while the other scope keeps the name
|
|
11
|
-
* reveals that definition (merged-catalog semantics; nothing extra to
|
|
12
|
-
* do beyond reloading).
|
|
13
|
-
* - Duplicating copies the COMPLETE definition under a new name — the
|
|
14
|
-
* only variant mechanism (no inheritance).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
|
|
19
|
-
import { getGlobalProfilesDir } from "../workspace.ts";
|
|
20
|
-
import { readTrustInputs } from "../launcher/initial-profile.ts";
|
|
21
|
-
import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
|
|
22
|
-
import { ProfileCatalogStore } from "../profile-catalog-store.ts";
|
|
23
|
-
|
|
24
|
-
export type CatalogScope = "global" | "project";
|
|
25
|
-
|
|
26
|
-
/** The store for one scope's catalog file — the ONLY place scope-file
|
|
27
|
-
* paths are constructed. Callers must still trust-gate project access
|
|
28
|
-
* (`requireScope` / `readCatalogScope`). */
|
|
29
|
-
export function catalogStore(
|
|
30
|
-
input: { realAgentDir: string; cwd: string },
|
|
31
|
-
scope: CatalogScope,
|
|
32
|
-
): ProfileCatalogStore {
|
|
33
|
-
if (scope === "global") {
|
|
34
|
-
return new ProfileCatalogStore(getGlobalProfilesDir());
|
|
35
|
-
}
|
|
36
|
-
return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles"));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function requireScope(input: { realAgentDir: string; cwd: string }, scope: CatalogScope): Promise<void> {
|
|
40
|
-
if (
|
|
41
|
-
scope === "project" &&
|
|
42
|
-
!(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
|
|
43
|
-
) {
|
|
44
|
-
throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Reads one scope's catalog with the trust gate applied — project reads
|
|
49
|
-
* return empty when untrusted (never touching the file). */
|
|
50
|
-
export async function readCatalogScope(
|
|
51
|
-
input: { realAgentDir: string; cwd: string },
|
|
52
|
-
scope: CatalogScope,
|
|
53
|
-
): Promise<Map<string, ProfileDefinition>> {
|
|
54
|
-
if (
|
|
55
|
-
scope === "project" &&
|
|
56
|
-
!(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
|
|
57
|
-
) {
|
|
58
|
-
return new Map();
|
|
59
|
-
}
|
|
60
|
-
return catalogStore(input, scope).readDefinitions();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Creates a complete definition in the chosen scope. */
|
|
64
|
-
export async function createProfile(
|
|
65
|
-
input: { realAgentDir: string; cwd: string },
|
|
66
|
-
scope: CatalogScope,
|
|
67
|
-
name: string,
|
|
68
|
-
definition: ProfileDefinition,
|
|
69
|
-
): Promise<void> {
|
|
70
|
-
await requireScope(input, scope);
|
|
71
|
-
const store = catalogStore(input, scope);
|
|
72
|
-
if ((await store.readDefinitions()).has(name)) {
|
|
73
|
-
throw new CatalogError(`profile "${name}" already exists in the ${scope} catalog`);
|
|
74
|
-
}
|
|
75
|
-
await store.upsert(name, definition);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Replaces a complete definition; the caller reloads iff it is active. */
|
|
79
|
-
export async function editProfile(
|
|
80
|
-
input: { realAgentDir: string; cwd: string },
|
|
81
|
-
scope: CatalogScope,
|
|
82
|
-
name: string,
|
|
83
|
-
definition: ProfileDefinition,
|
|
84
|
-
): Promise<void> {
|
|
85
|
-
await requireScope(input, scope);
|
|
86
|
-
if (name === DEFAULT_PROFILE_NAME) {
|
|
87
|
-
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be edited`);
|
|
88
|
-
}
|
|
89
|
-
const store = catalogStore(input, scope);
|
|
90
|
-
if (!(await store.readDefinitions()).has(name)) {
|
|
91
|
-
throw new CatalogError(`profile "${name}" not found in the ${scope} catalog`);
|
|
92
|
-
}
|
|
93
|
-
await store.upsert(name, definition);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Deletes a profile from the chosen scope. Deleting the active profile
|
|
98
|
-
* requires `replacement` (validated for existence in the remaining merged
|
|
99
|
-
* catalog by the caller's switch); the built-in default can never be
|
|
100
|
-
* deleted.
|
|
101
|
-
*/
|
|
102
|
-
export async function deleteProfile(
|
|
103
|
-
input: { realAgentDir: string; cwd: string },
|
|
104
|
-
scope: CatalogScope,
|
|
105
|
-
name: string,
|
|
106
|
-
options: { activeProfile?: string; replacement?: string },
|
|
107
|
-
): Promise<void> {
|
|
108
|
-
await requireScope(input, scope);
|
|
109
|
-
if (name === DEFAULT_PROFILE_NAME) {
|
|
110
|
-
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
|
|
111
|
-
}
|
|
112
|
-
if (options.activeProfile === name && options.replacement === undefined) {
|
|
113
|
-
throw new CatalogError(`profile "${name}" is active — choose a replacement profile first`);
|
|
114
|
-
}
|
|
115
|
-
await catalogStore(input, scope).remove(name);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Copies a complete definition under a new, unused name. */
|
|
119
|
-
export async function duplicateProfile(
|
|
120
|
-
input: { realAgentDir: string; cwd: string },
|
|
121
|
-
scope: CatalogScope,
|
|
122
|
-
sourceName: string,
|
|
123
|
-
newName: string,
|
|
124
|
-
): Promise<void> {
|
|
125
|
-
await requireScope(input, scope);
|
|
126
|
-
const store = catalogStore(input, scope);
|
|
127
|
-
const definitions = await store.readDefinitions();
|
|
128
|
-
const source = definitions.get(sourceName);
|
|
129
|
-
if (source === undefined) {
|
|
130
|
-
throw new CatalogError(`profile "${sourceName}" not found in the ${scope} catalog`);
|
|
131
|
-
}
|
|
132
|
-
if (definitions.has(newName)) {
|
|
133
|
-
throw new CatalogError(`profile "${newName}" already exists in the ${scope} catalog`);
|
|
134
|
-
}
|
|
135
|
-
await store.upsert(newName, { ...source });
|
|
136
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ProfileWizard: the interactive create/edit/duplicate dialog for
|
|
3
|
-
* `/profile create|edit|delete|duplicate` (ticket 09), UI-injected for
|
|
4
|
-
* testability.
|
|
5
|
-
*
|
|
6
|
-
* Definitions are complete and self-contained — the wizard edits whole
|
|
7
|
-
* fields, never inheritance or merge syntax. On edit, an empty answer
|
|
8
|
-
* keeps the current value (prefill via placeholder); there is no
|
|
9
|
-
* field-clearing gesture (delete + create instead). Any cancelled step
|
|
10
|
-
* aborts the wizard — nothing is written.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { ProfileDefinition } from "../profile-catalog.ts";
|
|
14
|
-
import type { CatalogScope } from "./profile-crud.ts";
|
|
15
|
-
|
|
16
|
-
export interface ProfileWizardUi {
|
|
17
|
-
select(title: string, options: string[]): Promise<string | undefined>;
|
|
18
|
-
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ProfileWizardResult {
|
|
22
|
-
scope: CatalogScope;
|
|
23
|
-
name: string;
|
|
24
|
-
definition: ProfileDefinition;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface ExistingProfile {
|
|
28
|
-
name: string;
|
|
29
|
-
source: CatalogScope;
|
|
30
|
-
definition: ProfileDefinition;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function parseList(raw: string): string[] {
|
|
34
|
-
return raw
|
|
35
|
-
.split(",")
|
|
36
|
-
.map((entry) => entry.trim())
|
|
37
|
-
.filter((entry) => entry.length > 0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** "provider/id[/thinkingLevel]" → ProfileModel; empty/undefined → none. */
|
|
41
|
-
function parseModel(raw: string): { defaultProvider: string; defaultModel: string; defaultThinkingLevel?: string } | undefined {
|
|
42
|
-
const trimmed = raw.trim();
|
|
43
|
-
if (trimmed.length === 0) return undefined;
|
|
44
|
-
const [provider, id, thinkingLevel] = trimmed.split("/").map((part) => part.trim());
|
|
45
|
-
if (provider === undefined || provider.length === 0 || id === undefined || id.length === 0) {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
return thinkingLevel !== undefined && thinkingLevel.length > 0
|
|
49
|
-
? { defaultProvider: provider, defaultModel: id, defaultThinkingLevel: thinkingLevel }
|
|
50
|
-
: { defaultProvider: provider, defaultModel: id };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function captureDefinition(
|
|
54
|
-
ui: ProfileWizardUi,
|
|
55
|
-
existing?: ProfileDefinition,
|
|
56
|
-
): Promise<ProfileDefinition | undefined> {
|
|
57
|
-
const definition: ProfileDefinition = {};
|
|
58
|
-
|
|
59
|
-
const label = await ui.input("label (optional; empty keeps current)", existing?.label);
|
|
60
|
-
if (label === undefined) return undefined;
|
|
61
|
-
if (label.trim().length > 0) definition.label = label.trim();
|
|
62
|
-
else if (existing?.label !== undefined) definition.label = existing.label;
|
|
63
|
-
|
|
64
|
-
const description = await ui.input("description (optional; empty keeps current)", existing?.description);
|
|
65
|
-
if (description === undefined) return undefined;
|
|
66
|
-
if (description.trim().length > 0) definition.description = description.trim();
|
|
67
|
-
else if (existing?.description !== undefined) definition.description = existing.description;
|
|
68
|
-
|
|
69
|
-
const listFields = [
|
|
70
|
-
["skills", "skills (comma-separated names or globs, empty = none)"],
|
|
71
|
-
["extensions", "extensions (names or globs, empty = none)"],
|
|
72
|
-
["mcps", "mcp servers (names or globs, empty = none)"],
|
|
73
|
-
["tools", "tools (names or globs, empty = pi default set)"],
|
|
74
|
-
] as const;
|
|
75
|
-
for (const [field, prompt] of listFields) {
|
|
76
|
-
const current = existing?.[field]?.join(", ");
|
|
77
|
-
const raw = await ui.input(prompt, current);
|
|
78
|
-
if (raw === undefined) return undefined;
|
|
79
|
-
const parsed = parseList(raw);
|
|
80
|
-
// Empty keeps the existing value on edit; on create it omits the field.
|
|
81
|
-
if (parsed.length > 0) {
|
|
82
|
-
definition[field] = parsed;
|
|
83
|
-
} else if (current !== undefined) {
|
|
84
|
-
definition[field] = existing?.[field];
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const instructions = await ui.input(
|
|
89
|
-
"instructions (appended to the system prompt; empty keeps current)",
|
|
90
|
-
existing?.instructions,
|
|
91
|
-
);
|
|
92
|
-
if (instructions === undefined) return undefined;
|
|
93
|
-
if (instructions.trim().length > 0) definition.instructions = instructions;
|
|
94
|
-
else if (existing?.instructions !== undefined) definition.instructions = existing.instructions;
|
|
95
|
-
|
|
96
|
-
const currentModel =
|
|
97
|
-
existing?.defaultProvider !== undefined && existing?.defaultModel !== undefined
|
|
98
|
-
? `${existing.defaultProvider}/${existing.defaultModel}${existing.defaultThinkingLevel !== undefined ? `/${existing.defaultThinkingLevel}` : ""}`
|
|
99
|
-
: undefined;
|
|
100
|
-
const modelRaw = await ui.input("model provider/id[/thinking] (empty = none)", currentModel);
|
|
101
|
-
if (modelRaw === undefined) return undefined;
|
|
102
|
-
const model = parseModel(modelRaw);
|
|
103
|
-
if (model !== undefined) {
|
|
104
|
-
definition.defaultProvider = model.defaultProvider;
|
|
105
|
-
definition.defaultModel = model.defaultModel;
|
|
106
|
-
if (model.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = model.defaultThinkingLevel;
|
|
107
|
-
} else if (existing?.defaultProvider !== undefined && modelRaw.trim().length === 0) {
|
|
108
|
-
definition.defaultProvider = existing.defaultProvider;
|
|
109
|
-
definition.defaultModel = existing.defaultModel;
|
|
110
|
-
if (existing.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = existing.defaultThinkingLevel;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return definition;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Create: scope first (project only when trusted), then name, then fields. */
|
|
117
|
-
export async function runProfileCreateWizard(
|
|
118
|
-
ui: ProfileWizardUi,
|
|
119
|
-
input: { projectTrusted: boolean },
|
|
120
|
-
): Promise<ProfileWizardResult | undefined> {
|
|
121
|
-
const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
|
|
122
|
-
// A single available scope needs no dialog.
|
|
123
|
-
const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
|
|
124
|
-
if (scope === undefined) return undefined;
|
|
125
|
-
|
|
126
|
-
const name = await ui.input("profile name");
|
|
127
|
-
if (name === undefined || name.trim().length === 0) return undefined;
|
|
128
|
-
|
|
129
|
-
const definition = await captureDefinition(ui);
|
|
130
|
-
if (definition === undefined) return undefined;
|
|
131
|
-
|
|
132
|
-
return { scope: scope as CatalogScope, name: name.trim(), definition };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Edit: fields prefilled from the existing complete definition. */
|
|
136
|
-
export async function runProfileEditWizard(
|
|
137
|
-
ui: ProfileWizardUi,
|
|
138
|
-
input: { existing: ExistingProfile },
|
|
139
|
-
): Promise<ProfileWizardResult | undefined> {
|
|
140
|
-
const definition = await captureDefinition(ui, input.existing.definition);
|
|
141
|
-
if (definition === undefined) return undefined;
|
|
142
|
-
return { scope: input.existing.source, name: input.existing.name, definition };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Duplicate: pick a source, name the copy — the only variant mechanism. */
|
|
146
|
-
export async function runProfileDuplicateWizard(
|
|
147
|
-
ui: ProfileWizardUi,
|
|
148
|
-
input: { candidates: ExistingProfile[] },
|
|
149
|
-
): Promise<{ scope: CatalogScope; sourceName: string; newName: string } | undefined> {
|
|
150
|
-
if (input.candidates.length === 0) return undefined;
|
|
151
|
-
const options = input.candidates.map(
|
|
152
|
-
(candidate) => `${candidate.name} [${candidate.source}]${candidate.definition.label !== undefined ? ` — ${candidate.definition.label}` : ""}`,
|
|
153
|
-
);
|
|
154
|
-
const chosen = await ui.select("duplicate which profile?", options);
|
|
155
|
-
if (chosen === undefined) return undefined;
|
|
156
|
-
const source = input.candidates.find((candidate) => chosen.startsWith(`${candidate.name} [`));
|
|
157
|
-
if (source === undefined) return undefined;
|
|
158
|
-
|
|
159
|
-
const newName = await ui.input("new profile name");
|
|
160
|
-
if (newName === undefined || newName.trim().length === 0) return undefined;
|
|
161
|
-
|
|
162
|
-
return { scope: source.source, sourceName: source.name, newName: newName.trim() };
|
|
163
|
-
}
|