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.
@@ -7,9 +7,11 @@
7
7
  * - A profile references skills, MCP servers, and tools, and may declare
8
8
  * instructions and a model preset. Extensions are not a profile resource:
9
9
  * every installed extension loads natively in every profile.
10
- * - schemaVersion 2 is current. Version 1 files load with their per-profile
11
- * `extensions` field ignored and a warning, so existing catalogs keep
12
- * working without an edit.
10
+ * - schemaVersion 1 is the only accepted version: any other value fails
11
+ * loudly instead of guessing at a shape.
12
+ * - Unknown fields (an `extensions` declaration left over from v0.1.0, an
13
+ * inheritance key) are ignored silently; saving drops them, so a written
14
+ * definition always matches the current shape.
13
15
  *
14
16
  * Invariants:
15
17
  * - The built-in `default` profile never exists in either file and cannot be
@@ -27,7 +29,7 @@ import path from "node:path";
27
29
 
28
30
  import { isRecord, readJsonFile } from "./json-file.ts";
29
31
 
30
- export const PROFILE_SCHEMA_VERSION = 2;
32
+ export const PROFILE_SCHEMA_VERSION = 1;
31
33
  export const DEFAULT_PROFILE_NAME = "default";
32
34
 
33
35
  export interface ProfileModel {
@@ -62,13 +64,6 @@ export interface ResolvedProfile {
62
64
  definition: ProfileDefinition;
63
65
  }
64
66
 
65
- /** One parsed catalog file: definitions plus non-fatal compatibility
66
- * warnings the caller surfaces once per activation. */
67
- export interface CatalogDocument {
68
- profiles: Map<string, ProfileDefinition>;
69
- warnings: string[];
70
- }
71
-
72
67
  export class CatalogError extends Error {
73
68
  constructor(message: string) {
74
69
  super(message);
@@ -94,8 +89,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
94
89
 
95
90
  /** Parses one raw profile definition; exported for the write-side store
96
91
  * (profile-catalog-store.ts) so anything written is loadable. Unknown
97
- * fields are ignored by design — `extensions` is reported by
98
- * parseCatalogDocument, which has the file path for the warning. */
92
+ * fields are ignored by design — an `extensions` key left over from
93
+ * v0.1.0 is dropped silently, exactly like any other unknown key. */
99
94
  export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
100
95
  if (!isRecord(raw)) {
101
96
  throw new CatalogError(`profile "${name}" must be an object`);
@@ -123,22 +118,16 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
123
118
 
124
119
  /** Parses one catalog document. Missing files are handled by the caller;
125
120
  * this function sees only parsed JSON. */
126
- export function parseCatalogDocument(value: unknown, filePath: string): CatalogDocument {
121
+ export function parseCatalogDocument(value: unknown, filePath: string): Map<string, ProfileDefinition> {
127
122
  if (!isRecord(value)) {
128
123
  throw new CatalogError(`${filePath}: catalog must be an object`);
129
124
  }
130
- const warnings: string[] = [];
131
125
  const version = value.schemaVersion;
132
- if (version !== 1 && version !== PROFILE_SCHEMA_VERSION) {
126
+ if (version !== PROFILE_SCHEMA_VERSION) {
133
127
  throw new CatalogError(
134
128
  `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
135
129
  );
136
130
  }
137
- if (version === 1) {
138
- warnings.push(
139
- `${filePath}: schemaVersion 1 is read as version ${PROFILE_SCHEMA_VERSION}; profiles declaring "extensions" are upgraded with that field ignored`,
140
- );
141
- }
142
131
  if (!isRecord(value.profiles)) {
143
132
  throw new CatalogError(`${filePath}: "profiles" must be an object mapping names to definitions`);
144
133
  }
@@ -149,21 +138,16 @@ export function parseCatalogDocument(value: unknown, filePath: string): CatalogD
149
138
  `${filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
150
139
  );
151
140
  }
152
- if (isRecord(raw) && raw.extensions !== undefined) {
153
- warnings.push(
154
- `${filePath}: profile "${name}" declares "extensions"; extensions are always loaded natively (ADR-0007) and the field is ignored — manage extensions with pi install`,
155
- );
156
- }
157
141
  profiles.set(name, parseProfileDefinition(name, raw));
158
142
  }
159
- return { profiles, warnings };
143
+ return profiles;
160
144
  }
161
145
 
162
146
  /** Reads one catalog file; missing → empty map, malformed → CatalogError. */
163
- async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
147
+ async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
164
148
  const result = await readJsonFile(catalogPath);
165
149
  if (!result.ok) {
166
- if (result.reason === "missing") return { profiles: new Map(), warnings: [] };
150
+ if (result.reason === "missing") return new Map();
167
151
  throw new CatalogError(`invalid JSON in ${catalogPath}`);
168
152
  }
169
153
  return parseCatalogDocument(result.value, catalogPath);
@@ -171,17 +155,9 @@ async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
171
155
 
172
156
  export class ProfileCatalog {
173
157
  readonly #profiles: ReadonlyMap<string, CatalogEntry>;
174
- readonly #warnings: readonly string[];
175
158
 
176
- private constructor(profiles: ReadonlyMap<string, CatalogEntry>, warnings: readonly string[]) {
159
+ private constructor(profiles: ReadonlyMap<string, CatalogEntry>) {
177
160
  this.#profiles = profiles;
178
- this.#warnings = warnings;
179
- }
180
-
181
- /** Compatibility warnings from reading the catalog files (v1 schema,
182
- * ignored `extensions` fields). Empty for a current, well-formed pair. */
183
- get warnings(): readonly string[] {
184
- return this.#warnings;
185
161
  }
186
162
 
187
163
  /**
@@ -193,18 +169,16 @@ export class ProfileCatalog {
193
169
  static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
194
170
  const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
195
171
  const profiles = new Map<string, CatalogEntry>();
196
- const warnings = [...global.warnings];
197
- for (const [name, definition] of global.profiles) {
172
+ for (const [name, definition] of global) {
198
173
  profiles.set(name, { source: "global", definition });
199
174
  }
200
175
  if (options?.projectDir !== undefined) {
201
176
  const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
202
- warnings.push(...project.warnings);
203
- for (const [name, definition] of project.profiles) {
177
+ for (const [name, definition] of project) {
204
178
  profiles.set(name, { source: "project", definition });
205
179
  }
206
180
  }
207
- return new ProfileCatalog(profiles, warnings);
181
+ return new ProfileCatalog(profiles);
208
182
  }
209
183
 
210
184
  /** Resolves a profile by name. `default` always resolves to the built-in
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ProfilePresets: the starting points `/profile create` offers.
3
+ *
4
+ * A preset is DATA, not a profile. It never appears in `/profile list` and
5
+ * cannot be activated until the create wizard copies it into a user catalog,
6
+ * so `default` stays the only built-in profile and nothing here is ever
7
+ * silently in effect. Once copied, the definition belongs to the user: the
8
+ * preset is not tracked, and later changes to it do not reach existing
9
+ * profiles.
10
+ *
11
+ * Every preset must work on a machine with no skills installed, no MCP
12
+ * adapter, and no provider credentials:
13
+ *
14
+ * - no `mcp`: a declared MCP intent fails the whole activation when
15
+ * `pi-mcp-adapter` is absent or the server was never discovered
16
+ * (ADR-0002).
17
+ * - no `model`: `provider` and `id` are required, and an unauthenticated
18
+ * model also fails the whole activation.
19
+ * - no `skills`: a literal reference warns once the skill turns out to be
20
+ * missing, and `[]` hides every skill — omitting the field keeps Pi's full
21
+ * visibility.
22
+ * - `tools` names Pi's built-in tools only, because extension and MCP tool
23
+ * names may never register, and it always contains `read`: Pi emits the
24
+ * prompt's skills section only while `read` or `bash` is active.
25
+ * - `instructions` states behavior, never a capability name, and stays
26
+ * short: Pi appends it to the system prompt on every turn.
27
+ *
28
+ * `test/profile-presets.test.ts` enforces every rule above, asserts
29
+ * `examples/profiles.json` is exactly this catalog, and asserts each preset
30
+ * appears verbatim in `examples/profiles.example.json`.
31
+ */
32
+
33
+ import type { ProfileDefinition } from "./profile-catalog.ts";
34
+
35
+ export interface ProfilePreset {
36
+ /** Catalog key the create wizard offers as the new profile's name. */
37
+ name: string;
38
+ definition: ProfileDefinition;
39
+ }
40
+
41
+ /** The read-only behavior contract: describe, do not mutate. */
42
+ const READ_ONLY_INSTRUCTIONS = [
43
+ "Read-only session: inspect and report; never create, edit, rename, or delete files.",
44
+ "If a change is needed, describe it in your reply instead of applying it.",
45
+ "Do not run commands that modify state (installs, formatters, commits, pushes, network writes).",
46
+ "Prefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.",
47
+ "Ground claims in evidence: cite file:line and separate verified facts from inferences.",
48
+ "Reply in English.",
49
+ ].join("\n");
50
+
51
+ export const PROFILE_PRESETS: readonly ProfilePreset[] = [
52
+ {
53
+ name: "read-only",
54
+ definition: {
55
+ label: "Read-only",
56
+ description: "Read-only session; no skills or MCP servers assumed — add your own.",
57
+ tools: ["read", "grep", "find", "ls"],
58
+ instructions: READ_ONLY_INSTRUCTIONS,
59
+ },
60
+ },
61
+ ];
@@ -23,7 +23,11 @@ import type { RuntimeOverlay } from "./runtime-state-store.ts";
23
23
 
24
24
  /** The live resource view a resolution runs against. */
25
25
  export interface LiveResources {
26
- skills: Array<{ name: string; filePath: string }>;
26
+ /** Pi's loaded skills. `undefined` when the caller cannot read them yet:
27
+ * Pi exposes the list on command contexts and on the `before_agent_start`
28
+ * event, not in `session_start`'s event context. An unknown set leaves the
29
+ * visibility filter intact and reports nothing (see `skillWarnings`). */
30
+ skills?: Array<{ name: string; filePath: string }>;
27
31
  toolNames: string[];
28
32
  /** MCP adapter state: presence plus discovered server names. */
29
33
  mcp: { adapterPresent: boolean; servers: string[] };
@@ -77,17 +81,21 @@ export class SelectionError extends Error {
77
81
  }
78
82
  }
79
83
 
80
- function resolveSkills(
81
- declared: string[] | undefined,
82
- disabled: string[],
84
+ /** Existence warnings for skill references against one live skill set.
85
+ * `undefined` means "not known yet" — Pi's skill list is only readable from
86
+ * a command context or from the `before_agent_start` event, so a startup
87
+ * activation reports nothing instead of every reference as unloaded. The
88
+ * same function re-checks the references on the first turn, when the list
89
+ * (including skills contributed through `resources_discover`) is complete. */
90
+ export function skillWarnings(
91
+ refs: readonly string[],
83
92
  live: LiveResources["skills"],
84
- ): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
85
- const refs = declared ?? [];
86
- const warning = { skillsUnresolved: [] as UnresolvedRef[], skillsUnmatched: [] as string[] };
87
- if (declared === undefined) {
88
- // The profile declares nothing; an overlay may still hide skills.
89
- return { ...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}), warning };
90
- }
93
+ ): Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> {
94
+ const warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> = {
95
+ skillsUnresolved: [],
96
+ skillsUnmatched: [],
97
+ };
98
+ if (live === undefined) return warning;
91
99
  for (const ref of refs) {
92
100
  const hits = live.filter((skill) => matchesReference(ref, skill.name));
93
101
  if (hits.length === 0) {
@@ -95,7 +103,22 @@ function resolveSkills(
95
103
  else warning.skillsUnresolved.push({ reference: ref, suggestions: suggestNames(ref, live.map((s) => s.name)) });
96
104
  }
97
105
  }
98
- return { filter: { refs, disabled }, warning };
106
+ return warning;
107
+ }
108
+
109
+ function resolveSkills(
110
+ declared: string[] | undefined,
111
+ disabled: string[],
112
+ live: LiveResources["skills"],
113
+ ): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
114
+ if (declared === undefined) {
115
+ // The profile declares nothing; an overlay may still hide skills.
116
+ return {
117
+ ...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}),
118
+ warning: skillWarnings([], live),
119
+ };
120
+ }
121
+ return { filter: { refs: declared, disabled }, warning: skillWarnings(declared, live) };
99
122
  }
100
123
 
101
124
  function resolveMcp(
@@ -211,23 +234,33 @@ export function resolveSelection(input: {
211
234
  return selection;
212
235
  }
213
236
 
214
- /** User-facing warning lines for one resolved selection. */
215
- export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
237
+ /** User-facing lines for one set of skill warnings. Shared by the startup
238
+ * report and the first-turn re-check, so both read identically. */
239
+ export function formatSkillWarnings(
240
+ profile: string,
241
+ warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched">,
242
+ ): string[] {
216
243
  const lines: string[] = [];
217
- for (const unresolved of selection.warnings.skillsUnresolved) {
244
+ for (const unresolved of warning.skillsUnresolved) {
218
245
  const hint =
219
246
  unresolved.suggestions.length > 0
220
247
  ? ` — did you mean: ${unresolved.suggestions.map((name) => JSON.stringify(name)).join(", ")}?`
221
248
  : "";
222
249
  lines.push(
223
- `profile "${selection.name}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
250
+ `profile "${profile}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
224
251
  );
225
252
  }
226
- if (selection.warnings.skillsUnmatched.length > 0) {
253
+ if (warning.skillsUnmatched.length > 0) {
227
254
  lines.push(
228
- `profile "${selection.name}": skill glob(s) ${selection.warnings.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
255
+ `profile "${profile}": skill glob(s) ${warning.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
229
256
  );
230
257
  }
258
+ return lines;
259
+ }
260
+
261
+ /** User-facing warning lines for one resolved selection. */
262
+ export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
263
+ const lines: string[] = formatSkillWarnings(selection.name, selection.warnings);
231
264
  if (selection.warnings.mcpUnmatched.length > 0) {
232
265
  lines.push(
233
266
  `profile "${selection.name}": MCP glob(s) ${selection.warnings.mcpUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
@@ -42,6 +42,19 @@ export interface RuntimeOverlay {
42
42
  tools?: string[];
43
43
  }
44
44
 
45
+ /** True when an overlay actually narrows the active profile. An overlay whose
46
+ * fields were all removed again (`/profile customize enable …`) is not a
47
+ * difference from the catalog, and `parseOverlay` drops an empty overlay on
48
+ * read. A `tools: []` override is a difference: it selects no tools. */
49
+ export function overlayNarrows(overlay: RuntimeOverlay | undefined): boolean {
50
+ if (overlay === undefined) return false;
51
+ return (
52
+ (overlay.disabledSkills?.length ?? 0) > 0 ||
53
+ (overlay.disabledMcp?.length ?? 0) > 0 ||
54
+ overlay.tools !== undefined
55
+ );
56
+ }
57
+
45
58
  function parseOverlay(value: unknown): RuntimeOverlay | undefined {
46
59
  if (!isRecord(value)) return undefined;
47
60
  const overlay: RuntimeOverlay = {};
@@ -0,0 +1,262 @@
1
+ /**
2
+ * StartupMcpScope: derives the profile-scoped MCP overlay at times when a
3
+ * full activation has not (or may not yet) run.
4
+ *
5
+ * Two callers:
6
+ * - The extension-load pass: must be synchronous and must finish before
7
+ * pi-mcp-adapter reads its config at `session_start` (and, for eager
8
+ * servers, at its own load time). Pi applies CLI flag values only AFTER
9
+ * extension loading, so the `--profile` / `--mcp-config` values are read
10
+ * from argv here; trust is mirrored from Pi's own resolution order
11
+ * (`hasTrustRequiringProjectResources` → stored decision →
12
+ * `defaultProjectTrust`; an interactive first-time prompt is not yet
13
+ * answerable at load time and counts as untrusted).
14
+ * - The switch pass: the caller already has the resolved allowlist, so the
15
+ * overlay is derived from it directly.
16
+ *
17
+ * The generated file IS the adapter's Pi-global slot (`<agentDir>/mcp.json`):
18
+ * flag injection is impossible (Pi rejects two extensions registering the
19
+ * same flag), so the mechanism works with the slot the adapter already reads.
20
+ * The user's own Pi-global servers live in the sidecar `mcp.user.json`.
21
+ *
22
+ * Failure policy: a profile name that cannot be resolved (missing, malformed
23
+ * catalog, unknown name) falls back to "no filtering" — the generated overlay
24
+ * then changes nothing, and the real problem is reported loudly by the
25
+ * activation that runs right after. A foreign `--mcp-config` disables
26
+ * management entirely: the user's explicit file is never overwritten.
27
+ */
28
+
29
+ import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
30
+ import path from "node:path";
31
+
32
+ import { isRecord, readJsonFileSync } from "./json-file.ts";
33
+ import { readAdapterOtherServerNamesSync, readMcpDocumentSync } from "./mcp-config.ts";
34
+ import {
35
+ buildMcpOverlay,
36
+ isDisabledStub,
37
+ isGeneratedOverlay,
38
+ MCP_GENERATED_MARKER,
39
+ mcpSlotPath,
40
+ mcpSourcePath,
41
+ resolveAllowedServers,
42
+ serializeMcpOverlay,
43
+ } from "./mcp-overlay.ts";
44
+ import { writeMcpOverlayIfChangedSync } from "./mcp-overlay-file.ts";
45
+ import { DEFAULT_PROFILE_NAME, parseCatalogDocument, type ProfileDefinition } from "./profile-catalog.ts";
46
+
47
+ /** Reads `--<name> <value>` / `--<name>=<value>` without Pi's parser (Pi
48
+ * applies extension flag values only after extension loading). Last wins. */
49
+ export function readFlagFromArgv(argv: readonly string[], name: string): string | undefined {
50
+ const long = `--${name}`;
51
+ let value: string | undefined;
52
+ for (let index = 0; index < argv.length; index++) {
53
+ const token = argv[index] ?? "";
54
+ if (token === long) {
55
+ const next = argv[index + 1];
56
+ if (next !== undefined && !next.startsWith("--")) value = next;
57
+ continue;
58
+ }
59
+ if (token.startsWith(`${long}=`)) {
60
+ const inline = token.slice(long.length + 1);
61
+ if (inline.length > 0) value = inline;
62
+ }
63
+ }
64
+ return value;
65
+ }
66
+
67
+ /** True when Pi itself would consider `cwd` trusted right now. Mirrors
68
+ * `resolveProjectTrusted` minus extension votes and the interactive prompt. */
69
+ export function resolveProjectTrustedSync(agentDir: string, cwd: string): boolean {
70
+ if (!hasTrustRequiringProjectResources(cwd)) return true;
71
+ const stored = new ProjectTrustStore(agentDir).get(cwd);
72
+ if (stored !== null) return stored;
73
+ return readDefaultProjectTrustSync(agentDir) === "always";
74
+ }
75
+
76
+ /** The saved selection for this run: project state wins over global state
77
+ * (project state only when trusted), then the built-in default. */
78
+ export function resolveStartupProfileNameSync(input: {
79
+ agentDir: string;
80
+ cwd: string;
81
+ projectTrusted: boolean;
82
+ argv: readonly string[];
83
+ }): string {
84
+ const requested = readFlagFromArgv(input.argv, "profile");
85
+ if (requested !== undefined && requested.length > 0) return requested;
86
+ const project = input.projectTrusted
87
+ ? readActiveProfile(path.join(input.cwd, ".pi", "pi-profile-state.json"))
88
+ : undefined;
89
+ return project ?? readActiveProfile(path.join(input.agentDir, "pi-profile-state.json")) ?? DEFAULT_PROFILE_NAME;
90
+ }
91
+
92
+ export interface McpOverlaySyncInput {
93
+ agentDir: string;
94
+ cwd: string;
95
+ /** Pi's trust decision. Omitted on the extension-load pass, where Pi has
96
+ * not resolved trust yet; the module mirrors Pi's own order then. */
97
+ projectTrusted?: boolean;
98
+ /** Effective `--mcp-config`; undefined means the managed overlay path. */
99
+ overridePath?: string;
100
+ /** The command line, for the load pass (Pi applies flag values only after
101
+ * extension loading, so `--profile` is read from here). */
102
+ argv?: readonly string[];
103
+ homeDir?: string;
104
+ }
105
+
106
+ export interface McpOverlaySyncResult {
107
+ overlayPath: string;
108
+ /** False when a foreign `--mcp-config` owns the adapter's slot. */
109
+ managed: boolean;
110
+ changed: boolean;
111
+ /** Set when nothing was written; the caller surfaces it as a warning. */
112
+ error?: string;
113
+ }
114
+
115
+ /** Load-time (and `session_start` re-check) pass. `profileName` short-circuits
116
+ * the state lookup when the caller already activated a profile. */
117
+ export function syncStartupMcpOverlay(
118
+ input: McpOverlaySyncInput & { profileName?: string; mcpRefs?: readonly string[] | undefined },
119
+ ): McpOverlaySyncResult {
120
+ try {
121
+ const trust = input.projectTrusted;
122
+ const profileName =
123
+ input.profileName ??
124
+ resolveStartupProfileNameSync({
125
+ agentDir: input.agentDir,
126
+ cwd: input.cwd,
127
+ projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
128
+ argv: input.argv ?? [],
129
+ });
130
+ const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
131
+ return writeOverlayForRefs({ ...input, refs });
132
+ } catch (error) {
133
+ return {
134
+ overlayPath: mcpSlotPath(input.agentDir),
135
+ managed: true,
136
+ changed: false,
137
+ error: error instanceof Error ? error.message : String(error),
138
+ };
139
+ }
140
+ }
141
+
142
+ /** Switch pass: the caller already resolved the allowlist. */
143
+ export function syncMcpOverlayForSelection(
144
+ input: McpOverlaySyncInput & { allowed: readonly string[] | "all" },
145
+ ): McpOverlaySyncResult {
146
+ try {
147
+ return writeOverlay({ ...input, refs: input.allowed });
148
+ } catch (error) {
149
+ return {
150
+ overlayPath: mcpSlotPath(input.agentDir),
151
+ managed: true,
152
+ changed: false,
153
+ error: error instanceof Error ? error.message : String(error),
154
+ };
155
+ }
156
+ }
157
+
158
+ function writeOverlayForRefs(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "unknown" }): McpOverlaySyncResult {
159
+ // An unresolvable profile is not an error here: the generated overlay
160
+ // then filters nothing and the activation reports the real problem.
161
+ const refs = input.refs === "unknown" ? undefined : input.refs;
162
+ return writeOverlay({ ...input, refs });
163
+ }
164
+
165
+ function writeOverlay(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "all" }): McpOverlaySyncResult {
166
+ const overlayPath = mcpSlotPath(input.agentDir);
167
+ if (input.overridePath !== undefined && path.resolve(input.overridePath) !== overlayPath) {
168
+ return { overlayPath, managed: false, changed: false };
169
+ }
170
+ const projectTrusted = input.projectTrusted ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
171
+ const discovery = {
172
+ agentDir: input.agentDir,
173
+ cwd: input.cwd,
174
+ projectTrusted,
175
+ ...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
176
+ };
177
+ // The slot is ours; a slot written by hand is adopted into the sidecar
178
+ // before the first overwrite, so a user's Pi-global servers survive.
179
+ const sourcePath = mcpSourcePath(input.agentDir);
180
+ adoptHandWrittenSlotSync(overlayPath, sourcePath);
181
+ const sourceDocument = readMcpDocumentSync(sourcePath);
182
+ const sourceNames = isRecord(sourceDocument?.mcpServers) ? Object.keys(sourceDocument.mcpServers) : [];
183
+ const otherNames = readAdapterOtherServerNamesSync(discovery);
184
+ const allowed =
185
+ input.refs === "all"
186
+ ? "all"
187
+ : resolveAllowedServers(input.refs, [...new Set([...sourceNames, ...otherNames])].sort());
188
+ const content = serializeMcpOverlay(
189
+ buildMcpOverlay({
190
+ ...(sourceDocument === undefined ? {} : { slotDocument: sourceDocument }),
191
+ otherServerNames: otherNames,
192
+ allowed,
193
+ }),
194
+ );
195
+ return { overlayPath, managed: true, changed: writeMcpOverlayIfChangedSync(overlayPath, content) };
196
+ }
197
+
198
+ /** A slot file without the generated marker holds the user's own Pi-global
199
+ * servers (from before this package managed the slot, or from a manual
200
+ * edit). Move them — plus the slot's non-server keys — into the sidecar
201
+ * before the slot is overwritten. Stub entries are skipped: they describe
202
+ * servers owned by the adapter's other sources. */
203
+ function adoptHandWrittenSlotSync(slotPath: string, sourcePath: string): void {
204
+ const slot = readMcpDocumentSync(slotPath);
205
+ if (slot === undefined || isGeneratedOverlay(slot)) return;
206
+ const sidecar = readMcpDocumentSync(sourcePath) ?? {};
207
+ const sidecarServers = isRecord(sidecar.mcpServers) ? sidecar.mcpServers : {};
208
+ const servers: Record<string, unknown> = { ...sidecarServers };
209
+ const slotServers = isRecord(slot.mcpServers) ? slot.mcpServers : {};
210
+ for (const [name, definition] of Object.entries(slotServers)) {
211
+ if (isDisabledStub(definition)) continue;
212
+ servers[name] = definition;
213
+ }
214
+ const document: Record<string, unknown> = { ...sidecar };
215
+ delete document[MCP_GENERATED_MARKER];
216
+ for (const [key, value] of Object.entries(slot)) {
217
+ if (key === "mcpServers" || key === MCP_GENERATED_MARKER) continue;
218
+ document[key] = value;
219
+ }
220
+ document.mcpServers = Object.fromEntries(Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)));
221
+ writeMcpOverlayIfChangedSync(sourcePath, `${JSON.stringify(document, null, 2)}\n`);
222
+ }
223
+
224
+ function readActiveProfile(statePath: string): string | undefined {
225
+ const result = readJsonFileSync(statePath);
226
+ if (!result.ok || !isRecord(result.value)) return undefined;
227
+ const name = result.value.activeProfile;
228
+ return typeof name === "string" && name.length > 0 ? name : undefined;
229
+ }
230
+
231
+ /** The profile's raw `mcp` references; `"unknown"` when the profile or its
232
+ * catalog cannot be read (caller falls back to "no filtering"). */
233
+ function readProfileMcpRefsSync(
234
+ input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
235
+ ): readonly string[] | undefined | "unknown" {
236
+ if (input.name === DEFAULT_PROFILE_NAME) return undefined;
237
+ const trusted = input.trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
238
+ const global = readCatalogSync(path.join(input.agentDir, "profiles.json"));
239
+ const project = trusted
240
+ ? readCatalogSync(path.join(input.cwd, ".pi", "profiles.json"))
241
+ : new Map<string, ProfileDefinition>();
242
+ if (global === "error" || project === "error") return "unknown";
243
+ const definition = project.get(input.name) ?? global.get(input.name);
244
+ return definition === undefined ? "unknown" : definition.mcp;
245
+ }
246
+
247
+ function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
248
+ const result = readJsonFileSync(filePath);
249
+ if (!result.ok) return result.reason === "missing" ? new Map<string, ProfileDefinition>() : "error";
250
+ try {
251
+ return parseCatalogDocument(result.value, filePath);
252
+ } catch {
253
+ return "error";
254
+ }
255
+ }
256
+
257
+ function readDefaultProjectTrustSync(agentDir: string): string | undefined {
258
+ const result = readJsonFileSync(path.join(agentDir, "settings.json"));
259
+ if (!result.ok || !isRecord(result.value)) return undefined;
260
+ const value = result.value.defaultProjectTrust;
261
+ return typeof value === "string" ? value : undefined;
262
+ }
@@ -105,10 +105,7 @@ export async function resolveStartupProfile(input: {
105
105
  const catalog = await ProfileCatalog.load(input.agentDir, {
106
106
  projectDir: input.projectTrusted ? input.cwd : undefined,
107
107
  });
108
- const warnings: string[] = [
109
- ...catalog.warnings,
110
- ...(await legacyRegistryWarnings(input)),
111
- ];
108
+ const warnings: string[] = await legacyRegistryWarnings(input);
112
109
 
113
110
  if (input.requested !== undefined) {
114
111
  if (catalog.resolve(input.requested) === undefined) {
@@ -53,7 +53,10 @@ export interface ActivationDeps {
53
53
 
54
54
  export interface ActivationResult {
55
55
  selection: ResolvedSelection;
56
- warnings: string[];
56
+ /** The overlay this activation applied; absent when the runtime runs the
57
+ * profile exactly as it is declared. Callers surface it (the footer badge)
58
+ * instead of re-reading the state file. */
59
+ overlay?: RuntimeOverlay;
57
60
  }
58
61
 
59
62
  /** Resolves a profile against the live resources without applying it.
@@ -65,7 +68,7 @@ export async function resolveProfileSelection(
65
68
  name: string,
66
69
  deps: Pick<ActivationDeps, "agentDir" | "cwd" | "projectTrusted" | "live" | "presetInputs">,
67
70
  overlay?: RuntimeOverlay,
68
- ): Promise<{ selection: ResolvedSelection; warnings: string[] }> {
71
+ ): Promise<ResolvedSelection> {
69
72
  const catalog = await ProfileCatalog.load(deps.agentDir, {
70
73
  projectDir: deps.projectTrusted ? deps.cwd : undefined,
71
74
  });
@@ -76,8 +79,7 @@ export async function resolveProfileSelection(
76
79
  );
77
80
  }
78
81
  const suppressTools = !deps.presetInputs.force && deps.presetInputs.explicit.tools;
79
- const selection = resolveSelection({ profile, overlay, live: deps.live, suppressTools });
80
- return { selection, warnings: [...catalog.warnings] };
82
+ return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
81
83
  }
82
84
 
83
85
  /** Activates a profile: resolve, validate, optionally persist, apply. */
@@ -87,7 +89,7 @@ export async function activateProfile(
87
89
  options?: { overlay?: RuntimeOverlay | null; persist?: boolean },
88
90
  ): Promise<ActivationResult> {
89
91
  const overlay = options?.overlay ?? undefined;
90
- const { selection, warnings } = await resolveProfileSelection(name, deps, overlay);
92
+ const selection = await resolveProfileSelection(name, deps, overlay);
91
93
  const preset = decidePreset({ model: selection.model, ...deps.presetInputs });
92
94
 
93
95
  // Validate before touching anything: the model preset is the only
@@ -109,5 +111,5 @@ export async function activateProfile(
109
111
  throw new ActivationError(result.error ?? `profile "${name}" could not be applied`);
110
112
  }
111
113
 
112
- return { selection, warnings };
114
+ return { selection, ...(overlay === undefined ? {} : { overlay }) };
113
115
  }