pi-profile-switch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "pi-profile-switch profile catalog",
4
+ "description": "Named profile definitions (global ~/.pi/agent/profiles.json or project .pi/profiles.json). Definitions are complete and self-contained: there is no inheritance field, and references are names/globs, never copies. Extensions are not a profile resource (ADR-0007): every installed extension loads natively. The built-in \"default\" profile must not be defined here.",
5
+ "type": "object",
6
+ "required": [
7
+ "schemaVersion",
8
+ "profiles"
9
+ ],
10
+ "additionalProperties": false,
11
+ "properties": {
12
+ "schemaVersion": {
13
+ "enum": [1, 2]
14
+ },
15
+ "profiles": {
16
+ "type": "object",
17
+ "propertyNames": {
18
+ "not": {
19
+ "const": "default"
20
+ }
21
+ },
22
+ "additionalProperties": {
23
+ "$ref": "#/$defs/profile"
24
+ }
25
+ }
26
+ },
27
+ "$defs": {
28
+ "profile": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "properties": {
32
+ "label": {
33
+ "type": "string",
34
+ "description": "Display label in the selector and /profile list."
35
+ },
36
+ "description": {
37
+ "type": "string"
38
+ },
39
+ "skills": {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "string"
43
+ },
44
+ "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
+ "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
+ "mcp": {
54
+ "type": "array",
55
+ "items": {
56
+ "type": "string"
57
+ },
58
+ "description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
59
+ },
60
+ "tools": {
61
+ "type": "array",
62
+ "items": {
63
+ "type": "string"
64
+ },
65
+ "description": "Tool names or globs expanded against Pi's live registry (including extension and MCP tools). Names that are not registered yet are retried each turn."
66
+ },
67
+ "model": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": [
71
+ "provider",
72
+ "id"
73
+ ],
74
+ "properties": {
75
+ "provider": {
76
+ "type": "string"
77
+ },
78
+ "id": {
79
+ "type": "string"
80
+ },
81
+ "thinkingLevel": {
82
+ "type": "string"
83
+ }
84
+ },
85
+ "description": "Session-start model preset. An explicit --model/--thinking flag or a model recorded in the session history wins; /profile use applies the preset."
86
+ },
87
+ "instructions": {
88
+ "type": "string",
89
+ "description": "Appended to Pi's fully built system prompt on every turn."
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared JSON-file reading for pi-profile-switch's file-backed stores (catalog,
3
+ * resource registry, runtime state). Each store maps read failures to its
4
+ * own error policy (loud CatalogError/RegistryError vs. quiet state
5
+ * fallback); this helper only classifies the outcome.
6
+ */
7
+
8
+ import { readFile } from "node:fs/promises";
9
+
10
+ export type JsonFileResult =
11
+ | { ok: true; value: unknown }
12
+ | { ok: false; reason: "missing" | "invalid" };
13
+
14
+ /** Reads and parses a JSON file. Unexpected I/O errors (permissions etc.)
15
+ * propagate — only absence and malformed JSON are classified. */
16
+ export async function readJsonFile(filePath: string): Promise<JsonFileResult> {
17
+ let raw: string;
18
+ try {
19
+ raw = await readFile(filePath, "utf8");
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
22
+ return { ok: false, reason: "missing" };
23
+ }
24
+ throw error;
25
+ }
26
+ try {
27
+ return { ok: true, value: JSON.parse(raw) };
28
+ } catch {
29
+ return { ok: false, reason: "invalid" };
30
+ }
31
+ }
32
+
33
+ export function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * AdapterConfigDiscovery: reads the MCP server NAMES pi-mcp-adapter would
3
+ * discover from its pi-native config files, without ever managing them.
4
+ *
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.
8
+ *
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.
15
+ *
16
+ * Malformed config files fail loudly — a broken mcp.json must not silently
17
+ * read as "no servers" and reject every reference.
18
+ */
19
+
20
+ import { isRecord, readJsonFile } from "./json-file.ts";
21
+
22
+ export class McpConfigError extends Error {
23
+ readonly filePath: string;
24
+
25
+ constructor(message: string, filePath: string) {
26
+ super(message);
27
+ this.name = "McpConfigError";
28
+ this.filePath = filePath;
29
+ }
30
+ }
31
+
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 [];
37
+ }
38
+ throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
39
+ }
40
+ if (!isRecord(result.value)) {
41
+ throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
42
+ }
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);
50
+ }
51
+
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
+ }
61
+ }
62
+ return [...names].sort();
63
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * McpCoordination: the pi-profile-switch ↔ pi-mcp-adapter contract (ADR-0002).
3
+ *
4
+ * The adapter's released versions (≤2.33) offer no in-memory server
5
+ * allowlist, so pi-profile-switch defines the coordination channel the locked
6
+ * adapter implements:
7
+ *
8
+ * - pi-profile-switch publishes the active profile's runtime server allowlist on
9
+ * `pi-profile:mcp-allowlist:v1` at activation (session start, `/profile
10
+ * use`, `customize`/`reset`, `/mcp enable|disable`). The allowlist is
11
+ * memory-only: pi-profile-switch never writes the adapter's `.pi/mcp.json`.
12
+ * - Adapter presence is probed via the adapter's documented
13
+ * request/result event pattern: emit a snapshot request for a bogus
14
+ * server name; an installed adapter fills `request.result` synchronously
15
+ * (with `{ok: false}` — the name is bogus), an absent adapter leaves it
16
+ * undefined.
17
+ *
18
+ * Activation probes the adapter and refuses a profile whose declared MCP
19
+ * intent cannot be satisfied before applying anything.
20
+ */
21
+
22
+ /** pi-profile-switch's allowlist channel (the locked adapter subscribes). */
23
+ export const MCP_ALLOWLIST_EVENT = "pi-profile:mcp-allowlist:v1";
24
+ export const MCP_ALLOWLIST_VERSION = 1 as const;
25
+
26
+ export interface McpAllowlistMessage {
27
+ version: typeof MCP_ALLOWLIST_VERSION;
28
+ profile: string;
29
+ servers: string[];
30
+ }
31
+
32
+ /** The adapter's snapshot channel, used here purely as a presence probe
33
+ * (see the module docblock). Kept as a literal so pi-profile-switch doesn't
34
+ * import adapter internals. */
35
+ export const MCP_ADAPTER_SNAPSHOT_EVENT = "pi-mcp-adapter:runtime-snapshot:v1";
36
+
37
+ /** True when the adapter answered the probe (filled `result` on the
38
+ * request object), regardless of the answer — presence, not health. */
39
+ export function probeAdapterPresence(events: { emit(channel: string, data: unknown): void }): boolean {
40
+ const request: { version: 1; name: string; result?: unknown } = {
41
+ version: 1,
42
+ name: "pi-profile:presence-probe",
43
+ };
44
+ events.emit(MCP_ADAPTER_SNAPSHOT_EVENT, request);
45
+ return request.result !== undefined;
46
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ModelSelection: when a profile's model preset may take effect.
3
+ *
4
+ * The profile model is a PRESET, not an override. Pi's own explicit choices
5
+ * win:
6
+ *
7
+ * 1. `--model` / `--thinking` on the command line.
8
+ * 2. A model or thinking level recorded in the session history (a resumed
9
+ * session restores the user's last choice).
10
+ * 3. The profile's declaration.
11
+ * 4. Pi's settings defaults.
12
+ *
13
+ * `/profile use` is the user choosing a profile deliberately, so it applies
14
+ * the preset over 1 and 2.
15
+ */
16
+
17
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
18
+
19
+ import type { ProfileModel } from "./profile-catalog.ts";
20
+ import type { ExplicitDeclarations } from "./startup-selection.ts";
21
+
22
+ export interface SessionChoices {
23
+ hasRecordedModel: boolean;
24
+ hasRecordedThinking: boolean;
25
+ }
26
+
27
+ /** Reads whether the session history already states a model or thinking
28
+ * level. Process startup is always `reason: "startup"`, so the recorded
29
+ * entries — not the event reason — are what distinguishes a resumed
30
+ * session from a fresh one. */
31
+ export function readSessionChoices(entries: SessionEntry[]): SessionChoices {
32
+ let hasRecordedModel = false;
33
+ let hasRecordedThinking = false;
34
+ for (const entry of entries) {
35
+ if (entry.type === "model_change") hasRecordedModel = true;
36
+ if (entry.type === "thinking_level_change") hasRecordedThinking = true;
37
+ }
38
+ return { hasRecordedModel, hasRecordedThinking };
39
+ }
40
+
41
+ export interface PresetDecisions {
42
+ model: boolean;
43
+ thinking: boolean;
44
+ }
45
+
46
+ /** Decides which halves of the preset may be applied. */
47
+ export function decidePreset(input: {
48
+ model: ProfileModel | undefined;
49
+ explicit: ExplicitDeclarations;
50
+ session: SessionChoices;
51
+ /** True for an explicit `/profile use` (overrides 1 and 2). */
52
+ force: boolean;
53
+ }): PresetDecisions {
54
+ if (input.model === undefined) {
55
+ return { model: false, thinking: false };
56
+ }
57
+ if (input.force) {
58
+ return { model: true, thinking: true };
59
+ }
60
+ return {
61
+ model: !input.explicit.model && !input.session.hasRecordedModel,
62
+ thinking: !input.explicit.thinking && !input.session.hasRecordedThinking,
63
+ };
64
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * NameMatching: the single implementation of literal/glob reference matching
3
+ * and did-you-mean suggestions, shared by the resolver (selection) and the
4
+ * prompt filter (visible skills).
5
+ */
6
+
7
+ import { minimatch } from "minimatch";
8
+
9
+ export function isGlob(pattern: string): boolean {
10
+ return pattern.includes("*") || pattern.includes("?") || pattern.includes("!");
11
+ }
12
+
13
+ export function matchesReference(pattern: string, candidate: string): boolean {
14
+ return isGlob(pattern) ? minimatch(candidate, pattern) : candidate === pattern;
15
+ }
16
+
17
+ /** Levenshtein distance, used only for did-you-mean hints. */
18
+ function editDistance(a: string, b: string): number {
19
+ const rows = a.length + 1;
20
+ const cols = b.length + 1;
21
+ let previous = Array.from({ length: cols }, (_, index) => index);
22
+ for (let row = 1; row < rows; row++) {
23
+ const current = [row];
24
+ for (let col = 1; col < cols; col++) {
25
+ const cost = a[row - 1] === b[col - 1] ? 0 : 1;
26
+ current[col] = Math.min(current[col - 1] + 1, previous[col] + 1, previous[col - 1] + cost);
27
+ }
28
+ previous = current;
29
+ }
30
+ return previous[cols - 1];
31
+ }
32
+
33
+ /** Up to three near-name suggestions for a literal reference. */
34
+ export function suggestNames(reference: string, candidates: string[]): string[] {
35
+ const lowered = reference.toLowerCase();
36
+ const scored = candidates
37
+ .map((candidate) => {
38
+ const loweredCandidate = candidate.toLowerCase();
39
+ const prefix = loweredCandidate.startsWith(lowered) || lowered.startsWith(loweredCandidate);
40
+ const contains = loweredCandidate.includes(lowered) || lowered.includes(loweredCandidate);
41
+ return { candidate, distance: editDistance(lowered, loweredCandidate), prefix, contains };
42
+ })
43
+ .filter((entry) => entry.prefix || entry.contains || entry.distance <= Math.max(2, Math.ceil(reference.length / 3)))
44
+ .sort((a, b) => {
45
+ if (a.prefix !== b.prefix) return a.prefix ? -1 : 1;
46
+ if (a.contains !== b.contains) return a.contains ? -1 : 1;
47
+ return a.distance - b.distance;
48
+ });
49
+ return scored.slice(0, 3).map((entry) => entry.candidate);
50
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * ProfileCatalogStore: the WRITE side of a profile catalog file, kept
3
+ * separate from the read-only ProfileCatalog.
4
+ *
5
+ * Invariants:
6
+ * - Whole-file overwrites (pretty-printed, `schemaVersion 2` envelope).
7
+ * A version 1 file is read with its `extensions` fields dropped and is
8
+ * rewritten as version 2 on the next save; wizard saves never block on
9
+ * concurrent edits — re-read at write time, same-name conflicts resolve
10
+ * last-write-wins.
11
+ * - Definitions are complete and self-contained: no inheritance fields
12
+ * (`extends`, merge, array append) exist or are accepted.
13
+ * - Definitions are re-parsed through the catalog's own
14
+ * `parseProfileDefinition`, so anything written is loadable;
15
+ * "default" is built in and can never be written.
16
+ * - Writes land in exactly one scope file (global or project).
17
+ */
18
+
19
+ import { mkdir, writeFile } from "node:fs/promises";
20
+ import path from "node:path";
21
+
22
+ import { isRecord, readJsonFile } from "./json-file.ts";
23
+ import {
24
+ CatalogError,
25
+ DEFAULT_PROFILE_NAME,
26
+ parseCatalogDocument,
27
+ parseProfileDefinition,
28
+ PROFILE_SCHEMA_VERSION,
29
+ type ProfileDefinition,
30
+ } from "./profile-catalog.ts";
31
+
32
+ export class ProfileCatalogStore {
33
+ readonly #filePath: string;
34
+
35
+ constructor(catalogPath: string) {
36
+ this.#filePath = catalogPath;
37
+ }
38
+
39
+ /** Validated definitions: missing file → empty; malformed → CatalogError
40
+ * (catalog errors never pass silently, even on the write path).
41
+ * Version 1 files load with `extensions` dropped. */
42
+ async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
43
+ const result = await readJsonFile(this.#filePath);
44
+ if (!result.ok) {
45
+ if (result.reason === "missing") return new Map();
46
+ throw new CatalogError(`invalid JSON in ${this.#filePath}`);
47
+ }
48
+ if (!isRecord(result.value)) {
49
+ throw new CatalogError(`${this.#filePath}: catalog must be an object`);
50
+ }
51
+ return parseCatalogDocument(result.value, this.#filePath).profiles;
52
+ }
53
+
54
+ /** Overwrites the file with the given definitions (last write wins). */
55
+ async writeDefinitions(definitions: ReadonlyMap<string, ProfileDefinition>): Promise<void> {
56
+ const profiles: Record<string, unknown> = {};
57
+ for (const [name, definition] of [...definitions.entries()].sort(([a], [b]) => a.localeCompare(b))) {
58
+ // Round-trip through the parser: only declared, validated fields
59
+ // are written back (self-contained; no unknown keys survive).
60
+ profiles[name] = parseProfileDefinition(name, definition);
61
+ }
62
+ await mkdir(path.dirname(this.#filePath), { recursive: true });
63
+ await writeFile(
64
+ this.#filePath,
65
+ `${JSON.stringify({ schemaVersion: PROFILE_SCHEMA_VERSION, profiles }, null, 2)}\n`,
66
+ );
67
+ }
68
+
69
+ /** Inserts or replaces one complete definition. */
70
+ async upsert(name: string, definition: ProfileDefinition): Promise<void> {
71
+ if (name.trim().length === 0) {
72
+ throw new CatalogError(`profile name must be non-empty`);
73
+ }
74
+ if (name === DEFAULT_PROFILE_NAME) {
75
+ throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`);
76
+ }
77
+ const definitions = await this.readDefinitions();
78
+ // Validate before mutating: the wizard's definition must parse.
79
+ definitions.set(name, parseProfileDefinition(name, definition));
80
+ await this.writeDefinitions(definitions);
81
+ }
82
+
83
+ /** Removes one profile; unknown names are a loud error, not a no-op. */
84
+ async remove(name: string): Promise<void> {
85
+ const definitions = await this.readDefinitions();
86
+ if (!definitions.delete(name)) {
87
+ throw new CatalogError(`profile "${name}" not found in ${this.#filePath}`);
88
+ }
89
+ await this.writeDefinitions(definitions);
90
+ }
91
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * ProfileCatalog: reads profile definitions from the global catalog
3
+ * (`<agentDir>/profiles.json`) and, for trusted projects, the project
4
+ * catalog (`<projectDir>/.pi/profiles.json`).
5
+ *
6
+ * ADR-0007 semantics:
7
+ * - A profile references skills, MCP servers, and tools, and may declare
8
+ * instructions and a model preset. Extensions are not a profile resource:
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.
13
+ *
14
+ * Invariants:
15
+ * - The built-in `default` profile never exists in either file and cannot be
16
+ * redefined there.
17
+ * - A project profile with the same name fully replaces the global
18
+ * definition (no merge, no inheritance); removing the project entry
19
+ * immediately reveals the global one.
20
+ * - The caller passes `projectDir` only when Pi reports the project trusted —
21
+ * an untrusted project's catalog is never read.
22
+ * - A malformed catalog fails loudly (CatalogError) rather than silently
23
+ * starting with an unintended selection.
24
+ */
25
+
26
+ import path from "node:path";
27
+
28
+ import { isRecord, readJsonFile } from "./json-file.ts";
29
+
30
+ export const PROFILE_SCHEMA_VERSION = 2;
31
+ export const DEFAULT_PROFILE_NAME = "default";
32
+
33
+ export interface ProfileModel {
34
+ provider: string;
35
+ id: string;
36
+ thinkingLevel?: string;
37
+ }
38
+
39
+ /** A profile definition as stored in a catalog file. All fields optional:
40
+ * undeclared fields leave Pi's behavior untouched. */
41
+ export interface ProfileDefinition {
42
+ label?: string;
43
+ description?: string;
44
+ skills?: string[];
45
+ mcp?: string[];
46
+ tools?: string[];
47
+ model?: ProfileModel;
48
+ instructions?: string;
49
+ }
50
+
51
+ /** Where a profile's definition came from. */
52
+ export type ProfileSource = "builtin" | "global" | "project";
53
+
54
+ interface CatalogEntry {
55
+ source: "global" | "project";
56
+ definition: ProfileDefinition;
57
+ }
58
+
59
+ export interface ResolvedProfile {
60
+ name: string;
61
+ source: ProfileSource;
62
+ definition: ProfileDefinition;
63
+ }
64
+
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
+ export class CatalogError extends Error {
73
+ constructor(message: string) {
74
+ super(message);
75
+ this.name = "CatalogError";
76
+ }
77
+ }
78
+
79
+ function readStringArray(value: unknown, field: string, profileName: string): string[] | undefined {
80
+ if (value === undefined) return undefined;
81
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
82
+ throw new CatalogError(`profile "${profileName}": "${field}" must be an array of strings`);
83
+ }
84
+ return value as string[];
85
+ }
86
+
87
+ function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
88
+ if (value === undefined) return undefined;
89
+ if (typeof value !== "string") {
90
+ throw new CatalogError(`profile "${profileName}": "${field}" must be a string`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /** Parses one raw profile definition; exported for the write-side store
96
+ * (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. */
99
+ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
100
+ if (!isRecord(raw)) {
101
+ throw new CatalogError(`profile "${name}" must be an object`);
102
+ }
103
+ const definition: ProfileDefinition = {};
104
+ const label = readOptionalString(raw.label, "label", name);
105
+ if (label !== undefined) definition.label = label;
106
+ const description = readOptionalString(raw.description, "description", name);
107
+ if (description !== undefined) definition.description = description;
108
+ for (const field of ["skills", "mcp", "tools"] as const) {
109
+ const entries = readStringArray(raw[field], field, name);
110
+ if (entries !== undefined) definition[field] = entries;
111
+ }
112
+ if (raw.model !== undefined) {
113
+ if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
114
+ throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
115
+ }
116
+ const thinkingLevel = readOptionalString(raw.model.thinkingLevel, "model.thinkingLevel", name);
117
+ definition.model = { provider: raw.model.provider, id: raw.model.id, ...(thinkingLevel ? { thinkingLevel } : {}) };
118
+ }
119
+ const instructions = readOptionalString(raw.instructions, "instructions", name);
120
+ if (instructions !== undefined) definition.instructions = instructions;
121
+ return definition;
122
+ }
123
+
124
+ /** Parses one catalog document. Missing files are handled by the caller;
125
+ * this function sees only parsed JSON. */
126
+ export function parseCatalogDocument(value: unknown, filePath: string): CatalogDocument {
127
+ if (!isRecord(value)) {
128
+ throw new CatalogError(`${filePath}: catalog must be an object`);
129
+ }
130
+ const warnings: string[] = [];
131
+ const version = value.schemaVersion;
132
+ if (version !== 1 && version !== PROFILE_SCHEMA_VERSION) {
133
+ throw new CatalogError(
134
+ `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
135
+ );
136
+ }
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
+ if (!isRecord(value.profiles)) {
143
+ throw new CatalogError(`${filePath}: "profiles" must be an object mapping names to definitions`);
144
+ }
145
+ const profiles = new Map<string, ProfileDefinition>();
146
+ for (const [name, raw] of Object.entries(value.profiles)) {
147
+ if (name === DEFAULT_PROFILE_NAME) {
148
+ throw new CatalogError(
149
+ `${filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
150
+ );
151
+ }
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
+ profiles.set(name, parseProfileDefinition(name, raw));
158
+ }
159
+ return { profiles, warnings };
160
+ }
161
+
162
+ /** Reads one catalog file; missing → empty map, malformed → CatalogError. */
163
+ async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
164
+ const result = await readJsonFile(catalogPath);
165
+ if (!result.ok) {
166
+ if (result.reason === "missing") return { profiles: new Map(), warnings: [] };
167
+ throw new CatalogError(`invalid JSON in ${catalogPath}`);
168
+ }
169
+ return parseCatalogDocument(result.value, catalogPath);
170
+ }
171
+
172
+ export class ProfileCatalog {
173
+ readonly #profiles: ReadonlyMap<string, CatalogEntry>;
174
+ readonly #warnings: readonly string[];
175
+
176
+ private constructor(profiles: ReadonlyMap<string, CatalogEntry>, warnings: readonly string[]) {
177
+ 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
+ }
186
+
187
+ /**
188
+ * Reads the global catalog, plus the project catalog when `projectDir` is
189
+ * given (trusted projects only — the caller gates on Pi's trust check).
190
+ * Missing files mean an empty catalog; malformed content throws
191
+ * CatalogError. Project entries replace same-name global entries.
192
+ */
193
+ static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
194
+ const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
195
+ const profiles = new Map<string, CatalogEntry>();
196
+ const warnings = [...global.warnings];
197
+ for (const [name, definition] of global.profiles) {
198
+ profiles.set(name, { source: "global", definition });
199
+ }
200
+ if (options?.projectDir !== undefined) {
201
+ const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
202
+ warnings.push(...project.warnings);
203
+ for (const [name, definition] of project.profiles) {
204
+ profiles.set(name, { source: "project", definition });
205
+ }
206
+ }
207
+ return new ProfileCatalog(profiles, warnings);
208
+ }
209
+
210
+ /** Resolves a profile by name. `default` always resolves to the built-in
211
+ * no-op profile; unknown names return undefined. */
212
+ resolve(name: string): ResolvedProfile | undefined {
213
+ if (name === DEFAULT_PROFILE_NAME) {
214
+ return { name: DEFAULT_PROFILE_NAME, source: "builtin", definition: {} };
215
+ }
216
+ const entry = this.#profiles.get(name);
217
+ return entry === undefined ? undefined : { name, source: entry.source, definition: entry.definition };
218
+ }
219
+
220
+ /** Lists the built-in default first, then profiles in file order (global
221
+ * entries in global order, project-only names appended after). */
222
+ list(): ResolvedProfile[] {
223
+ return [
224
+ this.resolve(DEFAULT_PROFILE_NAME)!,
225
+ ...[...this.#profiles.keys()].map((name) => this.resolve(name)!),
226
+ ];
227
+ }
228
+
229
+ /** True when a global definition of the same name is shadowed by the
230
+ * project entry. */
231
+ shadowsGlobal(name: string): boolean {
232
+ return this.#profiles.get(name)?.source === "project";
233
+ }
234
+ }