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.
@@ -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 explicit = detectExplicitDeclarations(process.argv.slice(2));
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 { skills, toolNames: pi.getAllTools().map((tool) => tool.name), mcp: { adapterPresent, servers } };
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
- current = { selection: result.selection };
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
- const { entries, warnings } = await listProfiles({
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
- notify(ctx, `created profile "${wizard.name}" (${wizard.scope}) activate with /profile use ${wizard.name}`, "info");
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
- current = undefined;
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
- current = { selection: result.selection };
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
- current = { selection: result.selection };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Named profiles for Pi: skills, MCP servers, tools, model, and instructions per workflow — switched in place in the same session.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,7 +10,8 @@
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
12
  "schemaVersion": {
13
- "enum": [1, 2]
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
  }