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,271 @@
1
+ /**
2
+ * StartupMcpScope: derives the profile-scoped MCP overlay at times when a
3
+ * full activation has not (or may not yet) run.
4
+ *
5
+ * Two callers:
6
+ * - The extension-load pass: must be synchronous and must finish before
7
+ * pi-mcp-adapter reads its config at `session_start` (and, for eager
8
+ * servers, at its own load time). Pi applies CLI flag values only AFTER
9
+ * extension loading, so the `--profile` / `--mcp-config` values are read
10
+ * from argv here; trust is mirrored from Pi's own resolution order
11
+ * (`hasTrustRequiringProjectResources` → stored decision →
12
+ * `defaultProjectTrust`; an interactive first-time prompt is not yet
13
+ * answerable at load time and counts as untrusted).
14
+ * - The switch pass: the caller already has the resolved allowlist, so the
15
+ * overlay is derived from it directly.
16
+ *
17
+ * The generated file IS the adapter's Pi-global slot (`<agentDir>/mcp.json`):
18
+ * flag injection is impossible (Pi rejects two extensions registering the
19
+ * same flag), so the mechanism works with the slot the adapter already reads.
20
+ * The user's own Pi-global servers live in the sidecar `mcp.user.json`.
21
+ *
22
+ * Failure policy: a profile name that cannot be resolved (missing, malformed
23
+ * catalog, unknown name) falls back to "no filtering" — the generated overlay
24
+ * then changes nothing, and the real problem is reported loudly by the
25
+ * activation that runs right after. A foreign `--mcp-config` disables
26
+ * management entirely: the user's explicit file is never overwritten.
27
+ */
28
+
29
+ import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
30
+ import path from "node:path";
31
+
32
+ import { isRecord, readJsonFileSync } from "./json-file.ts";
33
+ import { readAdapterOtherServerNamesSync, readMcpDocumentSync } from "./mcp-config.ts";
34
+ import {
35
+ buildMcpOverlay,
36
+ isDisabledStub,
37
+ isGeneratedOverlay,
38
+ MCP_GENERATED_MARKER,
39
+ mcpSlotPath,
40
+ mcpSourcePath,
41
+ resolveAllowedServers,
42
+ serializeMcpOverlay,
43
+ } from "./mcp-overlay.ts";
44
+ import { writeMcpOverlayIfChangedSync } from "./mcp-overlay-file.ts";
45
+ import { DEFAULT_PROFILE_NAME, parseCatalogDocument, type ProfileDefinition } from "./profile-catalog.ts";
46
+
47
+ /** Reads `--<name> <value>` / `--<name>=<value>` without Pi's parser (Pi
48
+ * applies extension flag values only after extension loading). Last wins. */
49
+ export function readFlagFromArgv(argv: readonly string[], name: string): string | undefined {
50
+ const long = `--${name}`;
51
+ let value: string | undefined;
52
+ for (let index = 0; index < argv.length; index++) {
53
+ const token = argv[index] ?? "";
54
+ if (token === long) {
55
+ const next = argv[index + 1];
56
+ if (next !== undefined && !next.startsWith("--")) value = next;
57
+ continue;
58
+ }
59
+ if (token.startsWith(`${long}=`)) {
60
+ const inline = token.slice(long.length + 1);
61
+ if (inline.length > 0) value = inline;
62
+ }
63
+ }
64
+ return value;
65
+ }
66
+
67
+ /** True when Pi itself would consider `cwd` trusted right now. Mirrors
68
+ * `resolveProjectTrusted` minus extension votes and the interactive prompt. */
69
+ export function resolveProjectTrustedSync(agentDir: string, cwd: string): boolean {
70
+ if (!hasTrustRequiringProjectResources(cwd)) return true;
71
+ const stored = new ProjectTrustStore(agentDir).get(cwd);
72
+ if (stored !== null) return stored;
73
+ return readDefaultProjectTrustSync(agentDir) === "always";
74
+ }
75
+
76
+ /** The profile this pass generates the overlay for: this process's already
77
+ * applied selection (`continuation`) first — a reload must not regress to
78
+ * the `--profile` flag — then the flag, then the saved selection: project
79
+ * state wins over global state (project state only when trusted), then the
80
+ * built-in default. */
81
+ export function resolveStartupProfileNameSync(input: {
82
+ agentDir: string;
83
+ cwd: string;
84
+ projectTrusted: boolean;
85
+ argv: readonly string[];
86
+ continuation?: string;
87
+ }): string {
88
+ if (input.continuation !== undefined && input.continuation.length > 0) return input.continuation;
89
+ const requested = readFlagFromArgv(input.argv, "profile");
90
+ if (requested !== undefined && requested.length > 0) return requested;
91
+ const project = input.projectTrusted
92
+ ? readActiveProfile(path.join(input.cwd, ".pi", "pi-profile-state.json"))
93
+ : undefined;
94
+ return project ?? readActiveProfile(path.join(input.agentDir, "pi-profile-state.json")) ?? DEFAULT_PROFILE_NAME;
95
+ }
96
+
97
+ export interface McpOverlaySyncInput {
98
+ agentDir: string;
99
+ cwd: string;
100
+ /** Pi's trust decision. Omitted on the extension-load pass, where Pi has
101
+ * not resolved trust yet; the module mirrors Pi's own order then. */
102
+ projectTrusted?: boolean;
103
+ /** Effective `--mcp-config`; undefined means the managed overlay path. */
104
+ overridePath?: string;
105
+ /** The selection this process already applied: a reload's overlay belongs
106
+ * to it, not to the `--profile` flag. */
107
+ continuation?: string;
108
+ /** The command line, for the load pass (Pi applies flag values only after
109
+ * extension loading, so `--profile` is read from here). */
110
+ argv?: readonly string[];
111
+ homeDir?: string;
112
+ }
113
+
114
+ export interface McpOverlaySyncResult {
115
+ overlayPath: string;
116
+ /** False when a foreign `--mcp-config` owns the adapter's slot. */
117
+ managed: boolean;
118
+ changed: boolean;
119
+ /** Set when nothing was written; the caller surfaces it as a warning. */
120
+ error?: string;
121
+ }
122
+
123
+ /** Load-time (and `session_start` re-check) pass. `profileName` short-circuits
124
+ * the state lookup when the caller already activated a profile. */
125
+ export function syncStartupMcpOverlay(
126
+ input: McpOverlaySyncInput & { profileName?: string; mcpRefs?: readonly string[] | undefined },
127
+ ): McpOverlaySyncResult {
128
+ try {
129
+ const trust = input.projectTrusted;
130
+ const profileName =
131
+ input.profileName ??
132
+ resolveStartupProfileNameSync({
133
+ agentDir: input.agentDir,
134
+ cwd: input.cwd,
135
+ projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
136
+ argv: input.argv ?? [],
137
+ ...(input.continuation === undefined ? {} : { continuation: input.continuation }),
138
+ });
139
+ const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
140
+ return writeOverlayForRefs({ ...input, refs });
141
+ } catch (error) {
142
+ return {
143
+ overlayPath: mcpSlotPath(input.agentDir),
144
+ managed: true,
145
+ changed: false,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ };
148
+ }
149
+ }
150
+
151
+ /** Switch pass: the caller already resolved the allowlist. */
152
+ export function syncMcpOverlayForSelection(
153
+ input: McpOverlaySyncInput & { allowed: readonly string[] | "all" },
154
+ ): McpOverlaySyncResult {
155
+ try {
156
+ return writeOverlay({ ...input, refs: input.allowed });
157
+ } catch (error) {
158
+ return {
159
+ overlayPath: mcpSlotPath(input.agentDir),
160
+ managed: true,
161
+ changed: false,
162
+ error: error instanceof Error ? error.message : String(error),
163
+ };
164
+ }
165
+ }
166
+
167
+ function writeOverlayForRefs(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "unknown" }): McpOverlaySyncResult {
168
+ // An unresolvable profile is not an error here: the generated overlay
169
+ // then filters nothing and the activation reports the real problem.
170
+ const refs = input.refs === "unknown" ? undefined : input.refs;
171
+ return writeOverlay({ ...input, refs });
172
+ }
173
+
174
+ function writeOverlay(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "all" }): McpOverlaySyncResult {
175
+ const overlayPath = mcpSlotPath(input.agentDir);
176
+ if (input.overridePath !== undefined && path.resolve(input.overridePath) !== overlayPath) {
177
+ return { overlayPath, managed: false, changed: false };
178
+ }
179
+ const projectTrusted = input.projectTrusted ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
180
+ const discovery = {
181
+ agentDir: input.agentDir,
182
+ cwd: input.cwd,
183
+ projectTrusted,
184
+ ...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
185
+ };
186
+ // The slot is ours; a slot written by hand is adopted into the sidecar
187
+ // before the first overwrite, so a user's Pi-global servers survive.
188
+ const sourcePath = mcpSourcePath(input.agentDir);
189
+ adoptHandWrittenSlotSync(overlayPath, sourcePath);
190
+ const sourceDocument = readMcpDocumentSync(sourcePath);
191
+ const sourceNames = isRecord(sourceDocument?.mcpServers) ? Object.keys(sourceDocument.mcpServers) : [];
192
+ const otherNames = readAdapterOtherServerNamesSync(discovery);
193
+ const allowed =
194
+ input.refs === "all"
195
+ ? "all"
196
+ : resolveAllowedServers(input.refs, [...new Set([...sourceNames, ...otherNames])].sort());
197
+ const content = serializeMcpOverlay(
198
+ buildMcpOverlay({
199
+ ...(sourceDocument === undefined ? {} : { slotDocument: sourceDocument }),
200
+ otherServerNames: otherNames,
201
+ allowed,
202
+ }),
203
+ );
204
+ return { overlayPath, managed: true, changed: writeMcpOverlayIfChangedSync(overlayPath, content) };
205
+ }
206
+
207
+ /** A slot file without the generated marker holds the user's own Pi-global
208
+ * servers (from before this package managed the slot, or from a manual
209
+ * edit). Move them — plus the slot's non-server keys — into the sidecar
210
+ * before the slot is overwritten. Stub entries are skipped: they describe
211
+ * servers owned by the adapter's other sources. */
212
+ function adoptHandWrittenSlotSync(slotPath: string, sourcePath: string): void {
213
+ const slot = readMcpDocumentSync(slotPath);
214
+ if (slot === undefined || isGeneratedOverlay(slot)) return;
215
+ const sidecar = readMcpDocumentSync(sourcePath) ?? {};
216
+ const sidecarServers = isRecord(sidecar.mcpServers) ? sidecar.mcpServers : {};
217
+ const servers: Record<string, unknown> = { ...sidecarServers };
218
+ const slotServers = isRecord(slot.mcpServers) ? slot.mcpServers : {};
219
+ for (const [name, definition] of Object.entries(slotServers)) {
220
+ if (isDisabledStub(definition)) continue;
221
+ servers[name] = definition;
222
+ }
223
+ const document: Record<string, unknown> = { ...sidecar };
224
+ delete document[MCP_GENERATED_MARKER];
225
+ for (const [key, value] of Object.entries(slot)) {
226
+ if (key === "mcpServers" || key === MCP_GENERATED_MARKER) continue;
227
+ document[key] = value;
228
+ }
229
+ document.mcpServers = Object.fromEntries(Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)));
230
+ writeMcpOverlayIfChangedSync(sourcePath, `${JSON.stringify(document, null, 2)}\n`);
231
+ }
232
+
233
+ function readActiveProfile(statePath: string): string | undefined {
234
+ const result = readJsonFileSync(statePath);
235
+ if (!result.ok || !isRecord(result.value)) return undefined;
236
+ const name = result.value.activeProfile;
237
+ return typeof name === "string" && name.length > 0 ? name : undefined;
238
+ }
239
+
240
+ /** The profile's raw `mcps` references; `"unknown"` when the profile or its
241
+ * catalog cannot be read (caller falls back to "no filtering"). */
242
+ function readProfileMcpRefsSync(
243
+ input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
244
+ ): readonly string[] | undefined | "unknown" {
245
+ if (input.name === DEFAULT_PROFILE_NAME) return undefined;
246
+ const trusted = input.trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
247
+ const global = readCatalogSync(path.join(input.agentDir, "profiles.json"));
248
+ const project = trusted
249
+ ? readCatalogSync(path.join(input.cwd, ".pi", "profiles.json"))
250
+ : new Map<string, ProfileDefinition>();
251
+ if (global === "error" || project === "error") return "unknown";
252
+ const definition = project.get(input.name) ?? global.get(input.name);
253
+ return definition === undefined ? "unknown" : definition.mcps;
254
+ }
255
+
256
+ function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
257
+ const result = readJsonFileSync(filePath);
258
+ if (!result.ok) return result.reason === "missing" ? new Map<string, ProfileDefinition>() : "error";
259
+ try {
260
+ return parseCatalogDocument(result.value, filePath);
261
+ } catch {
262
+ return "error";
263
+ }
264
+ }
265
+
266
+ function readDefaultProjectTrustSync(agentDir: string): string | undefined {
267
+ const result = readJsonFileSync(path.join(agentDir, "settings.json"));
268
+ if (!result.ok || !isRecord(result.value)) return undefined;
269
+ const value = result.value.defaultProjectTrust;
270
+ return typeof value === "string" ? value : undefined;
271
+ }
@@ -5,6 +5,12 @@
5
5
  * one-run selection: it is never written back to runtime state.
6
6
  * - Without the flag the saved selection applies: the trusted project's
7
7
  * state wins over the global state, then the built-in `default`.
8
+ * - Every session start after the first continues THIS process's selection
9
+ * (`continuation`): the flag is a startup directive, and re-reading it
10
+ * after an in-session switch would resurrect the profile the user just
11
+ * left. A continuation that no longer resolves falls back like a saved
12
+ * selection, with a warning, because a deleted profile must not strand the
13
+ * runtime.
8
14
  * - A saved selection that no longer resolves falls back to `default` with
9
15
  * a warning — restore is a convenience, not a commitment. An explicit
10
16
  * flag value never falls back: an unknown name is a loud error.
@@ -94,19 +100,70 @@ async function legacyRegistryWarnings(input: {
94
100
  return warnings;
95
101
  }
96
102
 
97
- /** Resolves the profile name for this session: explicit flag → trusted
98
- * project state global state default. Never writes state. */
103
+ /**
104
+ * The profile this process last applied. Kept on `globalThis` because Pi
105
+ * re-imports extension modules on every reload (jiti) — module state resets
106
+ * exactly when it is needed. It is a run-scoped fact, not saved state: the
107
+ * `--profile` flag must not resurrect itself over an in-session switch when
108
+ * the MCP overlay change rebuilds the runtime.
109
+ */
110
+ const RUN_SELECTION_KEY = "pi-profile-switch:applied-profile";
111
+
112
+ interface RunSelection {
113
+ appliedProfile?: string;
114
+ }
115
+
116
+ function runSelection(): RunSelection {
117
+ const holder = globalThis as unknown as Record<string, RunSelection | undefined>;
118
+ const current = holder[RUN_SELECTION_KEY] ?? {};
119
+ holder[RUN_SELECTION_KEY] = current;
120
+ return current;
121
+ }
122
+
123
+ /** Records the profile this process applied (startup or in-session).
124
+ * `undefined` clears the record; a fresh process starts empty. */
125
+ export function recordAppliedProfile(name: string | undefined): void {
126
+ runSelection().appliedProfile = name;
127
+ }
128
+
129
+ /** The profile this process is running, or undefined before the first
130
+ * activation. */
131
+ export function appliedProfile(): string | undefined {
132
+ return runSelection().appliedProfile;
133
+ }
134
+
135
+ /** Resolves the profile name for this session: the run's current selection
136
+ * (`continuation`) → explicit flag → trusted project state → global state →
137
+ * default. Never writes state.
138
+ *
139
+ * `requested` is the CLI flag: an unknown name is a loud error.
140
+ * `continuation` is what this process already applied, passed on every
141
+ * session start after the first: it keeps the in-session switch, and falls
142
+ * back (with a warning) when the name no longer resolves. */
99
143
  export async function resolveStartupProfile(input: {
100
144
  agentDir: string;
101
145
  cwd: string;
102
146
  projectTrusted: boolean;
103
147
  requested?: string;
148
+ continuation?: string;
104
149
  }): Promise<StartupProfile> {
105
150
  const catalog = await ProfileCatalog.load(input.agentDir, {
106
151
  projectDir: input.projectTrusted ? input.cwd : undefined,
107
152
  });
108
153
  const warnings: string[] = await legacyRegistryWarnings(input);
109
154
 
155
+ // The continuation first: this process already applied it, so the flag
156
+ // (a startup directive) has had its say.
157
+ if (input.continuation !== undefined) {
158
+ if (catalog.resolve(input.continuation) !== undefined) {
159
+ return { name: input.continuation, warnings };
160
+ }
161
+ warnings.push(
162
+ `profile ${JSON.stringify(input.continuation)} no longer exists — restoring the saved selection; ` +
163
+ `available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
164
+ );
165
+ }
166
+
110
167
  if (input.requested !== undefined) {
111
168
  if (catalog.resolve(input.requested) === undefined) {
112
169
  throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
@@ -82,6 +82,18 @@ export async function resolveProfileSelection(
82
82
  return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
83
83
  }
84
84
 
85
+ /** Drops a stale saved selection from the scope that no longer owns it. A
86
+ * project state file left behind by a previous project profile shadows the
87
+ * new global choice on the next startup (project state wins), so the switch
88
+ * path clears it; the scope's overlay stays, because that belongs to the
89
+ * profile it was created for. */
90
+ async function clearOtherSelection(stateDir: string): Promise<void> {
91
+ const store = new RuntimeStateStore(stateDir);
92
+ if ((await store.read()).activeProfile !== undefined) {
93
+ await store.update({ otherActiveProfile: undefined });
94
+ }
95
+ }
96
+
85
97
  /** Activates a profile: resolve, validate, optionally persist, apply. */
86
98
  export async function activateProfile(
87
99
  name: string,
@@ -100,10 +112,27 @@ export async function activateProfile(
100
112
  }
101
113
 
102
114
  if (options?.persist !== false) {
103
- await new RuntimeStateStore(stateDirFor(selection.source, deps)).update({
104
- activeProfile: selection.name,
105
- overlay: overlay ?? undefined,
106
- });
115
+ // Exactly one scope holds the saved selection: the project state file
116
+ // for a project profile, the global one otherwise (including the
117
+ // built-in `default`). A project profile is only reachable in a
118
+ // trusted project, so an untrusted project is skipped exactly like
119
+ // its catalog read — its state file stays untouched (ADR-0007).
120
+ const ownDir = stateDirFor(selection.source, deps);
121
+ const projectDir = stateDirFor("project", deps);
122
+ const writable = selection.source !== "project" || deps.projectTrusted;
123
+ if (writable) {
124
+ await new RuntimeStateStore(ownDir).update({
125
+ activeProfile: selection.name,
126
+ overlay: overlay ?? undefined,
127
+ });
128
+ // The scope that owned the previous selection must let go of it,
129
+ // or the next startup restores it: project state wins over global.
130
+ // An untrusted project is never read or written here either.
131
+ const otherDir = selection.source === "project" ? stateDirFor("global", deps) : projectDir;
132
+ if (otherDir !== ownDir && (otherDir !== projectDir || deps.projectTrusted)) {
133
+ await clearOtherSelection(otherDir);
134
+ }
135
+ }
107
136
  }
108
137
 
109
138
  const result = await applySelection({ selection, surface: deps.surface, preset });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * McpToggle: persistent, profile-scoped `/mcp enable|disable` (ticket 10).
3
3
  *
4
- * The profile's `mcp` array in its OWNING catalog is the profile-scoped
4
+ * The profile's `mcps` array in its OWNING catalog is the profile-scoped
5
5
  * state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
6
6
  * allowlist/profile-state API — ADR-0002's assumed store does not exist;
7
7
  * pi-profile-switch owns the contract). The runtime effect is the caller's
@@ -20,7 +20,13 @@
20
20
  */
21
21
 
22
22
  import { discoverAdapterServerNames } from "../mcp-config.ts";
23
- import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition, type ProfileSource } from "../profile-catalog.ts";
23
+ import {
24
+ CatalogError,
25
+ DEFAULT_PROFILE_NAME,
26
+ LEGACY_FIELD_ALIASES,
27
+ type ProfileDefinition,
28
+ type ProfileSource,
29
+ } from "../profile-catalog.ts";
24
30
  import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
25
31
 
26
32
  export async function setMcpServerEnabled(
@@ -32,7 +38,7 @@ export async function setMcpServerEnabled(
32
38
  },
33
39
  server: string,
34
40
  enabled: boolean,
35
- ): Promise<{ mcp: string[]; changed: boolean }> {
41
+ ): Promise<{ mcps: string[]; changed: boolean }> {
36
42
  const { name, source } = input.profile;
37
43
  if (name === DEFAULT_PROFILE_NAME || source === "builtin") {
38
44
  throw new CatalogError(
@@ -60,16 +66,18 @@ export async function setMcpServerEnabled(
60
66
  throw new CatalogError(`profile "${input.profile.name}" not found in the ${scope} catalog`);
61
67
  }
62
68
 
63
- const current = definition.mcp ?? [];
69
+ const current = definition.mcps ?? [];
64
70
  if (enabled === current.includes(server)) {
65
- return { mcp: current, changed: false }; // already in the requested state
71
+ return { mcps: current, changed: false }; // already in the requested state
66
72
  }
67
73
  const next = enabled ? [...current, server] : current.filter((name) => name !== server);
68
74
  // Drop the key entirely when empty (exactOptionalPropertyTypes; a
69
- // written `mcp: undefined` would also misrepresent the definition).
75
+ // written `mcps: undefined` would also misrepresent the definition) and
76
+ // drop the legacy alias, so one save migrates the file to `mcps`.
70
77
  const rest = { ...definition };
71
- delete rest.mcp;
72
- const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcp: next } : rest;
78
+ delete rest.mcps;
79
+ const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcps: next } : rest;
80
+ delete (updated as Record<string, unknown>)[LEGACY_FIELD_ALIASES.mcps];
73
81
  await catalogStore(input, scope).upsert(input.profile.name, updated);
74
- return { mcp: next, changed: true };
82
+ return { mcps: next, changed: true };
75
83
  }
@@ -75,7 +75,7 @@ async function captureDefinition(
75
75
 
76
76
  const listFields = [
77
77
  ["skills", "skills (comma-separated names or globs, empty = all visible)"],
78
- ["mcp", "mcp servers (names or globs, empty = none)"],
78
+ ["mcps", "MCP servers (names or globs, empty = none)"],
79
79
  ["tools", "tools (names or globs, empty = pi default set)"],
80
80
  ] as const;
81
81
  for (const [field, prompt] of listFields) {