pi-profile-switch 0.1.0 → 0.3.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 +10 -36
- package/README.zh-CN.md +10 -36
- package/examples/profiles.example.json +74 -0
- package/examples/profiles.json +6 -28
- package/extensions/pi-profile-switch/index.ts +213 -18
- package/package.json +1 -1
- package/schemas/profiles.schema.json +2 -8
- package/src/adapter-presence.ts +75 -0
- package/src/json-file.ts +20 -0
- package/src/mcp-config.ts +153 -34
- package/src/mcp-overlay-file.ts +35 -0
- package/src/mcp-overlay.ts +122 -0
- package/src/profile-badge.ts +142 -0
- package/src/profile-catalog-store.ts +4 -5
- package/src/profile-catalog.ts +17 -43
- package/src/profile-presets.ts +61 -0
- package/src/profile-resolver.ts +51 -18
- package/src/runtime-state-store.ts +13 -0
- package/src/startup-mcp-scope.ts +262 -0
- package/src/startup-selection.ts +1 -4
- package/src/switching/activate-profile.ts +8 -6
- package/src/switching/list-profiles.ts +8 -11
- package/src/switching/profile-wizard.ts +39 -6
|
@@ -6,16 +6,20 @@ import {
|
|
|
6
6
|
type ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
|
+
import { adapterPresent } from "../../src/adapter-presence.ts";
|
|
9
10
|
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
10
11
|
import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
11
12
|
import { readSessionChoices } from "../../src/model-selection.ts";
|
|
13
|
+
import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
|
|
12
14
|
import type { ProfileDefinition } from "../../src/profile-catalog.ts";
|
|
13
15
|
import {
|
|
14
16
|
formatSelectionWarnings,
|
|
17
|
+
formatSkillWarnings,
|
|
18
|
+
skillWarnings,
|
|
15
19
|
type LiveResources,
|
|
16
20
|
type ResolvedSelection,
|
|
17
21
|
} from "../../src/profile-resolver.ts";
|
|
18
|
-
import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
|
|
22
|
+
import { RuntimeStateStore, overlayNarrows, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
|
|
19
23
|
import {
|
|
20
24
|
applySkillsFilter,
|
|
21
25
|
formatInstructionsBlock,
|
|
@@ -27,6 +31,11 @@ import {
|
|
|
27
31
|
registerProfileFlag,
|
|
28
32
|
resolveStartupProfile,
|
|
29
33
|
} from "../../src/startup-selection.ts";
|
|
34
|
+
import {
|
|
35
|
+
readFlagFromArgv,
|
|
36
|
+
syncMcpOverlayForSelection,
|
|
37
|
+
syncStartupMcpOverlay,
|
|
38
|
+
} from "../../src/startup-mcp-scope.ts";
|
|
30
39
|
import { retryPendingTools, type ApplySurface } from "../../src/switching/apply-profile.ts";
|
|
31
40
|
import {
|
|
32
41
|
activateProfile,
|
|
@@ -69,6 +78,8 @@ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/sta
|
|
|
69
78
|
* instructions. Unselected skills stay loaded and `/skill:`-invocable.
|
|
70
79
|
* - `/profile …` command family and `/mcp enable|disable`.
|
|
71
80
|
* - Retry pending tool literals each turn until MCP/extension tools register.
|
|
81
|
+
* - Footer badge: `profile: <name>` (plus `*` for a runtime overlay) in Pi's
|
|
82
|
+
* footer status line while a non-`default` profile is active.
|
|
72
83
|
*/
|
|
73
84
|
|
|
74
85
|
type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => BuildSystemPromptOptions };
|
|
@@ -77,6 +88,21 @@ type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => Bu
|
|
|
77
88
|
interface Activation {
|
|
78
89
|
selection: ResolvedSelection;
|
|
79
90
|
skillsOutcome?: SkillsFilterOutcome;
|
|
91
|
+
/** Whether the skill references were checked against Pi's loaded set.
|
|
92
|
+
* False after a startup activation: `session_start`'s event context
|
|
93
|
+
* cannot read that list, so the check moves to the first turn. */
|
|
94
|
+
skillsChecked: boolean;
|
|
95
|
+
/** The overlay this runtime was activated with, when one is in effect. */
|
|
96
|
+
overlay?: RuntimeOverlay;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Maps an activation result onto the runtime state `current` mirrors. */
|
|
100
|
+
function activationOf(result: ActivationResult, skillsChecked: boolean): Activation {
|
|
101
|
+
return {
|
|
102
|
+
selection: result.selection,
|
|
103
|
+
skillsChecked,
|
|
104
|
+
...(result.overlay === undefined ? {} : { overlay: result.overlay }),
|
|
105
|
+
};
|
|
80
106
|
}
|
|
81
107
|
|
|
82
108
|
/** Subcommands that mutate a catalog; they need dialog-capable UI. */
|
|
@@ -95,9 +121,56 @@ const PROFILE_USAGE = [
|
|
|
95
121
|
|
|
96
122
|
export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
97
123
|
registerProfileFlag(pi);
|
|
98
|
-
const
|
|
124
|
+
const argv = process.argv.slice(2);
|
|
125
|
+
const explicit = detectExplicitDeclarations(argv);
|
|
126
|
+
// pi-mcp-adapter reads its config before any session event fires (and, for
|
|
127
|
+
// eager servers, at its own load time), so the startup profile's overlay
|
|
128
|
+
// is generated here, synchronously. Pi applies CLI flag values only after
|
|
129
|
+
// extension loading, hence argv.
|
|
130
|
+
const loadAgentDir = getAgentDir();
|
|
131
|
+
const adapterInstalled = adapterPresent({
|
|
132
|
+
agentDir: loadAgentDir,
|
|
133
|
+
argv,
|
|
134
|
+
probeAnswered: probeAdapterPresence(pi.events),
|
|
135
|
+
});
|
|
136
|
+
if (adapterInstalled) {
|
|
137
|
+
const requestedConfigPath = readFlagFromArgv(argv, "mcp-config");
|
|
138
|
+
syncStartupMcpOverlay({
|
|
139
|
+
agentDir: loadAgentDir,
|
|
140
|
+
cwd: process.cwd(),
|
|
141
|
+
argv,
|
|
142
|
+
...(requestedConfigPath === undefined ? {} : { overridePath: requestedConfigPath }),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
99
145
|
let current: Activation | undefined;
|
|
100
146
|
let filterWarningShown = false;
|
|
147
|
+
/** The last badge written to the footer, so a refresh only talks to Pi
|
|
148
|
+
* when the rendering actually changed. */
|
|
149
|
+
let badgeText: string | undefined;
|
|
150
|
+
|
|
151
|
+
/** The only writer of `current`'s profile identity (`selection.name` and
|
|
152
|
+
* `overlay`) and of the footer badge. Both mirror the selection this
|
|
153
|
+
* runtime applied, so a failed activation (which throws before reaching
|
|
154
|
+
* here) never claims to be active. The per-turn updates (`pendingTools`,
|
|
155
|
+
* `skillsOutcome`) leave the identity and the badge untouched. */
|
|
156
|
+
function setCurrent(ctx: ExtensionContext, next: Activation | undefined): void {
|
|
157
|
+
current = next;
|
|
158
|
+
refreshBadge(ctx);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Re-renders the badge from `current`. `default` and an unapplied profile
|
|
162
|
+
* render no badge, which removes Pi's footer status line entirely. */
|
|
163
|
+
function refreshBadge(ctx: ExtensionContext): void {
|
|
164
|
+
if (!ctx.hasUI) return;
|
|
165
|
+
const badge =
|
|
166
|
+
current === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: buildProfileBadge(current.selection.name, { overlay: overlayNarrows(current.overlay) });
|
|
169
|
+
const text = badge === undefined ? undefined : renderProfileBadge(badge, ctx.ui.theme);
|
|
170
|
+
if (text === badgeText) return;
|
|
171
|
+
badgeText = text;
|
|
172
|
+
ctx.ui.setStatus(PROFILE_STATUS_KEY, text);
|
|
173
|
+
}
|
|
101
174
|
|
|
102
175
|
const surface = (ctx: ExtensionContext): ApplySurface => ({
|
|
103
176
|
getAllTools: () => pi.getAllTools(),
|
|
@@ -120,8 +193,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
120
193
|
}
|
|
121
194
|
|
|
122
195
|
async function loadLive(ctx: ExtensionContext, projectTrusted: boolean): Promise<LiveResources> {
|
|
196
|
+
// Only command contexts expose the system-prompt options; the
|
|
197
|
+
// `session_start` event context has no accessor, so the loaded skills
|
|
198
|
+
// stay unknown there and their existence check moves to the first turn.
|
|
123
199
|
const options = (ctx as ContextWithOptions).getSystemPromptOptions?.();
|
|
124
|
-
const skills = (options?.skills ?? []).map((skill) => ({ name: skill.name, filePath: skill.filePath }));
|
|
125
200
|
const adapterPresent = probeAdapterPresence(pi.events);
|
|
126
201
|
let servers: string[] = [];
|
|
127
202
|
if (adapterPresent) {
|
|
@@ -131,7 +206,13 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
131
206
|
notify(ctx, error instanceof Error ? error.message : String(error), "warning");
|
|
132
207
|
}
|
|
133
208
|
}
|
|
134
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
...(options?.skills === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { skills: options.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })) }),
|
|
213
|
+
toolNames: pi.getAllTools().map((tool) => tool.name),
|
|
214
|
+
mcp: { adapterPresent, servers },
|
|
215
|
+
};
|
|
135
216
|
}
|
|
136
217
|
|
|
137
218
|
/** Builds the dependencies for one activation. `force` marks an explicit
|
|
@@ -162,20 +243,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
162
243
|
overlay: options?.overlay ?? null,
|
|
163
244
|
persist: options?.persist ?? true,
|
|
164
245
|
});
|
|
165
|
-
|
|
166
|
-
reportWarnings(ctx, result.warnings);
|
|
246
|
+
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
167
247
|
reportWarnings(ctx, formatSelectionWarnings(result.selection));
|
|
168
248
|
return result;
|
|
169
249
|
}
|
|
170
250
|
|
|
171
251
|
async function profileEntries(ctx: ExtensionContext): Promise<ProfileListEntry[]> {
|
|
172
|
-
|
|
252
|
+
return listProfiles({
|
|
173
253
|
realAgentDir: getAgentDir(),
|
|
174
254
|
cwd: ctx.cwd,
|
|
175
255
|
projectTrusted: ctx.isProjectTrusted(),
|
|
176
256
|
});
|
|
177
|
-
reportWarnings(ctx, warnings);
|
|
178
|
-
return entries;
|
|
179
257
|
}
|
|
180
258
|
|
|
181
259
|
function sendListMessage(entries: ProfileListEntry[]): void {
|
|
@@ -187,6 +265,75 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
187
265
|
});
|
|
188
266
|
}
|
|
189
267
|
|
|
268
|
+
/** `session_start` re-check. The load-time pass cannot know an interactive
|
|
269
|
+
* trust answer, and the adapter has already read its config by now — so a
|
|
270
|
+
* difference is written for the next reload and reported instead. */
|
|
271
|
+
function reportMcpOverlayState(
|
|
272
|
+
ctx: ExtensionContext,
|
|
273
|
+
profileName: string,
|
|
274
|
+
selection: ResolvedSelection,
|
|
275
|
+
input: { agentDir: string; cwd: string; projectTrusted: boolean },
|
|
276
|
+
): void {
|
|
277
|
+
const configOverride = readFlagFromArgv(argv, "mcp-config");
|
|
278
|
+
const sync = syncStartupMcpOverlay({
|
|
279
|
+
agentDir: input.agentDir,
|
|
280
|
+
cwd: input.cwd,
|
|
281
|
+
projectTrusted: input.projectTrusted,
|
|
282
|
+
profileName,
|
|
283
|
+
argv,
|
|
284
|
+
...(configOverride === undefined ? {} : { overridePath: configOverride }),
|
|
285
|
+
});
|
|
286
|
+
if (sync.error !== undefined) {
|
|
287
|
+
notify(ctx, `pi-profile-switch: MCP overlay unavailable — ${sync.error}`, "warning");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!sync.managed) {
|
|
291
|
+
if (selection.mcp !== undefined) {
|
|
292
|
+
notify(
|
|
293
|
+
ctx,
|
|
294
|
+
`pi-profile-switch: --mcp-config points at another file — profile "${profileName}" cannot filter MCP servers`,
|
|
295
|
+
"warning",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!sync.changed) return;
|
|
301
|
+
notify(ctx, `pi-profile-switch: MCP config updated for profile "${profileName}" — run /reload to apply`, "warning");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Repoints the adapter at the overlay for a just-activated selection and
|
|
305
|
+
* rebuilds the runtime when the MCP surface actually moved. Must be the
|
|
306
|
+
* caller's LAST use of `ctx`: `reload()` invalidates the old context. */
|
|
307
|
+
async function reloadForMcpOverlay(ctx: ExtensionCommandContext, selection: ResolvedSelection): Promise<void> {
|
|
308
|
+
if (!adapterInstalled) return;
|
|
309
|
+
const configOverride = readFlagFromArgv(argv, "mcp-config");
|
|
310
|
+
const sync = syncMcpOverlayForSelection({
|
|
311
|
+
agentDir: getAgentDir(),
|
|
312
|
+
cwd: ctx.cwd,
|
|
313
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
314
|
+
allowed: selection.mcp === undefined ? "all" : selection.mcp,
|
|
315
|
+
...(configOverride === undefined ? {} : { overridePath: configOverride }),
|
|
316
|
+
});
|
|
317
|
+
if (sync.error !== undefined) {
|
|
318
|
+
notify(ctx, `pi-profile-switch: MCP overlay not updated — ${sync.error}`, "warning");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!sync.managed) {
|
|
322
|
+
if (selection.mcp !== undefined) {
|
|
323
|
+
notify(
|
|
324
|
+
ctx,
|
|
325
|
+
`pi-profile-switch: --mcp-config points at another file — profile "${selection.name}" cannot filter MCP servers`,
|
|
326
|
+
"warning",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!sync.changed) return;
|
|
332
|
+
notify(ctx, `profile "${selection.name}": MCP servers updated — reloading runtime`, "info");
|
|
333
|
+
await ctx.waitForIdle();
|
|
334
|
+
await ctx.reload();
|
|
335
|
+
}
|
|
336
|
+
|
|
190
337
|
/** Bare `/profile`: the interactive picker, with a list fallback for
|
|
191
338
|
* modes without dialogs. */
|
|
192
339
|
async function runPicker(ctx: ExtensionCommandContext, entries: ProfileListEntry[]): Promise<void> {
|
|
@@ -206,6 +353,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
206
353
|
if (chosen === undefined || chosen.name === current?.selection.name) return;
|
|
207
354
|
const result = await activate(ctx, chosen.name, { force: true, overlay: null, persist: true });
|
|
208
355
|
notify(ctx, `profile active: ${result.selection.name}`, "info");
|
|
356
|
+
await reloadForMcpOverlay(ctx, result.selection);
|
|
209
357
|
}
|
|
210
358
|
|
|
211
359
|
/** `/profile create|duplicate|edit|delete`: TUI-only catalog CRUD. */
|
|
@@ -219,7 +367,12 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
219
367
|
const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: scopeInput.projectTrusted });
|
|
220
368
|
if (wizard === undefined) return;
|
|
221
369
|
await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
222
|
-
|
|
370
|
+
const origin = wizard.preset !== undefined ? ` from preset "${wizard.preset}"` : "";
|
|
371
|
+
notify(
|
|
372
|
+
ctx,
|
|
373
|
+
`created profile "${wizard.name}" (${wizard.scope})${origin} — activate with /profile use ${wizard.name}`,
|
|
374
|
+
"info",
|
|
375
|
+
);
|
|
223
376
|
return;
|
|
224
377
|
}
|
|
225
378
|
if (subcommand === "duplicate") {
|
|
@@ -262,8 +415,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
262
415
|
if (wizard === undefined) return;
|
|
263
416
|
await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
264
417
|
if (current !== undefined && name === current.selection.name) {
|
|
265
|
-
await activate(ctx, name, { persist: true });
|
|
418
|
+
const reactivated = await activate(ctx, name, { persist: true });
|
|
266
419
|
notify(ctx, `saved and reactivated profile "${name}"`, "info");
|
|
420
|
+
await reloadForMcpOverlay(ctx, reactivated.selection);
|
|
267
421
|
} else {
|
|
268
422
|
notify(ctx, `saved profile "${name}" (inactive — runtime untouched)`, "info");
|
|
269
423
|
}
|
|
@@ -301,13 +455,14 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
301
455
|
if (isActive) {
|
|
302
456
|
const result = await activate(ctx, replacement ?? name, { force: replacement !== undefined, persist: true });
|
|
303
457
|
notify(ctx, `deleted "${name}" (${scope}); profile active: ${result.selection.name}`, "info");
|
|
458
|
+
await reloadForMcpOverlay(ctx, result.selection);
|
|
304
459
|
} else {
|
|
305
460
|
notify(ctx, `deleted profile "${name}" (${scope})`, "info");
|
|
306
461
|
}
|
|
307
462
|
}
|
|
308
463
|
|
|
309
464
|
pi.on("session_start", async (_event, ctx) => {
|
|
310
|
-
|
|
465
|
+
setCurrent(ctx, undefined);
|
|
311
466
|
filterWarningShown = false;
|
|
312
467
|
const agentDir = getAgentDir();
|
|
313
468
|
const projectTrusted = ctx.isProjectTrusted();
|
|
@@ -327,8 +482,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
327
482
|
try {
|
|
328
483
|
// Startup activation never persists (a `--profile` selection is for
|
|
329
484
|
// this run only) and never applies a stored overlay.
|
|
330
|
-
await activate(ctx, startup.name, { persist: false, overlay: null });
|
|
485
|
+
const activated = await activate(ctx, startup.name, { persist: false, overlay: null });
|
|
331
486
|
reportWarnings(ctx, startup.warnings);
|
|
487
|
+
if (adapterInstalled) {
|
|
488
|
+
reportMcpOverlayState(ctx, startup.name, activated.selection, {
|
|
489
|
+
agentDir,
|
|
490
|
+
cwd: ctx.cwd,
|
|
491
|
+
projectTrusted,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
332
494
|
} catch (error) {
|
|
333
495
|
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
334
496
|
reportWarnings(ctx, startup.warnings);
|
|
@@ -351,6 +513,39 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
351
513
|
},
|
|
352
514
|
};
|
|
353
515
|
}
|
|
516
|
+
// A startup activation cannot see Pi's skill list, so its references are
|
|
517
|
+
// checked here, on the first turn that carries the complete set — by then
|
|
518
|
+
// `resources_discover` has contributed every extension's skills. The
|
|
519
|
+
// corrected warnings replace the (empty) startup ones, so `/profile
|
|
520
|
+
// status` reports the same thing the user was told.
|
|
521
|
+
if (!current.skillsChecked) {
|
|
522
|
+
const refs = current.selection.skills?.refs;
|
|
523
|
+
const corrected =
|
|
524
|
+
refs === undefined || refs === "all"
|
|
525
|
+
? undefined
|
|
526
|
+
: skillWarnings(
|
|
527
|
+
refs,
|
|
528
|
+
(event.systemPromptOptions.skills ?? []).map((skill) => ({
|
|
529
|
+
name: skill.name,
|
|
530
|
+
filePath: skill.filePath,
|
|
531
|
+
})),
|
|
532
|
+
);
|
|
533
|
+
current = {
|
|
534
|
+
...current,
|
|
535
|
+
skillsChecked: true,
|
|
536
|
+
...(corrected === undefined
|
|
537
|
+
? {}
|
|
538
|
+
: { selection: { ...current.selection, warnings: { ...current.selection.warnings, ...corrected } } }),
|
|
539
|
+
};
|
|
540
|
+
if (corrected !== undefined) {
|
|
541
|
+
reportWarnings(ctx, formatSkillWarnings(current.selection.name, corrected));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Pi has no extension-visible theme-change event and footer statuses are
|
|
545
|
+
// stored as finished strings, so re-render once per turn: a `/theme`
|
|
546
|
+
// switch is picked up without waiting for the next profile change.
|
|
547
|
+
// `refreshBadge` dedupes, so an unchanged badge sends nothing.
|
|
548
|
+
refreshBadge(ctx);
|
|
354
549
|
const filtered = applySkillsFilter({
|
|
355
550
|
systemPrompt: event.systemPrompt,
|
|
356
551
|
options: event.systemPromptOptions,
|
|
@@ -408,6 +603,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
408
603
|
case "use": {
|
|
409
604
|
const result = await activate(ctx, rest[0] as string, { force: true, overlay: null, persist: true });
|
|
410
605
|
notify(ctx, `profile active: ${result.selection.name}`, "info");
|
|
606
|
+
await reloadForMcpOverlay(ctx, result.selection);
|
|
411
607
|
return;
|
|
412
608
|
}
|
|
413
609
|
case "customize": {
|
|
@@ -418,8 +614,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
418
614
|
const deps = await activationDeps(ctx, false);
|
|
419
615
|
const target = { profile: { name: current.selection.name, source: current.selection.source } };
|
|
420
616
|
const result = await customizeOverlay({ ...deps, ...target }, parseCustomizeArgs(rest.join(" ")));
|
|
421
|
-
|
|
422
|
-
reportWarnings(ctx, result.warnings);
|
|
617
|
+
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
423
618
|
notify(ctx, `overlay updated: ${result.selection.name}`, "info");
|
|
424
619
|
return;
|
|
425
620
|
}
|
|
@@ -431,8 +626,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
431
626
|
const deps = await activationDeps(ctx, false);
|
|
432
627
|
const target = { profile: { name: current.selection.name, source: current.selection.source } };
|
|
433
628
|
const result = await resetOverlay({ ...deps, ...target });
|
|
434
|
-
|
|
435
|
-
reportWarnings(ctx, result.warnings);
|
|
629
|
+
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
436
630
|
notify(ctx, `overlay cleared: ${result.selection.name}`, "info");
|
|
437
631
|
return;
|
|
438
632
|
}
|
|
@@ -490,12 +684,13 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
490
684
|
// catalog; the stored overlay is preserved.
|
|
491
685
|
const overlay = (await new RuntimeStateStore(stateDirFor(profile.source, { agentDir, cwd: ctx.cwd })).read())
|
|
492
686
|
.overlay;
|
|
493
|
-
await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
|
|
687
|
+
const reactivated = await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
|
|
494
688
|
notify(
|
|
495
689
|
ctx,
|
|
496
690
|
`${action}d MCP server "${server}" in profile "${profile.name}" (mcp: [${result.mcp.join(", ")}])`,
|
|
497
691
|
"info",
|
|
498
692
|
);
|
|
693
|
+
await reloadForMcpOverlay(ctx, reactivated.selection);
|
|
499
694
|
} catch (error) {
|
|
500
695
|
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
501
696
|
}
|
package/package.json
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"additionalProperties": false,
|
|
11
11
|
"properties": {
|
|
12
12
|
"schemaVersion": {
|
|
13
|
-
"
|
|
13
|
+
"description": "Catalog format version. Only 1 is accepted.",
|
|
14
|
+
"const": 1
|
|
14
15
|
},
|
|
15
16
|
"profiles": {
|
|
16
17
|
"type": "object",
|
|
@@ -43,13 +44,6 @@
|
|
|
43
44
|
},
|
|
44
45
|
"description": "Skill names or globs. Controls what the model sees in the system prompt's skills section; every loaded skill stays callable by the user through /skill:name."
|
|
45
46
|
},
|
|
46
|
-
"extensions": {
|
|
47
|
-
"type": "array",
|
|
48
|
-
"items": {
|
|
49
|
-
"type": "string"
|
|
50
|
-
},
|
|
51
|
-
"description": "Deprecated (schemaVersion 1). Extensions are always loaded natively (ADR-0007); the field is ignored with a warning."
|
|
52
|
-
},
|
|
53
47
|
"mcp": {
|
|
54
48
|
"type": "array",
|
|
55
49
|
"items": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdapterPresence: a cheap, deterministic "is pi-mcp-adapter installed?"
|
|
3
|
+
* check that does not depend on extension load order.
|
|
4
|
+
*
|
|
5
|
+
* The overlay mechanism only exists to serve the adapter; when the adapter is
|
|
6
|
+
* absent the extension must not register the `mcp-config` flag default and
|
|
7
|
+
* must not write anything. Signals, in order of reliability:
|
|
8
|
+
*
|
|
9
|
+
* 1. Pi's npm package root (`<agentDir>/npm/node_modules/pi-mcp-adapter`).
|
|
10
|
+
* 2. The command line (`-e <path>` / `--extension <path>`).
|
|
11
|
+
* 3. Pi settings `packages` entries.
|
|
12
|
+
* 4. The adapter's own event-bus presence probe, when it answered during
|
|
13
|
+
* extension loading (only reliable when the adapter loaded first).
|
|
14
|
+
*
|
|
15
|
+
* Any failure reads as "absent": a false negative only disables the overlay,
|
|
16
|
+
* while a false positive could hide the user's own Pi-global slot file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
import { isRecord } from "./json-file.ts";
|
|
23
|
+
|
|
24
|
+
const ADAPTER_PACKAGE = "pi-mcp-adapter";
|
|
25
|
+
|
|
26
|
+
export interface AdapterPresenceInput {
|
|
27
|
+
agentDir: string;
|
|
28
|
+
argv: readonly string[];
|
|
29
|
+
/** Result of the adapter's event-bus probe, when the caller ran one. */
|
|
30
|
+
probeAnswered?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function adapterPresent(input: AdapterPresenceInput): boolean {
|
|
34
|
+
if (input.probeAnswered === true) return true;
|
|
35
|
+
try {
|
|
36
|
+
for (const candidate of [
|
|
37
|
+
path.join(input.agentDir, "npm", "node_modules", ADAPTER_PACKAGE),
|
|
38
|
+
path.join(input.agentDir, "node_modules", ADAPTER_PACKAGE),
|
|
39
|
+
]) {
|
|
40
|
+
if (existsSync(candidate)) return true;
|
|
41
|
+
}
|
|
42
|
+
return argvMentionsAdapter(input.argv) || settingsListAdapter(input.agentDir);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function argvMentionsAdapter(argv: readonly string[]): boolean {
|
|
49
|
+
for (let index = 0; index < argv.length; index++) {
|
|
50
|
+
const token = argv[index] ?? "";
|
|
51
|
+
if (token.includes(ADAPTER_PACKAGE)) return true;
|
|
52
|
+
if ((token === "-e" || token === "--extension") && (argv[index + 1] ?? "").includes(ADAPTER_PACKAGE)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function settingsListAdapter(agentDir: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
const raw: unknown = JSON.parse(readFileSync(path.join(agentDir, "settings.json"), "utf8"));
|
|
62
|
+
if (!isRecord(raw) || !Array.isArray(raw.packages)) return false;
|
|
63
|
+
return raw.packages.some((entry) => {
|
|
64
|
+
const source =
|
|
65
|
+
typeof entry === "string"
|
|
66
|
+
? entry
|
|
67
|
+
: isRecord(entry) && typeof entry.source === "string"
|
|
68
|
+
? entry.source
|
|
69
|
+
: undefined;
|
|
70
|
+
return source !== undefined && source.includes(ADAPTER_PACKAGE);
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/json-file.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* fallback); this helper only classifies the outcome.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
9
10
|
|
|
10
11
|
export type JsonFileResult =
|
|
@@ -30,6 +31,25 @@ export async function readJsonFile(filePath: string): Promise<JsonFileResult> {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** Synchronous twin of `readJsonFile`, for callers that must finish before
|
|
35
|
+
* an event Pi is about to emit (extension loading). Same classification. */
|
|
36
|
+
export function readJsonFileSync(filePath: string): JsonFileResult {
|
|
37
|
+
let raw: string;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(filePath, "utf8");
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
42
|
+
return { ok: false, reason: "missing" };
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
48
|
+
} catch {
|
|
49
|
+
return { ok: false, reason: "invalid" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
54
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
55
|
}
|