pi-profile-switch 0.1.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/LICENSE +21 -0
- package/README.md +106 -0
- package/README.zh-CN.md +106 -0
- package/examples/profiles.json +38 -0
- package/extensions/pi-profile-switch/index.ts +549 -0
- package/package.json +59 -0
- package/schemas/profiles.schema.json +94 -0
- package/src/json-file.ts +35 -0
- package/src/mcp-config.ts +63 -0
- package/src/mcp-coordination.ts +46 -0
- package/src/model-selection.ts +64 -0
- package/src/name-matching.ts +50 -0
- package/src/profile-catalog-store.ts +91 -0
- package/src/profile-catalog.ts +234 -0
- package/src/profile-resolver.ts +247 -0
- package/src/runtime-state-store.ts +105 -0
- package/src/skill-selection.ts +81 -0
- package/src/startup-selection.ts +147 -0
- package/src/switching/activate-profile.ts +113 -0
- package/src/switching/apply-profile.ts +131 -0
- package/src/switching/customize.ts +87 -0
- package/src/switching/list-profiles.ts +55 -0
- package/src/switching/mcp-toggle.ts +75 -0
- package/src/switching/profile-crud.ts +132 -0
- package/src/switching/profile-wizard.ts +158 -0
- package/src/switching/status.ts +113 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
type BuildSystemPromptOptions,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionCommandContext,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
10
|
+
import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
11
|
+
import { readSessionChoices } from "../../src/model-selection.ts";
|
|
12
|
+
import type { ProfileDefinition } from "../../src/profile-catalog.ts";
|
|
13
|
+
import {
|
|
14
|
+
formatSelectionWarnings,
|
|
15
|
+
type LiveResources,
|
|
16
|
+
type ResolvedSelection,
|
|
17
|
+
} from "../../src/profile-resolver.ts";
|
|
18
|
+
import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
|
|
19
|
+
import {
|
|
20
|
+
applySkillsFilter,
|
|
21
|
+
formatInstructionsBlock,
|
|
22
|
+
type SkillsFilterOutcome,
|
|
23
|
+
} from "../../src/skill-selection.ts";
|
|
24
|
+
import {
|
|
25
|
+
detectExplicitDeclarations,
|
|
26
|
+
readProfileFlag,
|
|
27
|
+
registerProfileFlag,
|
|
28
|
+
resolveStartupProfile,
|
|
29
|
+
} from "../../src/startup-selection.ts";
|
|
30
|
+
import { retryPendingTools, type ApplySurface } from "../../src/switching/apply-profile.ts";
|
|
31
|
+
import {
|
|
32
|
+
activateProfile,
|
|
33
|
+
type ActivationDeps,
|
|
34
|
+
type ActivationResult,
|
|
35
|
+
} from "../../src/switching/activate-profile.ts";
|
|
36
|
+
import { CUSTOMIZE_USAGE, customizeOverlay, parseCustomizeArgs, resetOverlay } from "../../src/switching/customize.ts";
|
|
37
|
+
import { formatProfileList, listProfiles, type ProfileListEntry } from "../../src/switching/list-profiles.ts";
|
|
38
|
+
import { setMcpServerEnabled } from "../../src/switching/mcp-toggle.ts";
|
|
39
|
+
import {
|
|
40
|
+
createProfile,
|
|
41
|
+
deleteProfile,
|
|
42
|
+
duplicateProfile,
|
|
43
|
+
editProfile,
|
|
44
|
+
readCatalogScope,
|
|
45
|
+
type CatalogInput,
|
|
46
|
+
} from "../../src/switching/profile-crud.ts";
|
|
47
|
+
import {
|
|
48
|
+
runProfileCreateWizard,
|
|
49
|
+
runProfileDuplicateWizard,
|
|
50
|
+
runProfileEditWizard,
|
|
51
|
+
} from "../../src/switching/profile-wizard.ts";
|
|
52
|
+
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* pi-profile-switch extension entry (ADR-0007).
|
|
56
|
+
*
|
|
57
|
+
* Installed like any other Pi package: no launcher, no environment override,
|
|
58
|
+
* no generated settings. The agent dir stays Pi's own, so sessions,
|
|
59
|
+
* extension configuration, packages, project trust, and context files are
|
|
60
|
+
* all native.
|
|
61
|
+
*
|
|
62
|
+
* Responsibilities:
|
|
63
|
+
* - `session_start`: resolve the startup profile (`--profile <flag>`, else
|
|
64
|
+
* the saved selection, else `default`), then apply the runtime parts of
|
|
65
|
+
* the selection — model preset, active tools, MCP allowlist. A failed
|
|
66
|
+
* activation applies nothing and reports loudly.
|
|
67
|
+
* - `before_agent_start`: rebuild the system prompt each turn — replace the
|
|
68
|
+
* skills section with the profile's visible set and append the profile's
|
|
69
|
+
* instructions. Unselected skills stay loaded and `/skill:`-invocable.
|
|
70
|
+
* - `/profile …` command family and `/mcp enable|disable`.
|
|
71
|
+
* - Retry pending tool literals each turn until MCP/extension tools register.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => BuildSystemPromptOptions };
|
|
75
|
+
|
|
76
|
+
/** The active profile in this runtime and the last prompt-filter result. */
|
|
77
|
+
interface Activation {
|
|
78
|
+
selection: ResolvedSelection;
|
|
79
|
+
skillsOutcome?: SkillsFilterOutcome;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Subcommands that mutate a catalog; they need dialog-capable UI. */
|
|
83
|
+
const CRUD_SUBCOMMANDS = ["create", "duplicate", "edit", "delete"] as const;
|
|
84
|
+
const SUBCOMMANDS = ["use", "list", "status", "customize", "reset", ...CRUD_SUBCOMMANDS] as const;
|
|
85
|
+
const REQUIRED_ARGUMENT: Readonly<Record<string, string>> = { use: "<name>", edit: "<name>", delete: "<name>" };
|
|
86
|
+
const PROFILE_USAGE = [
|
|
87
|
+
"/profile [picker]",
|
|
88
|
+
"/profile use <name>",
|
|
89
|
+
"/profile list | /profile status",
|
|
90
|
+
CUSTOMIZE_USAGE,
|
|
91
|
+
"/profile reset",
|
|
92
|
+
"/profile create | /profile duplicate",
|
|
93
|
+
"/profile edit <name> | /profile delete <name>",
|
|
94
|
+
].join(" · ");
|
|
95
|
+
|
|
96
|
+
export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
97
|
+
registerProfileFlag(pi);
|
|
98
|
+
const explicit = detectExplicitDeclarations(process.argv.slice(2));
|
|
99
|
+
let current: Activation | undefined;
|
|
100
|
+
let filterWarningShown = false;
|
|
101
|
+
|
|
102
|
+
const surface = (ctx: ExtensionContext): ApplySurface => ({
|
|
103
|
+
getAllTools: () => pi.getAllTools(),
|
|
104
|
+
setActiveTools: (names) => pi.setActiveTools(names),
|
|
105
|
+
modelRegistry: ctx.modelRegistry,
|
|
106
|
+
// The package does not export Pi's ThinkingLevel type, so the surface
|
|
107
|
+
// takes the API's own parameter types at the wiring boundary.
|
|
108
|
+
setModel: (model) => pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0]),
|
|
109
|
+
setThinkingLevel: (level) =>
|
|
110
|
+
pi.setThinkingLevel(level as Parameters<ExtensionAPI["setThinkingLevel"]>[0]),
|
|
111
|
+
events: pi.events,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
|
|
115
|
+
ctx.ui.notify(message, level);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function reportWarnings(ctx: ExtensionContext, warnings: string[]): void {
|
|
119
|
+
for (const warning of warnings) notify(ctx, warning, "warning");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadLive(ctx: ExtensionContext, projectTrusted: boolean): Promise<LiveResources> {
|
|
123
|
+
const options = (ctx as ContextWithOptions).getSystemPromptOptions?.();
|
|
124
|
+
const skills = (options?.skills ?? []).map((skill) => ({ name: skill.name, filePath: skill.filePath }));
|
|
125
|
+
const adapterPresent = probeAdapterPresence(pi.events);
|
|
126
|
+
let servers: string[] = [];
|
|
127
|
+
if (adapterPresent) {
|
|
128
|
+
try {
|
|
129
|
+
servers = await discoverAdapterServerNames(getAgentDir(), projectTrusted ? ctx.cwd : undefined);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "warning");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { skills, toolNames: pi.getAllTools().map((tool) => tool.name), mcp: { adapterPresent, servers } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Builds the dependencies for one activation. `force` marks an explicit
|
|
138
|
+
* user choice (`/profile use`, picker, CRUD reactivation): the profile's
|
|
139
|
+
* model and tool declarations then outrank the session state and the
|
|
140
|
+
* CLI flags. */
|
|
141
|
+
async function activationDeps(ctx: ExtensionContext, force: boolean): Promise<ActivationDeps> {
|
|
142
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
143
|
+
return {
|
|
144
|
+
agentDir: getAgentDir(),
|
|
145
|
+
cwd: ctx.cwd,
|
|
146
|
+
projectTrusted,
|
|
147
|
+
live: await loadLive(ctx, projectTrusted),
|
|
148
|
+
surface: surface(ctx),
|
|
149
|
+
presetInputs: { explicit, session: readSessionChoices(ctx.sessionManager.getEntries()), force },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resolves, validates, persists and applies one profile, then records it
|
|
154
|
+
* as the active selection and reports its warnings. */
|
|
155
|
+
async function activate(
|
|
156
|
+
ctx: ExtensionContext,
|
|
157
|
+
name: string,
|
|
158
|
+
options?: { force?: boolean; overlay?: RuntimeOverlay | null; persist?: boolean },
|
|
159
|
+
): Promise<ActivationResult> {
|
|
160
|
+
const deps = await activationDeps(ctx, options?.force ?? false);
|
|
161
|
+
const result = await activateProfile(name, deps, {
|
|
162
|
+
overlay: options?.overlay ?? null,
|
|
163
|
+
persist: options?.persist ?? true,
|
|
164
|
+
});
|
|
165
|
+
current = { selection: result.selection };
|
|
166
|
+
reportWarnings(ctx, result.warnings);
|
|
167
|
+
reportWarnings(ctx, formatSelectionWarnings(result.selection));
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function profileEntries(ctx: ExtensionContext): Promise<ProfileListEntry[]> {
|
|
172
|
+
const { entries, warnings } = await listProfiles({
|
|
173
|
+
realAgentDir: getAgentDir(),
|
|
174
|
+
cwd: ctx.cwd,
|
|
175
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
176
|
+
});
|
|
177
|
+
reportWarnings(ctx, warnings);
|
|
178
|
+
return entries;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sendListMessage(entries: ProfileListEntry[]): void {
|
|
182
|
+
pi.sendMessage({
|
|
183
|
+
customType: "pi-profile-switch",
|
|
184
|
+
content: formatProfileList(entries, current?.selection.name),
|
|
185
|
+
display: true,
|
|
186
|
+
details: { kind: "list", profiles: entries },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Bare `/profile`: the interactive picker, with a list fallback for
|
|
191
|
+
* modes without dialogs. */
|
|
192
|
+
async function runPicker(ctx: ExtensionCommandContext, entries: ProfileListEntry[]): Promise<void> {
|
|
193
|
+
if (!ctx.hasUI) {
|
|
194
|
+
sendListMessage(entries);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const choice = await ctx.ui.select(
|
|
198
|
+
"select a profile",
|
|
199
|
+
entries.map((entry) => {
|
|
200
|
+
const label = entry.label ?? entry.description;
|
|
201
|
+
return `${entry.name} [${entry.source}]${label !== undefined ? ` — ${label}` : ""}`;
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
if (choice === undefined) return; // cancelled
|
|
205
|
+
const chosen = entries.find((entry) => choice.startsWith(`${entry.name} [`));
|
|
206
|
+
if (chosen === undefined || chosen.name === current?.selection.name) return;
|
|
207
|
+
const result = await activate(ctx, chosen.name, { force: true, overlay: null, persist: true });
|
|
208
|
+
notify(ctx, `profile active: ${result.selection.name}`, "info");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `/profile create|duplicate|edit|delete`: TUI-only catalog CRUD. */
|
|
212
|
+
async function runCatalogCrud(
|
|
213
|
+
ctx: ExtensionCommandContext,
|
|
214
|
+
subcommand: string,
|
|
215
|
+
rest: string[],
|
|
216
|
+
scopeInput: CatalogInput,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
if (subcommand === "create") {
|
|
219
|
+
const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: scopeInput.projectTrusted });
|
|
220
|
+
if (wizard === undefined) return;
|
|
221
|
+
await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
222
|
+
notify(ctx, `created profile "${wizard.name}" (${wizard.scope}) — activate with /profile use ${wizard.name}`, "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (subcommand === "duplicate") {
|
|
226
|
+
const entries = await profileEntries(ctx);
|
|
227
|
+
const byScope = {
|
|
228
|
+
global: await readCatalogScope(scopeInput, "global"),
|
|
229
|
+
project: await readCatalogScope(scopeInput, "project"),
|
|
230
|
+
};
|
|
231
|
+
const candidates: Array<{ name: string; source: "global" | "project"; definition: ProfileDefinition }> = [];
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (entry.source === "builtin") continue;
|
|
234
|
+
const definition = byScope[entry.source].get(entry.name);
|
|
235
|
+
if (definition !== undefined) {
|
|
236
|
+
candidates.push({ name: entry.name, source: entry.source, definition });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const wizard = await runProfileDuplicateWizard(ctx.ui, { candidates });
|
|
240
|
+
if (wizard === undefined) return;
|
|
241
|
+
await duplicateProfile(scopeInput, wizard.scope, wizard.sourceName, wizard.newName);
|
|
242
|
+
notify(ctx, `duplicated "${wizard.sourceName}" → "${wizard.newName}" (${wizard.scope})`, "info");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const name = rest[0] as string;
|
|
247
|
+
const entries = await profileEntries(ctx);
|
|
248
|
+
const existing = entries.find((entry) => entry.name === name);
|
|
249
|
+
if (existing === undefined || existing.source === "builtin") {
|
|
250
|
+
notify(ctx, `profile "${name}" not found in a writable catalog`, "error");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (subcommand === "edit") {
|
|
254
|
+
const definition = (await readCatalogScope(scopeInput, existing.source)).get(name);
|
|
255
|
+
if (definition === undefined) {
|
|
256
|
+
notify(ctx, `profile "${name}" not found in the ${existing.source} catalog`, "error");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const wizard = await runProfileEditWizard(ctx.ui, {
|
|
260
|
+
existing: { name, source: existing.source, definition },
|
|
261
|
+
});
|
|
262
|
+
if (wizard === undefined) return;
|
|
263
|
+
await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
264
|
+
if (current !== undefined && name === current.selection.name) {
|
|
265
|
+
await activate(ctx, name, { persist: true });
|
|
266
|
+
notify(ctx, `saved and reactivated profile "${name}"`, "info");
|
|
267
|
+
} else {
|
|
268
|
+
notify(ctx, `saved profile "${name}" (inactive — runtime untouched)`, "info");
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// delete: choose the scope when both catalogs hold the name, and the
|
|
274
|
+
// replacement when the active profile disappears for good.
|
|
275
|
+
let scope = existing.source as "global" | "project";
|
|
276
|
+
const projectCatalog = await readCatalogScope(scopeInput, "project");
|
|
277
|
+
const globalCatalog = await readCatalogScope(scopeInput, "global");
|
|
278
|
+
if (projectCatalog.has(name) && globalCatalog.has(name)) {
|
|
279
|
+
const chosen = await ctx.ui.select(`delete "${name}" from which catalog?`, ["global", "project"]);
|
|
280
|
+
if (chosen === undefined) return;
|
|
281
|
+
scope = chosen as "global" | "project";
|
|
282
|
+
}
|
|
283
|
+
const survivesElsewhere =
|
|
284
|
+
(scope === "project" && globalCatalog.has(name)) || (scope === "global" && projectCatalog.has(name));
|
|
285
|
+
const isActive = current !== undefined && name === current.selection.name;
|
|
286
|
+
let replacement: string | undefined;
|
|
287
|
+
if (isActive && !survivesElsewhere) {
|
|
288
|
+
const survivors = entries.filter((entry) => entry.name !== name);
|
|
289
|
+
const chosen = await ctx.ui.select(
|
|
290
|
+
`"${name}" is active — switch to which profile?`,
|
|
291
|
+
survivors.map((entry) => `${entry.name} [${entry.source}]`),
|
|
292
|
+
);
|
|
293
|
+
if (chosen === undefined) return;
|
|
294
|
+
replacement = survivors.find((entry) => chosen.startsWith(`${entry.name} [`))?.name;
|
|
295
|
+
if (replacement === undefined) return;
|
|
296
|
+
}
|
|
297
|
+
await deleteProfile(scopeInput, scope, name, {
|
|
298
|
+
...(isActive ? { activeProfile: name } : {}),
|
|
299
|
+
...(replacement !== undefined ? { replacement } : {}),
|
|
300
|
+
});
|
|
301
|
+
if (isActive) {
|
|
302
|
+
const result = await activate(ctx, replacement ?? name, { force: replacement !== undefined, persist: true });
|
|
303
|
+
notify(ctx, `deleted "${name}" (${scope}); profile active: ${result.selection.name}`, "info");
|
|
304
|
+
} else {
|
|
305
|
+
notify(ctx, `deleted profile "${name}" (${scope})`, "info");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
310
|
+
current = undefined;
|
|
311
|
+
filterWarningShown = false;
|
|
312
|
+
const agentDir = getAgentDir();
|
|
313
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
314
|
+
const requested = readProfileFlag(pi);
|
|
315
|
+
let startup;
|
|
316
|
+
try {
|
|
317
|
+
startup = await resolveStartupProfile({
|
|
318
|
+
agentDir,
|
|
319
|
+
cwd: ctx.cwd,
|
|
320
|
+
projectTrusted,
|
|
321
|
+
...(requested !== undefined ? { requested } : {}),
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
// Startup activation never persists (a `--profile` selection is for
|
|
329
|
+
// this run only) and never applies a stored overlay.
|
|
330
|
+
await activate(ctx, startup.name, { persist: false, overlay: null });
|
|
331
|
+
reportWarnings(ctx, startup.warnings);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
334
|
+
reportWarnings(ctx, startup.warnings);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
339
|
+
if (current === undefined) return;
|
|
340
|
+
// Retry tool literals that were not registered at activation time;
|
|
341
|
+
// once all of them resolve, retrying stops so a native manual toggle
|
|
342
|
+
// is never clobbered.
|
|
343
|
+
const retry = retryPendingTools({ selection: current.selection, surface: surface(ctx) });
|
|
344
|
+
if (retry.applied) {
|
|
345
|
+
current = {
|
|
346
|
+
...current,
|
|
347
|
+
selection: {
|
|
348
|
+
...current.selection,
|
|
349
|
+
pendingTools: retry.pendingTools,
|
|
350
|
+
...(retry.active !== undefined ? { tools: retry.active } : {}),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const filtered = applySkillsFilter({
|
|
355
|
+
systemPrompt: event.systemPrompt,
|
|
356
|
+
options: event.systemPromptOptions,
|
|
357
|
+
filter: current.selection.skills,
|
|
358
|
+
});
|
|
359
|
+
if (
|
|
360
|
+
current.selection.skills !== undefined &&
|
|
361
|
+
(filtered.outcome === "section-missing" || filtered.outcome === "no-read-tool") &&
|
|
362
|
+
!filterWarningShown
|
|
363
|
+
) {
|
|
364
|
+
filterWarningShown = true;
|
|
365
|
+
const cause =
|
|
366
|
+
filtered.outcome === "no-read-tool"
|
|
367
|
+
? "neither the read nor the bash tool is active"
|
|
368
|
+
: "the system prompt carries no skills section";
|
|
369
|
+
notify(
|
|
370
|
+
ctx,
|
|
371
|
+
`pi-profile-switch: ${cause} — profile "${current.selection.name}" skills are not narrowed this session`,
|
|
372
|
+
"warning",
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
current = { ...current, skillsOutcome: filtered.outcome };
|
|
376
|
+
let systemPrompt = filtered.systemPrompt;
|
|
377
|
+
if (current.selection.instructions !== undefined) {
|
|
378
|
+
systemPrompt += formatInstructionsBlock(current.selection.name, current.selection.instructions);
|
|
379
|
+
}
|
|
380
|
+
if (systemPrompt === event.systemPrompt) return;
|
|
381
|
+
return { systemPrompt };
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
pi.registerCommand("profile", {
|
|
385
|
+
description: `pi-profile-switch: ${PROFILE_USAGE}`,
|
|
386
|
+
handler: async (args, ctx) => {
|
|
387
|
+
const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
388
|
+
const subcommand = subcommandRaw ?? "";
|
|
389
|
+
if (subcommand !== "" && !(SUBCOMMANDS as readonly string[]).includes(subcommand)) {
|
|
390
|
+
notify(ctx, PROFILE_USAGE, "error");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const required = REQUIRED_ARGUMENT[subcommand];
|
|
394
|
+
if (required !== undefined && rest[0] === undefined) {
|
|
395
|
+
notify(ctx, `usage: /profile ${subcommand} ${required}`, "error");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if ((CRUD_SUBCOMMANDS as readonly string[]).includes(subcommand) && ctx.mode !== "tui") {
|
|
399
|
+
notify(ctx, `/profile ${subcommand} requires TUI mode (current mode: ${ctx.mode})`, "error");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
switch (subcommand) {
|
|
404
|
+
case "": {
|
|
405
|
+
await runPicker(ctx, await profileEntries(ctx));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
case "use": {
|
|
409
|
+
const result = await activate(ctx, rest[0] as string, { force: true, overlay: null, persist: true });
|
|
410
|
+
notify(ctx, `profile active: ${result.selection.name}`, "info");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
case "customize": {
|
|
414
|
+
if (current === undefined) {
|
|
415
|
+
notify(ctx, "no active profile — nothing to customize", "error");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const deps = await activationDeps(ctx, false);
|
|
419
|
+
const target = { profile: { name: current.selection.name, source: current.selection.source } };
|
|
420
|
+
const result = await customizeOverlay({ ...deps, ...target }, parseCustomizeArgs(rest.join(" ")));
|
|
421
|
+
current = { selection: result.selection };
|
|
422
|
+
reportWarnings(ctx, result.warnings);
|
|
423
|
+
notify(ctx, `overlay updated: ${result.selection.name}`, "info");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
case "reset": {
|
|
427
|
+
if (current === undefined) {
|
|
428
|
+
notify(ctx, "no active profile — nothing to reset", "error");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const deps = await activationDeps(ctx, false);
|
|
432
|
+
const target = { profile: { name: current.selection.name, source: current.selection.source } };
|
|
433
|
+
const result = await resetOverlay({ ...deps, ...target });
|
|
434
|
+
current = { selection: result.selection };
|
|
435
|
+
reportWarnings(ctx, result.warnings);
|
|
436
|
+
notify(ctx, `overlay cleared: ${result.selection.name}`, "info");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
case "list": {
|
|
440
|
+
sendListMessage(await profileEntries(ctx));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
case "status": {
|
|
444
|
+
await sendStatus(pi, ctx, current);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
default: {
|
|
448
|
+
await runCatalogCrud(ctx, subcommand, rest, {
|
|
449
|
+
realAgentDir: getAgentDir(),
|
|
450
|
+
cwd: ctx.cwd,
|
|
451
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch (error) {
|
|
456
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
pi.registerCommand("mcp", {
|
|
462
|
+
description: "pi-profile-switch: /mcp enable <server> | /mcp disable <server>",
|
|
463
|
+
handler: async (args, ctx) => {
|
|
464
|
+
const [action, server] = args.trim().split(/\s+/).filter(Boolean);
|
|
465
|
+
if (!["enable", "disable"].includes(action ?? "") || server === undefined) {
|
|
466
|
+
notify(ctx, "usage: /mcp enable <server> | /mcp disable <server>", "error");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (current === undefined) {
|
|
470
|
+
notify(ctx, "no active profile — /mcp enable|disable edits the active profile's catalog entry", "error");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
if (!probeAdapterPresence(pi.events)) {
|
|
475
|
+
throw new Error("pi-mcp-adapter is not active in this session — /mcp enable|disable requires it");
|
|
476
|
+
}
|
|
477
|
+
const agentDir = getAgentDir();
|
|
478
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
479
|
+
const profile = { name: current.selection.name, source: current.selection.source };
|
|
480
|
+
const result = await setMcpServerEnabled(
|
|
481
|
+
{ realAgentDir: agentDir, cwd: ctx.cwd, projectTrusted, profile },
|
|
482
|
+
server,
|
|
483
|
+
action === "enable",
|
|
484
|
+
);
|
|
485
|
+
if (!result.changed) {
|
|
486
|
+
notify(ctx, `MCP server "${server}" is already ${action}d in profile "${profile.name}"`, "info");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Re-activate so the runtime allowlist matches the edited
|
|
490
|
+
// catalog; the stored overlay is preserved.
|
|
491
|
+
const overlay = (await new RuntimeStateStore(stateDirFor(profile.source, { agentDir, cwd: ctx.cwd })).read())
|
|
492
|
+
.overlay;
|
|
493
|
+
await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
|
|
494
|
+
notify(
|
|
495
|
+
ctx,
|
|
496
|
+
`${action}d MCP server "${server}" in profile "${profile.name}" (mcp: [${result.mcp.join(", ")}])`,
|
|
497
|
+
"info",
|
|
498
|
+
);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** `/profile status`: the active selection against the live view. */
|
|
507
|
+
async function sendStatus(
|
|
508
|
+
pi: ExtensionAPI,
|
|
509
|
+
ctx: ExtensionCommandContext,
|
|
510
|
+
current: Activation | undefined,
|
|
511
|
+
): Promise<void> {
|
|
512
|
+
if (current === undefined) {
|
|
513
|
+
pi.sendMessage({
|
|
514
|
+
customType: "pi-profile-switch",
|
|
515
|
+
content: "no active profile (activation failed or nothing was resolved — a plain Pi session)",
|
|
516
|
+
display: true,
|
|
517
|
+
details: { kind: "status", report: undefined },
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const options = ctx.getSystemPromptOptions();
|
|
522
|
+
const allSkills = (options.skills ?? []).map((skill) => ({ name: skill.name, filePath: skill.filePath }));
|
|
523
|
+
let discovered: string[] = [];
|
|
524
|
+
try {
|
|
525
|
+
discovered = await discoverAdapterServerNames(getAgentDir(), ctx.isProjectTrusted() ? ctx.cwd : undefined);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
528
|
+
}
|
|
529
|
+
const overlay = (
|
|
530
|
+
await new RuntimeStateStore(
|
|
531
|
+
stateDirFor(current.selection.source, { agentDir: getAgentDir(), cwd: ctx.cwd }),
|
|
532
|
+
).read()
|
|
533
|
+
).overlay;
|
|
534
|
+
const report = {
|
|
535
|
+
...buildStatusReport({
|
|
536
|
+
selection: current.selection,
|
|
537
|
+
allSkills,
|
|
538
|
+
discoveredMcpServers: discovered,
|
|
539
|
+
...(current.skillsOutcome !== undefined ? { filterOutcome: current.skillsOutcome } : {}),
|
|
540
|
+
}),
|
|
541
|
+
...(overlay !== undefined ? { overlay } : {}),
|
|
542
|
+
};
|
|
543
|
+
pi.sendMessage({
|
|
544
|
+
customType: "pi-profile-switch",
|
|
545
|
+
content: formatStatusMarkdown(report),
|
|
546
|
+
display: true,
|
|
547
|
+
details: { kind: "status", report },
|
|
548
|
+
});
|
|
549
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-profile-switch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Named profiles for Pi: skills, MCP servers, tools, model, and instructions per workflow — switched in place in the same session.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-profile-switch",
|
|
10
|
+
"pi-profile-switch",
|
|
11
|
+
"profile",
|
|
12
|
+
"profiles",
|
|
13
|
+
"profile-switching",
|
|
14
|
+
"skills",
|
|
15
|
+
"mcp",
|
|
16
|
+
"tools"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "VincentFF",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/VincentFF/pi-profile-switch.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/VincentFF/pi-profile-switch#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/VincentFF/pi-profile-switch/issues"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extensions"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"check": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"@types/node": "^24.0.0",
|
|
44
|
+
"ajv": "^8.20.0",
|
|
45
|
+
"typescript": "^5.8.0",
|
|
46
|
+
"vitest": "^3.0.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"minimatch": "^10.2.6"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"extensions",
|
|
53
|
+
"src",
|
|
54
|
+
"schemas",
|
|
55
|
+
"examples",
|
|
56
|
+
"README.md",
|
|
57
|
+
"README.zh-CN.md"
|
|
58
|
+
]
|
|
59
|
+
}
|