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.
- package/README.md +11 -27
- package/README.zh-CN.md +11 -27
- package/examples/profiles.example.json +74 -0
- package/extensions/pi-profile-switch/index.ts +150 -9
- package/package.json +1 -1
- package/schemas/profiles.schema.json +4 -11
- package/src/adapter-presence.ts +75 -0
- package/src/default-profiles.ts +59 -0
- package/src/json-file.ts +20 -0
- package/src/mcp-config.ts +153 -34
- package/src/mcp-overlay-file.ts +35 -0
- package/src/mcp-overlay.ts +122 -0
- package/src/profile-catalog-store.ts +1 -1
- package/src/profile-catalog.ts +39 -12
- package/src/profile-presets.ts +3 -2
- package/src/profile-resolver.ts +3 -3
- package/src/runtime-state-store.ts +6 -0
- package/src/startup-mcp-scope.ts +271 -0
- package/src/startup-selection.ts +59 -2
- package/src/switching/activate-profile.ts +33 -4
- package/src/switching/mcp-toggle.ts +17 -9
- package/src/switching/profile-wizard.ts +1 -1
|
@@ -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
|
+
}
|
package/src/startup-selection.ts
CHANGED
|
@@ -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
|
-
/**
|
|
98
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 `
|
|
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 {
|
|
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<{
|
|
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.
|
|
69
|
+
const current = definition.mcps ?? [];
|
|
64
70
|
if (enabled === current.includes(server)) {
|
|
65
|
-
return {
|
|
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 `
|
|
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.
|
|
72
|
-
const updated: ProfileDefinition = next.length > 0 ? { ...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 {
|
|
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
|
-
["
|
|
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) {
|