pi-profile-switch 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Profile badge: the persistent, one-line answer to "which profile is this
3
+ * session running?".
4
+ *
5
+ * Presentation only — no state, no I/O, no activation. The extension decides
6
+ * when a badge is written (after an activation that succeeded, via
7
+ * `ctx.ui.setStatus(PROFILE_STATUS_KEY, …)`), so the badge can never claim a
8
+ * selection that was not applied.
9
+ *
10
+ * One canonical rendering, shared with the `/profile status` heading
11
+ * (`profile: <name>`), plus `*` when a runtime overlay is in effect — the only
12
+ * runtime difference the catalog does not show.
13
+ */
14
+
15
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
16
+
17
+ import { DEFAULT_PROFILE_NAME } from "./profile-catalog.ts";
18
+
19
+ /**
20
+ * Status key in Pi's footer. Pi joins all extension statuses into one line,
21
+ * orders them by key, and truncates that line from the right — so an earlier
22
+ * key keeps its text visible on a narrow terminal. `active-profile` sorts
23
+ * before the keys it shares the line with (`mcp`, `pi-…`, `thinking`).
24
+ */
25
+ export const PROFILE_STATUS_KEY = "active-profile";
26
+
27
+ /** The label prefix, matching the `/profile status` heading. */
28
+ export const PROFILE_BADGE_LABEL = "profile";
29
+
30
+ /**
31
+ * Display columns reserved for the name before it is elided. The footer is a
32
+ * shared, fixed-width line: profile names are unbounded user input, so an
33
+ * unelided name would evict the statuses of other extensions.
34
+ */
35
+ export const PROFILE_BADGE_NAME_COLUMNS = 16;
36
+
37
+ const ELLIPSIS = "…";
38
+
39
+ export interface ProfileBadge {
40
+ /** The name `/profile use` accepts (never the display `label`). */
41
+ name: string;
42
+ /** A runtime overlay is in effect: this runtime differs from the catalog. */
43
+ overlay: boolean;
44
+ }
45
+
46
+ /** The minimum theme surface the badge needs; `ctx.ui.theme` satisfies it. */
47
+ export interface BadgeTheme {
48
+ fg(color: ThemeColor, text: string): string;
49
+ }
50
+
51
+ export interface BadgeOptions {
52
+ overlay: boolean;
53
+ /** Override the name budget (tests, future width awareness). */
54
+ nameColumns?: number;
55
+ }
56
+
57
+ /**
58
+ * Builds the badge for a resolved profile, or `undefined` when there must be
59
+ * no badge at all.
60
+ *
61
+ * `default` is Pi's native baseline — it declares nothing — so it must not
62
+ * change the footer either: a plain Pi session shows no badge, and the footer
63
+ * status line only exists while some extension status is set.
64
+ */
65
+ export function buildProfileBadge(name: string, options: BadgeOptions): ProfileBadge | undefined {
66
+ if (name === DEFAULT_PROFILE_NAME) return undefined;
67
+ return {
68
+ name: truncateToColumns(name, options.nameColumns ?? PROFILE_BADGE_NAME_COLUMNS),
69
+ overlay: options.overlay,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Renders the badge. Colors come from the caller's theme at render time: Pi
75
+ * stores footer statuses as finished strings, so the extension re-renders on
76
+ * profile changes and on each turn (there is no extension-visible theme-change
77
+ * event).
78
+ */
79
+ export function renderProfileBadge(badge: ProfileBadge, theme: BadgeTheme): string {
80
+ const label = theme.fg("dim", `${PROFILE_BADGE_LABEL}: `);
81
+ const name = theme.fg("dim", badge.name);
82
+ return badge.overlay ? `${label}${name}${theme.fg("warning", "*")}` : `${label}${name}`;
83
+ }
84
+
85
+ /* -------------------------------------------------------------------------- */
86
+ /* Display width */
87
+ /* -------------------------------------------------------------------------- */
88
+
89
+ /**
90
+ * Width of one code point in terminal columns: 2 for East Asian wide and
91
+ * fullwidth code points, 1 otherwise. Zero-width joiners and combining marks
92
+ * are counted as 1 — an approximation that only over-reserves space for
93
+ * exotic names.
94
+ */
95
+ export function codePointWidth(codePoint: number): number {
96
+ return isWide(codePoint) ? 2 : 1;
97
+ }
98
+
99
+ /** Display width of `text` in terminal columns. */
100
+ export function displayWidth(text: string): number {
101
+ let width = 0;
102
+ for (const character of text) width += codePointWidth(character.codePointAt(0)!);
103
+ return width;
104
+ }
105
+
106
+ /**
107
+ * Truncates `text` to at most `columns` terminal columns, appending `…` when
108
+ * something was dropped. The ellipsis is part of the budget, so the result
109
+ * never exceeds `columns`.
110
+ */
111
+ export function truncateToColumns(text: string, columns: number, ellipsis = ELLIPSIS): string {
112
+ if (columns <= 0) return "";
113
+ if (displayWidth(text) <= columns) return text;
114
+ const budget = columns - displayWidth(ellipsis);
115
+ let result = "";
116
+ let width = 0;
117
+ for (const character of text) {
118
+ const next = width + codePointWidth(character.codePointAt(0)!);
119
+ if (next > budget) break;
120
+ result += character;
121
+ width = next;
122
+ }
123
+ return budget < 0 ? "" : `${result}${ellipsis}`;
124
+ }
125
+
126
+ function isWide(codePoint: number): boolean {
127
+ return (
128
+ (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
129
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals, Kangxi, CJK symbols
130
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) || // kana, CJK compatibility, CJK punctuation
131
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK unified ideographs extension A
132
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK unified ideographs
133
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi syllables
134
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
135
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compatibility ideographs
136
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compatibility forms
137
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
138
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
139
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji, pictographs
140
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK unified ideographs extension B+
141
+ );
142
+ }
@@ -3,9 +3,8 @@
3
3
  * separate from the read-only ProfileCatalog.
4
4
  *
5
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
6
+ * - Whole-file overwrites (pretty-printed, `schemaVersion 1` envelope, the
7
+ * constant owned by profile-catalog.ts). Wizard saves never block on
9
8
  * concurrent edits — re-read at write time, same-name conflicts resolve
10
9
  * last-write-wins.
11
10
  * - Definitions are complete and self-contained: no inheritance fields
@@ -38,7 +37,7 @@ export class ProfileCatalogStore {
38
37
 
39
38
  /** Validated definitions: missing file → empty; malformed → CatalogError
40
39
  * (catalog errors never pass silently, even on the write path).
41
- * Version 1 files load with `extensions` dropped. */
40
+ * Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
42
41
  async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
43
42
  const result = await readJsonFile(this.#filePath);
44
43
  if (!result.ok) {
@@ -48,7 +47,7 @@ export class ProfileCatalogStore {
48
47
  if (!isRecord(result.value)) {
49
48
  throw new CatalogError(`${this.#filePath}: catalog must be an object`);
50
49
  }
51
- return parseCatalogDocument(result.value, this.#filePath).profiles;
50
+ return parseCatalogDocument(result.value, this.#filePath);
52
51
  }
53
52
 
54
53
  /** Overwrites the file with the given definitions (last write wins). */