pi-profile-switch 0.3.1 → 0.4.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 +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- package/src/switching/apply-profile.ts +0 -131
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
|
|
6
|
+
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
7
|
+
import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
|
|
8
|
+
import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
|
|
9
|
+
import { CUSTOMIZE_USAGE, customizeOverlay, parseCustomizeArgs, resetOverlay } from "../../src/switching/customize.ts";
|
|
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
|
+
import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
25
|
+
import { setMcpServerEnabled } from "../../src/switching/mcp-toggle.ts";
|
|
26
|
+
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
27
|
+
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
28
|
+
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* pi-profile extension entry.
|
|
32
|
+
*
|
|
33
|
+
* Loaded into the spawned pi via `-e`. Responsibilities:
|
|
34
|
+
* - Append the profile's declared instructions to Pi's fully built system
|
|
35
|
+
* prompt on every turn (`before_agent_start`), so the default prompt,
|
|
36
|
+
* AGENTS.md, and other extensions keep working.
|
|
37
|
+
* - After every session start (startup/reload/new/resume/fork), apply the
|
|
38
|
+
* launch plan: re-expand tool references against Pi's live registry,
|
|
39
|
+
* set the declared model/thinking, publish the MCP allowlist, persist the
|
|
40
|
+
* selection + rollback anchor after switches, and produce the one-shot
|
|
41
|
+
* change summary injected into the next turn.
|
|
42
|
+
* - `/profile use <name>` / `/profile reload`: in-session switching without
|
|
43
|
+
* restarting the Pi process (src/switching/switch-profile.ts).
|
|
44
|
+
* - `/profile customize` / `/profile reset`: runtime overlay (ticket 06).
|
|
45
|
+
* - `/profile` (selector), `/profile list`, `/profile status`:
|
|
46
|
+
* observability surface (ticket 07). Status combines the active launch
|
|
47
|
+
* plan, the stored overlay, fresh MCP discovery, and Pi's actual command
|
|
48
|
+
* registrations (the winner evidence for same-name conflicts).
|
|
49
|
+
* - `/profile create|edit|delete|duplicate`: catalog CRUD wizards (ticket 09).
|
|
50
|
+
* CRUD is TUI-only (ticket 11): gated on `ctx.mode === "tui"` with a
|
|
51
|
+
* mode-aware refusal. Mutations apply via the standard reload path;
|
|
52
|
+
* mutation success is notified BEFORE the reload — the command context
|
|
53
|
+
* is stale afterwards.
|
|
54
|
+
* - list/status ship structured `details` payloads (`{kind, profiles}` /
|
|
55
|
+
* `{kind, report}`) for RPC consumers (ticket 11).
|
|
56
|
+
* - `/mcp enable|disable <server>`: edit the active profile's mcp array in
|
|
57
|
+
* its owning catalog, then reload (ticket 10). Fails clearly with the
|
|
58
|
+
* adapter absent; notifies before the reload (stale context after).
|
|
59
|
+
*
|
|
60
|
+
* Pi re-executes this module on reload, so post-reload state is established
|
|
61
|
+
* exclusively through `session_start` — nothing stale survives.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
function setProfileStatus(ui: unknown, profile: string | undefined): void {
|
|
65
|
+
if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
|
|
66
|
+
(ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
71
|
+
const runtimeDir = process.env.PI_CODING_AGENT_DIR;
|
|
72
|
+
if (runtimeDir === undefined) return;
|
|
73
|
+
|
|
74
|
+
let pendingSummary: string | undefined;
|
|
75
|
+
|
|
76
|
+
pi.on("session_start", async (event, ctx) => {
|
|
77
|
+
const plan = await readLaunchPlanFile(runtimeDir);
|
|
78
|
+
setProfileStatus(ctx.ui, plan?.profile);
|
|
79
|
+
const result = await applyLaunchPlan({
|
|
80
|
+
runtimeDir,
|
|
81
|
+
cwd: ctx.cwd,
|
|
82
|
+
reason: event.reason,
|
|
83
|
+
surface: {
|
|
84
|
+
getAllTools: () => pi.getAllTools(),
|
|
85
|
+
setActiveTools: (names) => pi.setActiveTools(names),
|
|
86
|
+
modelRegistry: ctx.modelRegistry,
|
|
87
|
+
setModel: (model) => pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0]),
|
|
88
|
+
setThinkingLevel: (level) =>
|
|
89
|
+
pi.setThinkingLevel(level as Parameters<ExtensionAPI["setThinkingLevel"]>[0]),
|
|
90
|
+
events: pi.events,
|
|
91
|
+
notify: (message, level) => ctx.ui?.notify(message, level),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
pendingSummary = result.summary;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.on("before_agent_start", async (event) => {
|
|
98
|
+
const plan = await readLaunchPlanFile(runtimeDir);
|
|
99
|
+
const instructions = plan?.instructions;
|
|
100
|
+
let systemPrompt = event.systemPrompt;
|
|
101
|
+
if (instructions !== undefined && instructions.length > 0 && !systemPrompt.includes(instructions)) {
|
|
102
|
+
systemPrompt = `${systemPrompt}\n\n${instructions}`;
|
|
103
|
+
}
|
|
104
|
+
if (pendingSummary !== undefined) {
|
|
105
|
+
systemPrompt = `${systemPrompt}\n\n[${pendingSummary}]`;
|
|
106
|
+
pendingSummary = undefined;
|
|
107
|
+
}
|
|
108
|
+
return { systemPrompt };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
pi.registerCommand("profile", {
|
|
112
|
+
description: "pi-profile: /profile [use|reload|customize|reset|list|status|create|edit|delete|duplicate]",
|
|
113
|
+
handler: async (args, ctx) => {
|
|
114
|
+
const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
115
|
+
const subcommand = subcommandRaw ?? ""; // bare /profile → selector
|
|
116
|
+
// Stale-tolerant: after a successful reload this command context is
|
|
117
|
+
// invalidated and property access throws. Post-reload feedback is
|
|
118
|
+
// the new instance's job (session_start summary), so swallowed
|
|
119
|
+
// stale-ctx failures lose nothing the user would otherwise see.
|
|
120
|
+
const notify = (message: string, level: "info" | "warning" | "error") => {
|
|
121
|
+
try {
|
|
122
|
+
ctx.ui?.notify(message, level);
|
|
123
|
+
} catch {
|
|
124
|
+
// stale context after reload — see above
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const usage = `usage: /profile [use <name> | reload | ${CUSTOMIZE_USAGE} | reset | list | status | create | edit <name> | delete <name> | duplicate]`;
|
|
128
|
+
if (subcommand === "use" && rest.length === 0) {
|
|
129
|
+
notify("usage: /profile use <name>", "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
subcommand !== "" &&
|
|
134
|
+
![
|
|
135
|
+
"use",
|
|
136
|
+
"reload",
|
|
137
|
+
"customize",
|
|
138
|
+
"reset",
|
|
139
|
+
"list",
|
|
140
|
+
"status",
|
|
141
|
+
"create",
|
|
142
|
+
"edit",
|
|
143
|
+
"delete",
|
|
144
|
+
"duplicate",
|
|
145
|
+
].includes(
|
|
146
|
+
subcommand,
|
|
147
|
+
)
|
|
148
|
+
) {
|
|
149
|
+
notify(usage, "error");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (["edit", "delete"].includes(subcommand) && rest[0] === undefined) {
|
|
153
|
+
notify(`usage: /profile ${subcommand} <name>`, "error");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const plan = await readLaunchPlanFile(runtimeDir);
|
|
158
|
+
if (plan?.agentDir === undefined) {
|
|
159
|
+
// Without the real agent dir the switch cannot reach catalogs,
|
|
160
|
+
// trust state, or state files — fail loudly, never guess one.
|
|
161
|
+
notify("cannot switch: the launch plan carries no real agent dir", "error");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const deps: SwitchDeps = {
|
|
165
|
+
runtimeDir,
|
|
166
|
+
realAgentDir: plan.agentDir,
|
|
167
|
+
cwd: ctx.cwd,
|
|
168
|
+
waitForIdle: () => ctx.waitForIdle(),
|
|
169
|
+
reload: () => ctx.reload(),
|
|
170
|
+
// A real reload invalidates this context (Pi re-executes
|
|
171
|
+
// extensions); property access then throws. Interactive Pi
|
|
172
|
+
// swallows reload refusals, so this probe is the switch's
|
|
173
|
+
// proof that the reload actually ran.
|
|
174
|
+
assertStale: () => {
|
|
175
|
+
void ctx.cwd;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
if (subcommand === "use") {
|
|
179
|
+
const result = await switchProfile(rest[0], deps, { clearOverlay: true });
|
|
180
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
181
|
+
setProfileStatus(ctx.ui, result.profile);
|
|
182
|
+
notify(`profile active: ${result.profile}`, "info");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (subcommand === "reload") {
|
|
186
|
+
const result = await switchProfile(undefined, deps, { reloadCurrent: true });
|
|
187
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
188
|
+
setProfileStatus(ctx.ui, result.profile);
|
|
189
|
+
notify(`profile reloaded: ${result.profile}`, "info");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (subcommand === "customize") {
|
|
193
|
+
const result = await customizeOverlay(deps, parseCustomizeArgs(rest.join(" ")));
|
|
194
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
195
|
+
notify(`overlay updated: ${result.profile}`, "info");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (subcommand === "reset") {
|
|
199
|
+
const result = await resetOverlay(deps);
|
|
200
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
201
|
+
notify(`overlay cleared: ${result.profile}`, "info");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Profile catalog CRUD (ticket 09): TUI-only wizards; mutations
|
|
205
|
+
// land in the chosen scope file. Editing the active profile
|
|
206
|
+
// reloads immediately; deleting the active profile requires a
|
|
207
|
+
// replacement chosen up front, then switches to it.
|
|
208
|
+
if (["create", "edit", "delete", "duplicate"].includes(subcommand)) {
|
|
209
|
+
if (ctx.mode !== "tui") {
|
|
210
|
+
notify(`/profile ${subcommand} requires TUI mode (current mode: ${ctx.mode})`, "error");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const scopeInput = { realAgentDir: plan.agentDir, cwd: ctx.cwd };
|
|
214
|
+
if (subcommand === "create") {
|
|
215
|
+
const { projectTrusted: canWriteProject } = await readTrustInputs({
|
|
216
|
+
agentDir: plan.agentDir,
|
|
217
|
+
cwd: ctx.cwd,
|
|
218
|
+
});
|
|
219
|
+
const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: canWriteProject });
|
|
220
|
+
if (wizard === undefined) return;
|
|
221
|
+
await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
222
|
+
notify(`created profile "${wizard.name}" (${wizard.scope}) — activate with /profile use ${wizard.name}`, "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (subcommand === "duplicate") {
|
|
226
|
+
const entries = await listProfiles(scopeInput);
|
|
227
|
+
// Read each scope once (trust-gated — never reads an
|
|
228
|
+
// untrusted project's catalog).
|
|
229
|
+
const byScope = {
|
|
230
|
+
global: await readCatalogScope(scopeInput, "global"),
|
|
231
|
+
project: await readCatalogScope(scopeInput, "project"),
|
|
232
|
+
};
|
|
233
|
+
const candidates: Array<{ name: string; source: "global" | "project"; definition: ProfileDefinition }> = [];
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (entry.source === "builtin") continue;
|
|
236
|
+
const definition = byScope[entry.source].get(entry.name);
|
|
237
|
+
if (definition !== undefined) {
|
|
238
|
+
candidates.push({ name: entry.name, source: entry.source, definition });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const wizard = await runProfileDuplicateWizard(ctx.ui, { candidates });
|
|
242
|
+
if (wizard === undefined) return;
|
|
243
|
+
await duplicateProfile(scopeInput, wizard.scope, wizard.sourceName, wizard.newName);
|
|
244
|
+
notify(
|
|
245
|
+
`duplicated "${wizard.sourceName}" → "${wizard.newName}" (${wizard.scope}) — the full definition was copied`,
|
|
246
|
+
"info",
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const name = rest[0] as string;
|
|
251
|
+
if (subcommand === "edit") {
|
|
252
|
+
// Edit the WINNING definition in its source scope.
|
|
253
|
+
const entries = await listProfiles(scopeInput);
|
|
254
|
+
const existing = entries.find((entry) => entry.name === name);
|
|
255
|
+
if (existing === undefined || existing.source === "builtin") {
|
|
256
|
+
notify(`profile "${name}" not found in a writable catalog`, "error");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const definition = (await readCatalogScope(scopeInput, existing.source)).get(name);
|
|
260
|
+
if (definition === undefined) {
|
|
261
|
+
notify(`profile "${name}" not found in the ${existing.source} catalog`, "error");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const wizard = await runProfileEditWizard(ctx.ui, {
|
|
265
|
+
existing: { name, source: existing.source, definition },
|
|
266
|
+
});
|
|
267
|
+
if (wizard === undefined) return;
|
|
268
|
+
await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
269
|
+
if (name === plan.profile) {
|
|
270
|
+
notify(`saved profile "${name}"; reloading`, "info");
|
|
271
|
+
await switchProfile(plan.profile, deps, { reloadCurrent: true });
|
|
272
|
+
} else {
|
|
273
|
+
notify(`saved profile "${name}" (inactive — runtime untouched)`, "info");
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// delete
|
|
278
|
+
const entries = await listProfiles(scopeInput);
|
|
279
|
+
const existing = entries.find((entry) => entry.name === name);
|
|
280
|
+
if (existing === undefined || existing.source === "builtin") {
|
|
281
|
+
notify(`profile "${name}" not found in a writable catalog`, "error");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Both scopes hold the name: choose which record to delete.
|
|
285
|
+
let scope = existing.source as "global" | "project";
|
|
286
|
+
const projectCatalog = await readCatalogScope(scopeInput, "project");
|
|
287
|
+
const globalCatalog = await readCatalogScope(scopeInput, "global");
|
|
288
|
+
if (projectCatalog.has(name) && globalCatalog.has(name)) {
|
|
289
|
+
const chosen = await ctx.ui.select(`delete "${name}" from which catalog?`, ["global", "project"]);
|
|
290
|
+
if (chosen === undefined) return;
|
|
291
|
+
scope = chosen as "global" | "project";
|
|
292
|
+
}
|
|
293
|
+
// If the OTHER scope keeps the name alive, deletion reveals
|
|
294
|
+
// it and the session can stay on the revealed same-name
|
|
295
|
+
// definition (symmetric: project→global and global→project).
|
|
296
|
+
const survivesElsewhere =
|
|
297
|
+
(scope === "project" && globalCatalog.has(name)) || (scope === "global" && projectCatalog.has(name));
|
|
298
|
+
let replacement: string | undefined;
|
|
299
|
+
if (name === plan.profile && !survivesElsewhere) {
|
|
300
|
+
const survivors = entries.filter((entry) => entry.name !== name);
|
|
301
|
+
const chosen = await ctx.ui.select(
|
|
302
|
+
`"${name}" is active — switch to which profile?`,
|
|
303
|
+
survivors.map((entry) => `${entry.name} [${entry.source}]`),
|
|
304
|
+
);
|
|
305
|
+
if (chosen === undefined) return;
|
|
306
|
+
replacement = chosen.split(" [")[0];
|
|
307
|
+
}
|
|
308
|
+
await deleteProfile(scopeInput, scope, name, { activeProfile: plan.profile, replacement });
|
|
309
|
+
if (name === plan.profile) {
|
|
310
|
+
if (replacement !== undefined) {
|
|
311
|
+
notify(`deleted "${name}" (${scope}); switching to ${replacement}`, "info");
|
|
312
|
+
await switchProfile(replacement, deps, { clearOverlay: true });
|
|
313
|
+
} else {
|
|
314
|
+
notify(`deleted the ${scope} record of "${name}"; reloading the revealed definition`, "info");
|
|
315
|
+
await switchProfile(plan.profile, deps, { reloadCurrent: true });
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
notify(`deleted profile "${name}" (${scope})`, "info");
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Observability surface (ticket 07): bare /profile opens the
|
|
323
|
+
// selector; list/status render via a displayed custom message.
|
|
324
|
+
const entries = await listProfiles({ realAgentDir: plan.agentDir, cwd: ctx.cwd });
|
|
325
|
+
if (subcommand === "list") {
|
|
326
|
+
pi.sendMessage({
|
|
327
|
+
customType: "pi-profile",
|
|
328
|
+
content: formatProfileList(entries, plan.profile),
|
|
329
|
+
display: true,
|
|
330
|
+
details: { kind: "list", profiles: entries },
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (subcommand === "status") {
|
|
335
|
+
const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
|
|
336
|
+
const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
337
|
+
const state = await new RuntimeStateStore(stateDir).read();
|
|
338
|
+
const report = buildStatusReport({
|
|
339
|
+
plan,
|
|
340
|
+
overlay: state.overlay,
|
|
341
|
+
discoveredMcpServers: await discoverAdapterServerNames(
|
|
342
|
+
plan.agentDir,
|
|
343
|
+
projectTrusted ? ctx.cwd : undefined,
|
|
344
|
+
),
|
|
345
|
+
commands: pi.getCommands(),
|
|
346
|
+
tools: pi.getAllTools(),
|
|
347
|
+
});
|
|
348
|
+
pi.sendMessage({
|
|
349
|
+
customType: "pi-profile",
|
|
350
|
+
content: formatStatusMarkdown(report),
|
|
351
|
+
display: true,
|
|
352
|
+
// Structured form for RPC consumers (ticket 11): the message
|
|
353
|
+
// event carries the full report object in `details`.
|
|
354
|
+
details: { kind: "status", report },
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Bare /profile: the interactive selector. Without dialog-capable
|
|
359
|
+
// UI (print mode), fall back to the list.
|
|
360
|
+
if (!ctx.hasUI) {
|
|
361
|
+
pi.sendMessage({
|
|
362
|
+
customType: "pi-profile",
|
|
363
|
+
content: formatProfileList(entries, plan.profile),
|
|
364
|
+
display: true,
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const choice = await ctx.ui.select(
|
|
369
|
+
"select a profile",
|
|
370
|
+
entries.map((entry) => {
|
|
371
|
+
const label = entry.label ?? entry.description;
|
|
372
|
+
return `${entry.name} [${entry.source}]${label !== undefined ? ` — ${label}` : ""}`;
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
if (choice === undefined) return; // cancelled
|
|
376
|
+
const chosen = choice.split(" [")[0] ?? choice;
|
|
377
|
+
if (chosen === plan.profile) return;
|
|
378
|
+
const switched = await switchProfile(chosen, deps, { clearOverlay: true });
|
|
379
|
+
for (const warning of switched.warnings) notify(warning, "warning");
|
|
380
|
+
setProfileStatus(ctx.ui, switched.profile);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
notify(error instanceof Error ? error.message : String(error), "error");
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Persistent profile-scoped MCP toggles (ticket 10): edit the active
|
|
388
|
+
// profile's mcp array in its owning catalog, then reload so the runtime
|
|
389
|
+
// and the republished allowlist match. The adapter's own configuration
|
|
390
|
+
// (mcp.json files) is never written.
|
|
391
|
+
pi.registerCommand("mcp", {
|
|
392
|
+
description: "pi-profile: /mcp enable <server> | /mcp disable <server>",
|
|
393
|
+
handler: async (args, ctx) => {
|
|
394
|
+
const notify = (message: string, level: "info" | "warning" | "error") => {
|
|
395
|
+
try {
|
|
396
|
+
ctx.ui?.notify(message, level);
|
|
397
|
+
} catch {
|
|
398
|
+
// stale context after reload — see /profile
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const [action, server] = args.trim().split(/\s+/).filter(Boolean);
|
|
402
|
+
if (!["enable", "disable"].includes(action ?? "") || server === undefined) {
|
|
403
|
+
notify("usage: /mcp enable <server> | /mcp disable <server>", "error");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const plan = await readLaunchPlanFile(runtimeDir);
|
|
408
|
+
if (plan?.agentDir === undefined) {
|
|
409
|
+
notify("pi-profile: launch plan is missing agentDir — cannot toggle MCP servers", "error");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!probeAdapterPresence(pi.events)) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"pi-mcp-adapter is not active in this session — /mcp enable|disable requires it " +
|
|
415
|
+
"(select the adapter in the profile's extensions).",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const result = await setMcpServerEnabled(
|
|
419
|
+
{ realAgentDir: plan.agentDir, cwd: ctx.cwd, profile: { name: plan.profile, source: plan.source } },
|
|
420
|
+
server,
|
|
421
|
+
action === "enable",
|
|
422
|
+
);
|
|
423
|
+
if (!result.changed) {
|
|
424
|
+
notify(`MCP server "${server}" is already ${action}d in profile "${plan.profile}"`, "info");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Notify BEFORE the reload: this context is stale afterwards.
|
|
428
|
+
notify(
|
|
429
|
+
`${action}d MCP server "${server}" in profile "${plan.profile}" (mcps: [${result.mcps.join(", ")}]); reloading`,
|
|
430
|
+
"info",
|
|
431
|
+
);
|
|
432
|
+
const switched = await switchProfile(plan.profile, {
|
|
433
|
+
runtimeDir,
|
|
434
|
+
realAgentDir: plan.agentDir,
|
|
435
|
+
cwd: ctx.cwd,
|
|
436
|
+
waitForIdle: () => ctx.waitForIdle(),
|
|
437
|
+
reload: () => ctx.reload(),
|
|
438
|
+
// Same staleness probe as /profile: interactive Pi
|
|
439
|
+
// swallows reload refusals; a live context afterwards
|
|
440
|
+
// means the reload never ran.
|
|
441
|
+
assertStale: () => {
|
|
442
|
+
void ctx.cwd;
|
|
443
|
+
},
|
|
444
|
+
}, { reloadCurrent: true });
|
|
445
|
+
for (const warning of switched.warnings) notify(warning, "warning");
|
|
446
|
+
} catch (error) {
|
|
447
|
+
notify(error instanceof Error ? error.message : String(error), "error");
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
package/package.json
CHANGED
|
@@ -1,29 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Named profiles for Pi: skills, MCP servers,
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
8
|
-
"pi",
|
|
9
|
-
"pi-profile-switch",
|
|
8
|
+
"pi agent",
|
|
10
9
|
"pi-profile-switch",
|
|
11
10
|
"profile",
|
|
12
|
-
"
|
|
13
|
-
"profile-switching",
|
|
14
|
-
"skills",
|
|
15
|
-
"mcp",
|
|
16
|
-
"tools"
|
|
11
|
+
"pi"
|
|
17
12
|
],
|
|
18
13
|
"license": "MIT",
|
|
19
|
-
"author": "VincentFF",
|
|
20
14
|
"repository": {
|
|
21
15
|
"type": "git",
|
|
22
16
|
"url": "git+https://github.com/VincentFF/pi-profile-switch.git"
|
|
23
17
|
},
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"url": "https://github.com/VincentFF/pi-profile-switch/issues"
|
|
18
|
+
"bin": {
|
|
19
|
+
"pi-profile": "bin/pi-profile.js"
|
|
27
20
|
},
|
|
28
21
|
"pi": {
|
|
29
22
|
"extensions": [
|
|
@@ -31,6 +24,7 @@
|
|
|
31
24
|
]
|
|
32
25
|
},
|
|
33
26
|
"scripts": {
|
|
27
|
+
"postinstall": "node bin/postinstall.js",
|
|
34
28
|
"check": "tsc --noEmit",
|
|
35
29
|
"test": "vitest run",
|
|
36
30
|
"test:watch": "vitest"
|
|
@@ -46,9 +40,12 @@
|
|
|
46
40
|
"vitest": "^3.0.0"
|
|
47
41
|
},
|
|
48
42
|
"dependencies": {
|
|
43
|
+
"jiti": "^2.7.0",
|
|
49
44
|
"minimatch": "^10.2.6"
|
|
50
45
|
},
|
|
51
46
|
"files": [
|
|
47
|
+
"bin",
|
|
48
|
+
"defaults",
|
|
52
49
|
"extensions",
|
|
53
50
|
"src",
|
|
54
51
|
"schemas",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"title": "pi-profile-switch profile catalog",
|
|
4
|
-
"description": "Named profile definitions (global ~/.pi/agent/profiles.json or project .pi/profiles.json). Definitions are complete and self-contained: there is no inheritance field, and references are names/globs, never copies.
|
|
4
|
+
"description": "Named profile definitions (global ~/.pi-profile-switch/profiles.json, legacy fallback ~/.pi/agent/profiles.json; or project .pi/profiles.json). Definitions are complete and self-contained: there is no inheritance field, and resource references are names/globs, never copies. The built-in \"default\" profile must not be defined here.",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"required": [
|
|
7
7
|
"schemaVersion",
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"additionalProperties": false,
|
|
11
11
|
"properties": {
|
|
12
12
|
"schemaVersion": {
|
|
13
|
-
"description": "Catalog format version. Only 1 is accepted.",
|
|
14
13
|
"const": 1
|
|
15
14
|
},
|
|
16
15
|
"profiles": {
|
|
@@ -42,41 +41,40 @@
|
|
|
42
41
|
"items": {
|
|
43
42
|
"type": "string"
|
|
44
43
|
},
|
|
45
|
-
"description": "Skill names or globs.
|
|
44
|
+
"description": "Skill names or globs (e.g. \"review\", \"internal-*\") referencing discovered skills."
|
|
45
|
+
},
|
|
46
|
+
"extensions": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"items": {
|
|
49
|
+
"type": "string"
|
|
50
|
+
},
|
|
51
|
+
"description": "Extension names or globs. Package names, source aliases, loose-file stems, glob patterns, or direct file paths, resolved against discovered extensions."
|
|
46
52
|
},
|
|
47
53
|
"mcps": {
|
|
48
54
|
"type": "array",
|
|
49
55
|
"items": {
|
|
50
56
|
"type": "string"
|
|
51
57
|
},
|
|
52
|
-
"description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config.
|
|
58
|
+
"description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
|
|
53
59
|
},
|
|
54
60
|
"tools": {
|
|
55
61
|
"type": "array",
|
|
56
62
|
"items": {
|
|
57
63
|
"type": "string"
|
|
58
64
|
},
|
|
59
|
-
"description": "Tool names or globs expanded against Pi's live registry (including extension
|
|
65
|
+
"description": "Tool names or globs expanded against Pi's live registry (including extension tools)."
|
|
60
66
|
},
|
|
61
|
-
"
|
|
62
|
-
"type": "
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"id": {
|
|
73
|
-
"type": "string"
|
|
74
|
-
},
|
|
75
|
-
"thinkingLevel": {
|
|
76
|
-
"type": "string"
|
|
77
|
-
}
|
|
78
|
-
},
|
|
79
|
-
"description": "Session-start model preset. An explicit --model/--thinking flag or a model recorded in the session history wins; /profile use applies the preset."
|
|
67
|
+
"defaultProvider": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "Startup provider for this profile (e.g. \"anthropic\", \"openai\"). Mirrors Pi's settings.defaultProvider."
|
|
70
|
+
},
|
|
71
|
+
"defaultModel": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Startup model ID for this profile. Mirrors Pi's settings.defaultModel."
|
|
74
|
+
},
|
|
75
|
+
"defaultThinkingLevel": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Startup thinking level for this profile. Mirrors Pi's settings.defaultThinkingLevel."
|
|
80
78
|
},
|
|
81
79
|
"instructions": {
|
|
82
80
|
"type": "string",
|