pi-profile-switch 0.3.1 → 0.4.2

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.
Files changed (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pre-spawn validation for a profile's declared model (ticket 02).
3
+ *
4
+ * Mirrors Pi's own session preflight: the model must resolve against the
5
+ * user's real model catalog (built-ins + models.json custom providers), and
6
+ * the provider must have configured auth (stored credential, static key, or
7
+ * ambient source). A missing or unauthenticated declared model fails
8
+ * activation before Pi is spawned, so a session never runs on an unexpected
9
+ * model.
10
+ *
11
+ * Note: Pi deliberately accepts unlisted model IDs under a known provider
12
+ * (custom/self-hosted models), so an unknown *provider* is the hard failure.
13
+ */
14
+
15
+ import path from "node:path";
16
+
17
+ import { ModelRuntime, resolveCliModel } from "@earendil-works/pi-coding-agent";
18
+
19
+ import type { ProfileModel } from "../profile-catalog.ts";
20
+
21
+ /** Returns an error message, or undefined when the model exists and is
22
+ * authenticated. Reads the user's real agent dir state; never writes. */
23
+ export async function checkDeclaredModel(agentDir: string, model: ProfileModel): Promise<string | undefined> {
24
+ const runtime = await ModelRuntime.create({
25
+ authPath: path.join(agentDir, "auth.json"),
26
+ modelsPath: path.join(agentDir, "models.json"),
27
+ modelsStorePath: path.join(agentDir, "models-store.json"),
28
+ refreshOnCreate: false,
29
+ allowModelNetwork: false,
30
+ });
31
+ const resolved = resolveCliModel({ cliModel: `${model.provider}/${model.id}`, modelRuntime: runtime });
32
+ if (resolved.error !== undefined) {
33
+ return resolved.error;
34
+ }
35
+ // resolveCliModel fuzzy-matches partial IDs; a stored profile must resolve
36
+ // to exactly the declared model (Pi's custom-model-id fallback also yields
37
+ // the declared ID verbatim, so exactness is compatible with it).
38
+ if (
39
+ resolved.model === undefined ||
40
+ resolved.model.id !== model.id ||
41
+ resolved.model.provider.toLowerCase() !== model.provider.toLowerCase()
42
+ ) {
43
+ return `model not found: ${model.provider}/${model.id}`;
44
+ }
45
+ const authenticated =
46
+ runtime.hasConfiguredAuth(resolved.model.provider) ||
47
+ (await runtime.checkAuth(resolved.model.provider)) !== undefined;
48
+ if (!authenticated) {
49
+ return `no credentials configured for provider "${resolved.model.provider}"`;
50
+ }
51
+ return undefined;
52
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * RuntimeCleanup: sweeps stale per-launch runtime directories at startup.
3
+ *
4
+ * The generated agent dirs under `<agentDir>/pi-profile/runtime/launch-*`
5
+ * (ADR-0005) would otherwise accumulate forever. Startup sweep is the ONLY
6
+ * cleanup mechanism by design: any exit — graceful, signal, SIGKILL, power
7
+ * loss — kills the child pid, so the next launch's sweep converges. There is
8
+ * no exit-time deletion; it would only buy immediacy at the cost of deletion
9
+ * logic on the signal path.
10
+ *
11
+ * Liveness token: a `pid` file written by spawnPi into the runtime dir.
12
+ * (Naming the dir after the pid is impossible — the pid does not exist
13
+ * before spawn, and the running process's PI_CODING_AGENT_DIR path is
14
+ * frozen.) Rules per launch-* dir:
15
+ * - pid file parses and the process is alive (or EPERM) → keep;
16
+ * ESRCH → delete.
17
+ * - no/unparsable pid file → delete only when the dir mtime is older than
18
+ * NO_PID_GRACE_MS. The grace window guards the concurrent-launch race (a
19
+ * second launcher between mkdtemp and its pid write must not be reaped);
20
+ * it also covers pre-feature dirs and post-mkdtemp crashes.
21
+ * PID reuse needs no /proc check: a wrong keep only delays cleanup and
22
+ * self-heals once the reused pid dies.
23
+ *
24
+ * Everything is best-effort: sweep errors never block a launch.
25
+ */
26
+
27
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
28
+ import path from "node:path";
29
+
30
+ /** Grace period for launch dirs without a (parseable) pid file. */
31
+ export const NO_PID_GRACE_MS = 10 * 60 * 1000;
32
+
33
+ function isProcessAlive(pid: number): boolean {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ } catch (error) {
38
+ // EPERM means the process exists but is not signal-able by us: keep.
39
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
40
+ }
41
+ }
42
+
43
+ function runtimeRootOf(agentDir: string): string {
44
+ return path.join(agentDir, "pi-profile", "runtime");
45
+ }
46
+
47
+ async function sweepEntry(dir: string): Promise<void> {
48
+ let pid: number | undefined;
49
+ try {
50
+ const raw = await readFile(path.join(dir, "pid"), "utf8");
51
+ const parsed = Number.parseInt(raw.trim(), 10);
52
+ if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
53
+ } catch {
54
+ // No pid file (or unreadable): fall through to the mtime guard.
55
+ }
56
+
57
+ if (pid !== undefined) {
58
+ if (!isProcessAlive(pid)) await rm(dir, { recursive: true, force: true });
59
+ return;
60
+ }
61
+
62
+ const info = await stat(dir);
63
+ if (Date.now() - info.mtimeMs > NO_PID_GRACE_MS) {
64
+ await rm(dir, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ /** Deletes stale launch dirs under the agent dir's runtime root. Never throws. */
69
+ export async function sweepStaleRuntimeDirs(agentDir: string): Promise<void> {
70
+ let entries: string[];
71
+ try {
72
+ entries = await readdir(runtimeRootOf(agentDir));
73
+ } catch {
74
+ return; // No runtime root yet: nothing to sweep.
75
+ }
76
+ for (const entry of entries) {
77
+ if (!entry.startsWith("launch-")) continue;
78
+ const dir = path.join(runtimeRootOf(agentDir), entry);
79
+ try {
80
+ if ((await stat(dir)).isDirectory()) await sweepEntry(dir);
81
+ } catch {
82
+ // Best-effort: one bad entry must not stop the sweep or the launch.
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Spawns the real `pi` binary as a subprocess (ADR-0005).
3
+ *
4
+ * The spawned pi gets: the pi-profile extension via `-e`, any generated
5
+ * flags, and the user's arguments verbatim. stdio is inherited so interactive
6
+ * TUI, RPC, and print modes all behave natively; exit codes and signals
7
+ * propagate.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { writeFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import type { GeneratedRuntime } from "../settings-generator.ts";
16
+
17
+ export interface SpawnPiOptions {
18
+ /** Generated runtime dir + env + flags. */
19
+ generated: GeneratedRuntime;
20
+ /** User arguments, forwarded verbatim. */
21
+ piArgs: string[];
22
+ /** Trust override recorded by the launcher; re-applied natively for the default profile. */
23
+ trustOverride: boolean | undefined;
24
+ }
25
+
26
+ const EXTENSION_ENTRY = fileURLToPath(new URL("../../extensions/pi-profile/index.ts", import.meta.url));
27
+
28
+ /** Pure argv construction for the spawned pi: extension entry, generated
29
+ * flags, trust re-application, then user args verbatim. */
30
+ export function buildPiArgs(options: SpawnPiOptions): string[] {
31
+ const args = ["-e", EXTENSION_ENTRY, ...options.generated.flags];
32
+ // default profile keeps trust behavior native: re-apply the recorded flag.
33
+ if (options.trustOverride === true) args.push("--approve");
34
+ if (options.trustOverride === false) args.push("--no-approve");
35
+ args.push(...options.piArgs);
36
+ return args;
37
+ }
38
+
39
+ export async function spawnPi(options: SpawnPiOptions): Promise<number> {
40
+ const env = { ...process.env, ...options.generated.env };
41
+ delete env.PI_CODING_AGENT_SESSION_DIR;
42
+
43
+ const child = spawn("pi", buildPiArgs(options), {
44
+ stdio: "inherit",
45
+ env,
46
+ });
47
+
48
+ // Liveness token for the next launch's startup sweep (runtime-cleanup.ts).
49
+ // Best-effort: the dir was just written by generateRuntimeDir, so this can
50
+ // only fail under disk/permission trouble that would have surfaced earlier.
51
+ if (child.pid !== undefined) {
52
+ try {
53
+ await writeFile(path.join(options.generated.runtimeDir, "pid"), String(child.pid));
54
+ } catch (error) {
55
+ console.error(`pi-profile: warning: could not write pid file: ${(error as Error).message}`);
56
+ }
57
+ }
58
+
59
+ const onSigint = () => child.kill("SIGINT");
60
+ const onSigterm = () => child.kill("SIGTERM");
61
+ process.on("SIGINT", onSigint);
62
+ process.on("SIGTERM", onSigterm);
63
+
64
+ try {
65
+ return await new Promise<number>((resolve, reject) => {
66
+ child.on("error", (error: NodeJS.ErrnoException) => {
67
+ if (error.code === "ENOENT") {
68
+ reject(new Error(`pi binary not found on PATH`));
69
+ } else {
70
+ reject(error);
71
+ }
72
+ });
73
+ child.on("exit", (code, signal) => {
74
+ if (code !== null) resolve(code);
75
+ else resolve(signal === "SIGINT" ? 130 : 1);
76
+ });
77
+ });
78
+ } finally {
79
+ process.off("SIGINT", onSigint);
80
+ process.off("SIGTERM", onSigterm);
81
+ }
82
+ }
package/src/mcp-config.ts CHANGED
@@ -1,32 +1,26 @@
1
1
  /**
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.
2
+ * AdapterConfigDiscovery: reads the MCP server NAMES pi-mcp-adapter would
3
+ * discover from its pi-native config files, without ever managing them.
5
4
  *
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).
5
+ * pi-profile never stores MCP connection parameters or credentials
6
+ * (ADR-0002); this module reads only the `mcpServers` key names so the
7
+ * launcher can validate a profile's `mcp` references before spawn.
11
8
  *
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.
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 launch validation. The pi-native files are the
14
+ * adapter's documented default, and no in-session re-validation exists (the
15
+ * adapter's status snapshots arrive too late and only post-init), so the
16
+ * launch check is the only name validation keep configs in the pi-native
17
+ * files.
21
18
  *
22
19
  * Malformed config files fail loudly — a broken mcp.json must not silently
23
20
  * read as "no servers" and reject every reference.
24
21
  */
25
22
 
26
- import { homedir } from "node:os";
27
- import path from "node:path";
28
-
29
- import { isRecord, readJsonFileSync } from "./json-file.ts";
23
+ import { isRecord, readJsonFile } from "./json-file.ts";
30
24
 
31
25
  export class McpConfigError extends Error {
32
26
  readonly filePath: string;
@@ -38,145 +32,35 @@ export class McpConfigError extends Error {
38
32
  }
39
33
  }
40
34
 
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;
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);
35
+ async function readServerNames(filePath: string): Promise<string[]> {
36
+ const result = await readJsonFile(filePath);
166
37
  if (!result.ok) {
167
- if (result.reason === "missing") return undefined;
38
+ if (result.reason === "missing") {
39
+ return [];
40
+ }
168
41
  throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
169
42
  }
170
43
  if (!isRecord(result.value)) {
171
44
  throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
172
45
  }
173
- return result.value;
46
+ if (result.value.mcpServers === undefined) {
47
+ return [];
48
+ }
49
+ if (!isRecord(result.value.mcpServers)) {
50
+ throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
51
+ }
52
+ return Object.keys(result.value.mcpServers);
174
53
  }
175
54
 
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);
55
+ /** Server names the adapter would discover: global agentDir config plus the
56
+ * trusted project's config. Pass `projectDir` only when the trust check
57
+ * passed — an untrusted project's config is never read. */
58
+ export async function discoverAdapterServerNames(agentDir: string, projectDir?: string): Promise<string[]> {
59
+ const names = new Set(await readServerNames(`${agentDir}/mcp.json`));
60
+ if (projectDir !== undefined) {
61
+ for (const name of await readServerNames(`${projectDir}/.pi/mcp.json`)) {
62
+ names.add(name);
63
+ }
180
64
  }
181
- return Object.keys(document.mcpServers);
65
+ return [...names].sort();
182
66
  }
@@ -1,25 +1,26 @@
1
1
  /**
2
- * McpCoordination: the pi-profile-switch ↔ pi-mcp-adapter contract (ADR-0002).
2
+ * McpCoordination: the pi-profile ↔ pi-mcp-adapter contract (ADR-0002).
3
3
  *
4
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
5
+ * allowlist, so pi-profile defines the coordination channel the locked
6
6
  * adapter implements:
7
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`.
8
+ * - pi-profile publishes the active profile's runtime server allowlist on
9
+ * `pi-profile:mcp-allowlist:v1` at session start (and after every profile
10
+ * switch reload, ticket 05). The allowlist is memory-only: pi-profile
11
+ * never writes the adapter's `.pi/mcp.json` overlay.
12
12
  * - Adapter presence is probed via the adapter's documented
13
13
  * request/result event pattern: emit a snapshot request for a bogus
14
14
  * server name; an installed adapter fills `request.result` synchronously
15
15
  * (with `{ok: false}` — the name is bogus), an absent adapter leaves it
16
16
  * undefined.
17
17
  *
18
- * Activation probes the adapter and refuses a profile whose declared MCP
19
- * intent cannot be satisfied before applying anything.
18
+ * Launch-time, the launcher additionally refuses to spawn when a profile
19
+ * declares `mcp` but no active extension identifies as the adapter
20
+ * (`isAdapterExtension`) — failing before spawn beats failing in-session.
20
21
  */
21
22
 
22
- /** pi-profile-switch's allowlist channel (the locked adapter subscribes). */
23
+ /** pi-profile's allowlist channel (the locked adapter subscribes). */
23
24
  export const MCP_ALLOWLIST_EVENT = "pi-profile:mcp-allowlist:v1";
24
25
  export const MCP_ALLOWLIST_VERSION = 1 as const;
25
26
 
@@ -30,10 +31,28 @@ export interface McpAllowlistMessage {
30
31
  }
31
32
 
32
33
  /** 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
+ * (see the module docblock). Kept as a literal so pi-profile doesn't
34
35
  * import adapter internals. */
35
36
  export const MCP_ADAPTER_SNAPSHOT_EVENT = "pi-mcp-adapter:runtime-snapshot:v1";
36
37
 
38
+ export class MissingMcpAdapterError extends Error {
39
+ constructor(profile: string) {
40
+ super(
41
+ `profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
42
+ `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
43
+ );
44
+ this.name = "MissingMcpAdapterError";
45
+ }
46
+ }
47
+
48
+ /** Identifies the adapter among active extension entries by its install
49
+ * path containing "pi-mcp-adapter" (npm package roots and local dirs both
50
+ * match). Heuristic by design — activation-plan entries carry no package
51
+ * source string, and the in-session probe is the authoritative check. */
52
+ export function isAdapterExtension(entry: { entry: string }): boolean {
53
+ return entry.entry.includes("pi-mcp-adapter");
54
+ }
55
+
37
56
  /** True when the adapter answered the probe (filled `result` on the
38
57
  * request object), regardless of the answer — presence, not health. */
39
58
  export function probeAdapterPresence(events: { emit(channel: string, data: unknown): void }): boolean {
@@ -1,12 +1,11 @@
1
1
  /**
2
- * ProfileCatalogStore: the WRITE side of a profile catalog file, kept
3
- * separate from the read-only ProfileCatalog.
2
+ * ProfileCatalogStore: the WRITE side of a profile catalog file
3
+ * (ticket 09), kept separate from the read-only ProfileCatalog.
4
4
  *
5
5
  * Invariants:
6
- * - Whole-file overwrites (pretty-printed, `schemaVersion 1` envelope, the
7
- * constant owned by profile-catalog.ts). Wizard saves never block on
8
- * concurrent edits — re-read at write time, same-name conflicts resolve
9
- * last-write-wins.
6
+ * - Whole-file overwrites (pretty-printed, schemaVersion envelope);
7
+ * wizard saves never block on concurrent edits re-read at write time,
8
+ * same-name conflicts resolve last-write-wins.
10
9
  * - Definitions are complete and self-contained: no inheritance fields
11
10
  * (`extends`, merge, array append) exist or are accepted.
12
11
  * - Definitions are re-parsed through the catalog's own
@@ -22,7 +21,6 @@ import { isRecord, readJsonFile } from "./json-file.ts";
22
21
  import {
23
22
  CatalogError,
24
23
  DEFAULT_PROFILE_NAME,
25
- parseCatalogDocument,
26
24
  parseProfileDefinition,
27
25
  PROFILE_SCHEMA_VERSION,
28
26
  type ProfileDefinition,
@@ -30,16 +28,20 @@ import {
30
28
 
31
29
  export class ProfileCatalogStore {
32
30
  readonly #filePath: string;
31
+ readonly #fallbackPath?: string;
33
32
 
34
- constructor(catalogPath: string) {
33
+ constructor(catalogPath: string, fallbackPath?: string) {
35
34
  this.#filePath = catalogPath;
35
+ this.#fallbackPath = fallbackPath;
36
36
  }
37
37
 
38
38
  /** Validated definitions: missing file → empty; malformed → CatalogError
39
- * (catalog errors never pass silently, even on the write path).
40
- * Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
39
+ * (catalog errors never pass silently, even on the write path). */
41
40
  async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
42
- const result = await readJsonFile(this.#filePath);
41
+ let result = await readJsonFile(this.#filePath);
42
+ if (!result.ok && result.reason === "missing" && this.#fallbackPath) {
43
+ result = await readJsonFile(this.#fallbackPath);
44
+ }
43
45
  if (!result.ok) {
44
46
  if (result.reason === "missing") return new Map();
45
47
  throw new CatalogError(`invalid JSON in ${this.#filePath}`);
@@ -47,7 +49,24 @@ export class ProfileCatalogStore {
47
49
  if (!isRecord(result.value)) {
48
50
  throw new CatalogError(`${this.#filePath}: catalog must be an object`);
49
51
  }
50
- return parseCatalogDocument(result.value, this.#filePath);
52
+ if (result.value.schemaVersion !== PROFILE_SCHEMA_VERSION) {
53
+ throw new CatalogError(
54
+ `${this.#filePath}: unsupported schemaVersion ${JSON.stringify(result.value.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
55
+ );
56
+ }
57
+ if (!isRecord(result.value.profiles)) {
58
+ throw new CatalogError(`${this.#filePath}: "profiles" must be an object mapping names to definitions`);
59
+ }
60
+ const definitions = new Map<string, ProfileDefinition>();
61
+ for (const [name, raw] of Object.entries(result.value.profiles)) {
62
+ if (name === DEFAULT_PROFILE_NAME) {
63
+ throw new CatalogError(
64
+ `${this.#filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
65
+ );
66
+ }
67
+ definitions.set(name, parseProfileDefinition(name, raw));
68
+ }
69
+ return definitions;
51
70
  }
52
71
 
53
72
  /** Overwrites the file with the given definitions (last write wins). */