pi-profile-switch 0.2.0 → 0.3.1

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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * DefaultProfiles: the catalog a fresh install starts from.
3
+ *
4
+ * Pi packages have no install hook (docs/packages.md) — `pi install` only
5
+ * unpacks the package and runs `npm install` — so "installing the default
6
+ * profiles" can only happen the first time the extension actually loads.
7
+ * `seedDefaultProfilesSync` therefore runs at load time, before any session
8
+ * event, and is idempotent: an existing catalog file is never read, rewritten,
9
+ * or backed up, and the package never seeds again once the file exists.
10
+ *
11
+ * The seeded content is the shipped preset catalog, exactly what
12
+ * `examples/profiles.json` publishes, reached through the same
13
+ * `PROFILE_PRESETS` data `/profile create` offers — one definition, so the
14
+ * three cannot drift. The `read-only` profile declares built-in tools and
15
+ * instructions only: it activates on a machine with no skills, no
16
+ * `pi-mcp-adapter`, and no credentials, and it changes nothing until the
17
+ * user selects it.
18
+ *
19
+ * `profile-presets.test.ts` pins the content contract: this catalog equals
20
+ * the published `examples/profiles.json` and every definition is
21
+ * resource-free.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
25
+ import path from "node:path";
26
+
27
+ import { DEFAULT_PROFILE_NAME, PROFILE_SCHEMA_VERSION } from "./profile-catalog.ts";
28
+ import { PROFILE_PRESETS } from "./profile-presets.ts";
29
+
30
+ /** The catalog written to `<agentDir>/profiles.json` on first load. */
31
+ export const DEFAULT_PROFILE_CATALOG = {
32
+ schemaVersion: PROFILE_SCHEMA_VERSION,
33
+ profiles: Object.fromEntries(
34
+ PROFILE_PRESETS.filter((preset) => preset.name !== DEFAULT_PROFILE_NAME).map((preset) => [
35
+ preset.name,
36
+ preset.definition,
37
+ ]),
38
+ ),
39
+ };
40
+
41
+ export type DefaultSeedResult =
42
+ /** The agent dir had no catalog and now holds the default one. */
43
+ | "seeded"
44
+ /** A catalog file was already there; it was not touched. */
45
+ | "present";
46
+
47
+ /**
48
+ * Writes `<agentDir>/profiles.json` when it does not exist yet. Synchronous
49
+ * on purpose: the load-time call site cannot await, and the write is one small
50
+ * file. Existing files win unconditionally — an empty or hand-written catalog
51
+ * is a user decision, not a missing default.
52
+ */
53
+ export function seedDefaultProfilesSync(agentDir: string, catalog: unknown = DEFAULT_PROFILE_CATALOG): DefaultSeedResult {
54
+ const target = path.join(agentDir, "profiles.json");
55
+ if (existsSync(target)) return "present";
56
+ mkdirSync(agentDir, { recursive: true });
57
+ writeFileSync(target, `${JSON.stringify(catalog, null, 2)}\n`);
58
+ return "seeded";
59
+ }
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
  }
package/src/mcp-config.ts CHANGED
@@ -1,23 +1,32 @@
1
1
  /**
2
- * AdapterConfigDiscovery: reads the MCP server NAMES pi-mcp-adapter would
3
- * discover from its pi-native config files, without ever managing them.
2
+ * AdapterConfigDiscovery: reads what pi-mcp-adapter would load from its
3
+ * pi-native FILE sources server names for reference validation, and the
4
+ * "Pi global override" slot document for the generated overlay.
4
5
  *
5
- * pi-profile-switch never stores MCP connection parameters or credentials
6
- * (ADR-0002); this module reads only the `mcpServers` key names so an
7
- * activation can validate a profile's `mcp` references before applying it.
6
+ * Discovery mirrors the adapter's own source order (later sources override
7
+ * earlier ones): shared global MCP config, the two `.agents` globals, the Pi
8
+ * global override slot (`--mcp-config`, else `<agentDir>/mcp.json`), then the
9
+ * project's `.mcp.json` and `.pi/mcp.json` (project sources only when Pi
10
+ * reports the project trusted — an untrusted project's config is never read).
8
11
  *
9
- * Discovery scope (documented limitation): the pi-native files only — the
10
- * global `<agentDir>/mcp.json` and, when trusted, the project's
11
- * `.pi/mcp.json`. Servers defined solely in the adapter's editor-specific
12
- * legacy locations (~/.claude/mcp.json et al.) are invisible here; profiles
13
- * referencing them fail activation validation. The pi-native files are the
14
- * adapter's documented default, so keep configs there.
12
+ * Not covered (documented limitation): the adapter's opt-in host discovery
13
+ * (`~/.claude.json`, `~/.cursor/mcp.json`, …), package manifests (`pi.mcp`)
14
+ * and agent/Claude plugin sources. Their servers are namespaced and cannot be
15
+ * referenced by a profile today; the overlay still disables them when a name
16
+ * happens to match one it knows.
17
+ *
18
+ * pi-profile-switch never writes these files and never exposes connection
19
+ * parameters; the slot document is read only to be carried into the generated
20
+ * overlay, because that file replaces the slot.
15
21
  *
16
22
  * Malformed config files fail loudly — a broken mcp.json must not silently
17
23
  * read as "no servers" and reject every reference.
18
24
  */
19
25
 
20
- import { isRecord, readJsonFile } from "./json-file.ts";
26
+ import { homedir } from "node:os";
27
+ import path from "node:path";
28
+
29
+ import { isRecord, readJsonFileSync } from "./json-file.ts";
21
30
 
22
31
  export class McpConfigError extends Error {
23
32
  readonly filePath: string;
@@ -29,35 +38,145 @@ export class McpConfigError extends Error {
29
38
  }
30
39
  }
31
40
 
32
- async function readServerNames(filePath: string): Promise<string[]> {
33
- const result = await readJsonFile(filePath);
34
- if (!result.ok) {
35
- if (result.reason === "missing") {
36
- return [];
41
+ export interface AdapterMcpSource {
42
+ label: string;
43
+ filePath: string;
44
+ scope: "global" | "project";
45
+ /** True for the slot `--mcp-config` replaces. */
46
+ slot: boolean;
47
+ }
48
+
49
+ export interface AdapterMcpDiscoveryInput {
50
+ agentDir: string;
51
+ cwd: string;
52
+ projectTrusted: boolean;
53
+ /** The effective `--mcp-config` value, when one is in play. */
54
+ overridePath?: string;
55
+ /** Home directory override; tests point this at their fixture. */
56
+ homeDir?: string;
57
+ }
58
+
59
+ /** The adapter's file sources, in its own precedence order. Paths are
60
+ * de-duplicated: the adapter skips a source whose read path equals the slot. */
61
+ export function adapterMcpSources(input: AdapterMcpDiscoveryInput): AdapterMcpSource[] {
62
+ const home = input.homeDir ?? homedir();
63
+ const slotPath = path.resolve(input.overridePath ?? path.join(input.agentDir, "mcp.json"));
64
+ const candidates: AdapterMcpSource[] = [
65
+ { label: "shared global MCP config", filePath: path.join(home, ".config", "mcp", "mcp.json"), scope: "global", slot: false },
66
+ { label: ".agents MCP config", filePath: path.join(home, ".agents", "mcp.json"), scope: "global", slot: false },
67
+ { label: ".agents/mcp MCP config", filePath: path.join(home, ".agents", "mcp", "mcp.json"), scope: "global", slot: false },
68
+ { label: "Pi global MCP override", filePath: slotPath, scope: "global", slot: true },
69
+ ];
70
+ if (input.projectTrusted) {
71
+ candidates.push(
72
+ { label: "project MCP config", filePath: path.resolve(input.cwd, ".mcp.json"), scope: "project", slot: false },
73
+ { label: "project Pi MCP override", filePath: path.resolve(input.cwd, ".pi", "mcp.json"), scope: "project", slot: false },
74
+ );
75
+ }
76
+ const seen = new Set<string>();
77
+ return candidates.filter((source) => {
78
+ if (source.slot) return true; // the slot is always the read path
79
+ if (seen.has(source.filePath)) return false;
80
+ seen.add(source.filePath);
81
+ return true;
82
+ });
83
+ }
84
+
85
+ export interface AdapterMcpView {
86
+ /** Read path of the slot `--mcp-config` replaces. */
87
+ slotPath: string;
88
+ /** Parsed slot document (verbatim), when the file exists. */
89
+ slotDocument?: Record<string, unknown>;
90
+ slotNames: string[];
91
+ /** Server names from the other file sources. */
92
+ otherNames: string[];
93
+ /** Union of slot and other names, sorted. */
94
+ serverNames: string[];
95
+ }
96
+
97
+ /** Synchronous read: the extension-load pass must finish before the adapter's
98
+ * session initialization, and the files are tiny. */
99
+ export function readAdapterMcpViewSync(input: AdapterMcpDiscoveryInput): AdapterMcpView {
100
+ const sources = adapterMcpSources(input);
101
+ const slot = sources.find((source) => source.slot);
102
+ const slotPath = slot?.filePath ?? path.join(input.agentDir, "mcp.json");
103
+ let slotDocument: Record<string, unknown> | undefined;
104
+ const slotNames: string[] = [];
105
+ const otherNames = new Set<string>();
106
+ for (const source of sources) {
107
+ if (source.slot) {
108
+ const document = readMcpDocumentSync(source.filePath);
109
+ if (document === undefined) continue;
110
+ slotDocument = document;
111
+ slotNames.push(...serverNames(document, source.filePath));
112
+ continue;
37
113
  }
114
+ const document = readMcpDocumentSync(source.filePath);
115
+ if (document === undefined) continue;
116
+ for (const name of serverNames(document, source.filePath)) otherNames.add(name);
117
+ }
118
+ for (const name of slotNames) otherNames.delete(name);
119
+ return {
120
+ slotPath,
121
+ ...(slotDocument === undefined ? {} : { slotDocument }),
122
+ slotNames,
123
+ otherNames: [...otherNames].sort(),
124
+ serverNames: [...new Set([...slotNames, ...otherNames])].sort(),
125
+ };
126
+ }
127
+
128
+ /** Async twin for the runtime paths (selection, status, toggles). */
129
+ export async function readAdapterMcpView(input: AdapterMcpDiscoveryInput): Promise<AdapterMcpView> {
130
+ return readAdapterMcpViewSync(input);
131
+ }
132
+
133
+ /** Server names from every source EXCEPT the slot file. The slot is generated
134
+ * by pi-profile-switch, so it must not feed back into the next generation —
135
+ * otherwise a stub would look like a source server and vanish on the next
136
+ * write. */
137
+ export function readAdapterOtherServerNamesSync(input: AdapterMcpDiscoveryInput): string[] {
138
+ const names = new Set<string>();
139
+ for (const source of adapterMcpSources(input)) {
140
+ if (source.slot) continue;
141
+ const document = readMcpDocumentSync(source.filePath);
142
+ if (document === undefined) continue;
143
+ for (const name of serverNames(document, source.filePath)) names.add(name);
144
+ }
145
+ return [...names].sort();
146
+ }
147
+
148
+ /** Server names the adapter would discover. `projectDir` is passed only when
149
+ * the trust check passed. */
150
+ export async function discoverAdapterServerNames(
151
+ agentDir: string,
152
+ projectDir?: string,
153
+ homeDir?: string,
154
+ ): Promise<string[]> {
155
+ return readAdapterMcpViewSync({
156
+ agentDir,
157
+ cwd: projectDir ?? process.cwd(),
158
+ projectTrusted: projectDir !== undefined,
159
+ ...(homeDir === undefined ? {} : { homeDir }),
160
+ }).serverNames;
161
+ }
162
+
163
+ /** Missing file → undefined; malformed → McpConfigError. */
164
+ export function readMcpDocumentSync(filePath: string): Record<string, unknown> | undefined {
165
+ const result = readJsonFileSync(filePath);
166
+ if (!result.ok) {
167
+ if (result.reason === "missing") return undefined;
38
168
  throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
39
169
  }
40
170
  if (!isRecord(result.value)) {
41
171
  throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
42
172
  }
43
- if (result.value.mcpServers === undefined) {
44
- return [];
45
- }
46
- if (!isRecord(result.value.mcpServers)) {
47
- throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
48
- }
49
- return Object.keys(result.value.mcpServers);
173
+ return result.value;
50
174
  }
51
175
 
52
- /** Server names the adapter would discover: global agentDir config plus the
53
- * trusted project's config. Pass `projectDir` only when the trust check
54
- * passed — an untrusted project's config is never read. */
55
- export async function discoverAdapterServerNames(agentDir: string, projectDir?: string): Promise<string[]> {
56
- const names = new Set(await readServerNames(`${agentDir}/mcp.json`));
57
- if (projectDir !== undefined) {
58
- for (const name of await readServerNames(`${projectDir}/.pi/mcp.json`)) {
59
- names.add(name);
60
- }
176
+ function serverNames(document: Record<string, unknown>, filePath: string): string[] {
177
+ if (document.mcpServers === undefined) return [];
178
+ if (!isRecord(document.mcpServers)) {
179
+ throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
61
180
  }
62
- return [...names].sort();
181
+ return Object.keys(document.mcpServers);
63
182
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * McpOverlayFile: the only writer of the generated MCP overlay.
3
+ *
4
+ * Invariants:
5
+ * - Atomic (temporary file + rename) so a reader never sees a half-written
6
+ * document.
7
+ * - Written only when the bytes change: a profile switch that does not move
8
+ * the MCP selection produces no write and therefore no reload.
9
+ * - Mode 0600: the overlay carries the Pi-global slot's definitions verbatim
10
+ * when the user keeps servers there, and those definitions may embed
11
+ * credentials even though the overlay itself never introduces any.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ /** Current bytes, or undefined when the file is missing/unreadable. */
18
+ export function readMcpOverlaySync(overlayPath: string): string | undefined {
19
+ try {
20
+ return readFileSync(overlayPath, "utf8");
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ /** Writes `content` when it differs from what is on disk. Returns true when
27
+ * the file changed. */
28
+ export function writeMcpOverlayIfChangedSync(overlayPath: string, content: string): boolean {
29
+ if (readMcpOverlaySync(overlayPath) === content) return false;
30
+ mkdirSync(path.dirname(overlayPath), { recursive: true });
31
+ const temporary = `${overlayPath}.tmp-${process.pid}`;
32
+ writeFileSync(temporary, content, { mode: 0o600 });
33
+ renameSync(temporary, overlayPath);
34
+ return true;
35
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * McpOverlay: the PURE half of the profile-scoped MCP filter.
3
+ *
4
+ * `pi-mcp-adapter` has no runtime allowlist channel (ADR-0002 amendment), so
5
+ * the profile's `mcp` declaration is enforced by a generated config file the
6
+ * adapter reads as its "Pi global override" slot (the slot `--mcp-config`
7
+ * replaces). The file is a DISABLE OVERLAY: entries carry no connection
8
+ * parameters and no credentials — `{ "<server>": { "disabled": true } }` is
9
+ * the adapter's own idiom for `/mcp disable`, and its config merge is
10
+ * per-field, so a stub merges onto the definition owned by the user's own
11
+ * file.
12
+ *
13
+ * One exception is structural: because the overlay file REPLACES the Pi
14
+ * global slot, the servers the user keeps in that slot are carried over
15
+ * verbatim from the sidecar (`mcp.user.json`); a hand-written slot file is
16
+ * adopted into the sidecar before the first overwrite.
17
+ *
18
+ * `allowed` semantics:
19
+ * - `"all"` — the profile declares no `mcp`: nothing is disabled.
20
+ * - `[]` — every discovered server is disabled.
21
+ * - `["github", …]` — everything outside the list is disabled.
22
+ */
23
+
24
+ import path from "node:path";
25
+
26
+ import { isRecord } from "./json-file.ts";
27
+ import { matchesReference } from "./name-matching.ts";
28
+
29
+ /**
30
+ * The generated overlay REPLACES the adapter's Pi-global slot, so it lives at
31
+ * the slot's default path. The user's own Pi-global servers move to a sidecar
32
+ * that this package never writes except when adopting a hand-written slot.
33
+ */
34
+ export const MCP_SLOT_FILE_NAME = "mcp.json";
35
+ export const MCP_SOURCE_FILE_NAME = "mcp.user.json";
36
+ /** Top-level key marking a file as generated. The adapter ignores unknown
37
+ * top-level keys, so the marker never reaches it as configuration. */
38
+ export const MCP_GENERATED_MARKER = "piProfileSwitch";
39
+
40
+ /** The adapter's Pi-global slot: the generated overlay. */
41
+ export function mcpSlotPath(agentDir: string): string {
42
+ return path.join(agentDir, MCP_SLOT_FILE_NAME);
43
+ }
44
+
45
+ /** The user-owned sidecar holding the Pi-global servers verbatim. */
46
+ export function mcpSourcePath(agentDir: string): string {
47
+ return path.join(agentDir, MCP_SOURCE_FILE_NAME);
48
+ }
49
+
50
+ /** True when the document was produced by pi-profile-switch. */
51
+ export function isGeneratedOverlay(value: unknown): boolean {
52
+ if (!isRecord(value)) return false;
53
+ const marker = value[MCP_GENERATED_MARKER];
54
+ return isRecord(marker) && marker.generated === true;
55
+ }
56
+
57
+ /** True for the credential-free stubs the overlay adds for servers defined in
58
+ * the adapter's other sources. */
59
+ export function isDisabledStub(value: unknown): boolean {
60
+ return isRecord(value) && Object.keys(value).length === 1 && value.disabled === true;
61
+ }
62
+
63
+ /** Server names a profile's `mcp` references resolve to. `undefined` (the
64
+ * profile declares nothing) means "no filtering" and is reported as the
65
+ * literal `"all"`. Glob references follow the same rules as every other
66
+ * profile reference. */
67
+ export function resolveAllowedServers(
68
+ refs: readonly string[] | undefined,
69
+ discovered: readonly string[],
70
+ ): readonly string[] | "all" {
71
+ if (refs === undefined) return "all";
72
+ const allowed = new Set<string>();
73
+ for (const ref of refs) {
74
+ for (const name of discovered) {
75
+ if (matchesReference(ref, name)) allowed.add(name);
76
+ }
77
+ }
78
+ return [...allowed];
79
+ }
80
+
81
+ export interface McpOverlayInput {
82
+ /** Parsed Pi-global slot document, when the user has one. */
83
+ slotDocument?: Record<string, unknown>;
84
+ /** Server names defined in the adapter's other file sources. */
85
+ otherServerNames: readonly string[];
86
+ allowed: readonly string[] | "all";
87
+ }
88
+
89
+ /** Builds the overlay document. Server order is sorted so the serialized
90
+ * bytes are stable and a rewrite only happens on a real change. */
91
+ export function buildMcpOverlay(input: McpOverlayInput): Record<string, unknown> {
92
+ const allowed = input.allowed === "all" ? undefined : new Set(input.allowed);
93
+ const slotServers = isRecord(input.slotDocument?.mcpServers) ? input.slotDocument.mcpServers : {};
94
+ const servers: Record<string, unknown> = {};
95
+ for (const [name, definition] of Object.entries(slotServers)) {
96
+ if (allowed === undefined || allowed.has(name) || !isRecord(definition)) {
97
+ servers[name] = definition;
98
+ continue;
99
+ }
100
+ servers[name] = { ...definition, disabled: true };
101
+ }
102
+ for (const name of input.otherServerNames) {
103
+ if (name in servers) continue; // the slot's definition owns the name
104
+ if (allowed === undefined || allowed.has(name)) continue;
105
+ servers[name] = { disabled: true };
106
+ }
107
+ const document: Record<string, unknown> = {};
108
+ for (const [key, value] of Object.entries(input.slotDocument ?? {})) {
109
+ if (key !== "mcpServers" && key !== MCP_GENERATED_MARKER) document[key] = value;
110
+ }
111
+ document[MCP_GENERATED_MARKER] = { generated: true, version: 1 };
112
+ document.mcpServers = Object.fromEntries(
113
+ Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)),
114
+ );
115
+ return document;
116
+ }
117
+
118
+ /** Stable serialization: two runs with the same semantics produce the same
119
+ * bytes, so the writer can skip no-op writes. */
120
+ export function serializeMcpOverlay(document: Record<string, unknown>): string {
121
+ return `${JSON.stringify(document, null, 2)}\n`;
122
+ }
@@ -37,7 +37,7 @@ export class ProfileCatalogStore {
37
37
 
38
38
  /** Validated definitions: missing file → empty; malformed → CatalogError
39
39
  * (catalog errors never pass silently, even on the write path).
40
- * Unknown fields (a legacy `extensions` declaration) are dropped. */
40
+ * Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
41
41
  async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
42
42
  const result = await readJsonFile(this.#filePath);
43
43
  if (!result.ok) {
@@ -7,11 +7,17 @@
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 1 is current. Version 2 describes the same fields — the
11
- * number only marked the era in which `extensions` was still a profile
12
- * resource and reads the same; every save writes version 1.
13
- * - A legacy `extensions` field is ignored silently, like any other unknown
14
- * field: extensions load natively and are managed with `pi install`.
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.
15
+ * - The catalog key for MCP servers is `mcps` (plural, like `skills` and
16
+ * `tools`). The legacy name `mcp` is still READ as an alias so an
17
+ * existing catalog keeps its allowlist; `mcps` wins when both keys are
18
+ * present, and a value of the wrong type fails loudly either way.
19
+ * Definitions always carry the canonical `mcps` field in memory, and
20
+ * profile-catalog-store.ts drops a file's legacy key on its next write.
15
21
  *
16
22
  * Invariants:
17
23
  * - The built-in `default` profile never exists in either file and cannot be
@@ -30,8 +36,6 @@ import path from "node:path";
30
36
  import { isRecord, readJsonFile } from "./json-file.ts";
31
37
 
32
38
  export const PROFILE_SCHEMA_VERSION = 1;
33
- /** The number v0.1.0 wrote for the same field shape: read, never written. */
34
- const LEGACY_SCHEMA_VERSION = 2;
35
39
  export const DEFAULT_PROFILE_NAME = "default";
36
40
 
37
41
  export interface ProfileModel {
@@ -46,7 +50,7 @@ export interface ProfileDefinition {
46
50
  label?: string;
47
51
  description?: string;
48
52
  skills?: string[];
49
- mcp?: string[];
53
+ mcps?: string[];
50
54
  tools?: string[];
51
55
  model?: ProfileModel;
52
56
  instructions?: string;
@@ -81,6 +85,27 @@ function readStringArray(value: unknown, field: string, profileName: string): st
81
85
  return value as string[];
82
86
  }
83
87
 
88
+ /** Legacy catalog key names, still accepted on read (canonical name wins).
89
+ * The write paths (/profile CRUD, /mcp enable|disable) delete the alias, so
90
+ * a file migrates to the canonical spelling the next time it is saved. */
91
+ export const LEGACY_FIELD_ALIASES = { mcps: "mcp" } as const;
92
+
93
+ /** Reads an array-of-strings field under its canonical name or a legacy
94
+ * alias. Both spellings are validated when present, so a value of the
95
+ * wrong type never passes silently through either key. */
96
+ function readAliasedStringArray(
97
+ raw: Record<string, unknown>,
98
+ canonical: string,
99
+ legacy: string,
100
+ profileName: string,
101
+ ): string[] | undefined {
102
+ for (const key of [canonical, legacy]) {
103
+ if (raw[key] !== undefined) readStringArray(raw[key], key, profileName);
104
+ }
105
+ const source = raw[canonical] !== undefined ? canonical : legacy;
106
+ return readStringArray(raw[source], source, profileName);
107
+ }
108
+
84
109
  function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
85
110
  if (value === undefined) return undefined;
86
111
  if (typeof value !== "string") {
@@ -91,8 +116,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
91
116
 
92
117
  /** Parses one raw profile definition; exported for the write-side store
93
118
  * (profile-catalog-store.ts) so anything written is loadable. Unknown
94
- * fields are ignored by design — a legacy `extensions` declaration is
95
- * dropped silently, exactly like any other unknown key. */
119
+ * fields are ignored by design — an `extensions` key left over from
120
+ * v0.1.0 is dropped silently, exactly like any other unknown key. */
96
121
  export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
97
122
  if (!isRecord(raw)) {
98
123
  throw new CatalogError(`profile "${name}" must be an object`);
@@ -102,10 +127,12 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
102
127
  if (label !== undefined) definition.label = label;
103
128
  const description = readOptionalString(raw.description, "description", name);
104
129
  if (description !== undefined) definition.description = description;
105
- for (const field of ["skills", "mcp", "tools"] as const) {
130
+ for (const field of ["skills", "tools"] as const) {
106
131
  const entries = readStringArray(raw[field], field, name);
107
132
  if (entries !== undefined) definition[field] = entries;
108
133
  }
134
+ const mcps = readAliasedStringArray(raw, "mcps", LEGACY_FIELD_ALIASES.mcps, name);
135
+ if (mcps !== undefined) definition.mcps = mcps;
109
136
  if (raw.model !== undefined) {
110
137
  if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
111
138
  throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
@@ -125,7 +152,7 @@ export function parseCatalogDocument(value: unknown, filePath: string): Map<stri
125
152
  throw new CatalogError(`${filePath}: catalog must be an object`);
126
153
  }
127
154
  const version = value.schemaVersion;
128
- if (version !== PROFILE_SCHEMA_VERSION && version !== LEGACY_SCHEMA_VERSION) {
155
+ if (version !== PROFILE_SCHEMA_VERSION) {
129
156
  throw new CatalogError(
130
157
  `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
131
158
  );
@@ -25,8 +25,9 @@
25
25
  * - `instructions` states behavior, never a capability name, and stays
26
26
  * short: Pi appends it to the system prompt on every turn.
27
27
  *
28
- * `test/profile-presets.test.ts` enforces every rule above and asserts
29
- * `examples/profiles.json` is exactly this catalog.
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`.
30
31
  */
31
32
 
32
33
  import type { ProfileDefinition } from "./profile-catalog.ts";
@@ -5,7 +5,7 @@
5
5
  * ADR-0007 semantics:
6
6
  * - `skills` resolves to a visibility filter (see skill-selection.ts), not
7
7
  * to loaded resources: every skill stays loaded and user-invocable.
8
- * - `mcp` resolves to a runtime server allowlist; a declared MCP intent that
8
+ * - `mcps` resolves to a runtime server allowlist; a declared MCP intent that
9
9
  * cannot be satisfied (adapter absent, literal server unknown) fails the
10
10
  * activation before anything is applied.
11
11
  * - `tools` resolves to an active tool set; literals the live registry does
@@ -136,7 +136,7 @@ function resolveMcp(
136
136
  if (!live.adapterPresent) {
137
137
  throw new SelectionError(
138
138
  `profile "${profileName}" declares MCP servers but pi-mcp-adapter is not active in this session — ` +
139
- `install the adapter or remove the "mcp" declaration`,
139
+ `install the adapter or remove the "mcps" declaration`,
140
140
  );
141
141
  }
142
142
  const selected: string[] = [];
@@ -201,7 +201,7 @@ export function resolveSelection(input: {
201
201
  const definition = profile.definition;
202
202
 
203
203
  const skills = resolveSkills(definition.skills, overlay?.disabledSkills ?? [], live.skills);
204
- const mcp = resolveMcp(definition.mcp, overlay?.disabledMcp ?? [], live.mcp, profile.name);
204
+ const mcp = resolveMcp(definition.mcps, overlay?.disabledMcp ?? [], live.mcp, profile.name);
205
205
  const tools = input.suppressTools === true
206
206
  ? { pendingTools: [], warning: { toolsUnmatched: [] } }
207
207
  : resolveTools(overlay?.tools ?? definition.tools, live.toolNames);
@@ -33,6 +33,11 @@ export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd
33
33
  export interface RuntimeState {
34
34
  activeProfile?: string;
35
35
  overlay?: RuntimeOverlay;
36
+ /** Patch-only marker: clears `activeProfile` from the store it is sent
37
+ * to, and to that store alone. A profile switch sends it to the OTHER
38
+ * scope's store, so a stale project selection cannot shadow the new
39
+ * global choice on the next startup. Never stored, never read back. */
40
+ otherActiveProfile?: undefined;
36
41
  }
37
42
 
38
43
  export interface RuntimeOverlay {
@@ -112,6 +117,7 @@ export class RuntimeStateStore {
112
117
  if (patch.overlay === undefined) delete next.overlay;
113
118
  else next.overlay = patch.overlay;
114
119
  }
120
+ if ("otherActiveProfile" in patch) delete next.activeProfile;
115
121
  await this.write(next);
116
122
  return next;
117
123
  }