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,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DefaultProfiles: the catalog a fresh install starts from.
|
|
3
|
+
*
|
|
4
|
+
* Pi packages have no install hook (docs/packages.md) — `pi install` only
|
|
5
|
+
* unpacks the package and runs `npm install` — so "installing the default
|
|
6
|
+
* profiles" can only happen the first time the extension actually loads.
|
|
7
|
+
* `seedDefaultProfilesSync` therefore runs at load time, before any session
|
|
8
|
+
* event, and is idempotent: an existing catalog file is never read, rewritten,
|
|
9
|
+
* or backed up, and the package never seeds again once the file exists.
|
|
10
|
+
*
|
|
11
|
+
* The seeded content is the shipped preset catalog, exactly what
|
|
12
|
+
* `examples/profiles.json` publishes, reached through the same
|
|
13
|
+
* `PROFILE_PRESETS` data `/profile create` offers — one definition, so the
|
|
14
|
+
* three cannot drift. The `read-only` profile declares built-in tools and
|
|
15
|
+
* instructions only: it activates on a machine with no skills, no
|
|
16
|
+
* `pi-mcp-adapter`, and no credentials, and it changes nothing until the
|
|
17
|
+
* user selects it.
|
|
18
|
+
*
|
|
19
|
+
* `profile-presets.test.ts` pins the content contract: this catalog equals
|
|
20
|
+
* the published `examples/profiles.json` and every definition is
|
|
21
|
+
* resource-free.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { DEFAULT_PROFILE_NAME, PROFILE_SCHEMA_VERSION } from "./profile-catalog.ts";
|
|
28
|
+
import { PROFILE_PRESETS } from "./profile-presets.ts";
|
|
29
|
+
|
|
30
|
+
/** The catalog written to `<agentDir>/profiles.json` on first load. */
|
|
31
|
+
export const DEFAULT_PROFILE_CATALOG = {
|
|
32
|
+
schemaVersion: PROFILE_SCHEMA_VERSION,
|
|
33
|
+
profiles: Object.fromEntries(
|
|
34
|
+
PROFILE_PRESETS.filter((preset) => preset.name !== DEFAULT_PROFILE_NAME).map((preset) => [
|
|
35
|
+
preset.name,
|
|
36
|
+
preset.definition,
|
|
37
|
+
]),
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DefaultSeedResult =
|
|
42
|
+
/** The agent dir had no catalog and now holds the default one. */
|
|
43
|
+
| "seeded"
|
|
44
|
+
/** A catalog file was already there; it was not touched. */
|
|
45
|
+
| "present";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Writes `<agentDir>/profiles.json` when it does not exist yet. Synchronous
|
|
49
|
+
* on purpose: the load-time call site cannot await, and the write is one small
|
|
50
|
+
* file. Existing files win unconditionally — an empty or hand-written catalog
|
|
51
|
+
* is a user decision, not a missing default.
|
|
52
|
+
*/
|
|
53
|
+
export function seedDefaultProfilesSync(agentDir: string, catalog: unknown = DEFAULT_PROFILE_CATALOG): DefaultSeedResult {
|
|
54
|
+
const target = path.join(agentDir, "profiles.json");
|
|
55
|
+
if (existsSync(target)) return "present";
|
|
56
|
+
mkdirSync(agentDir, { recursive: true });
|
|
57
|
+
writeFileSync(target, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
58
|
+
return "seeded";
|
|
59
|
+
}
|
package/src/json-file.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* fallback); this helper only classifies the outcome.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
9
10
|
|
|
10
11
|
export type JsonFileResult =
|
|
@@ -30,6 +31,25 @@ export async function readJsonFile(filePath: string): Promise<JsonFileResult> {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** Synchronous twin of `readJsonFile`, for callers that must finish before
|
|
35
|
+
* an event Pi is about to emit (extension loading). Same classification. */
|
|
36
|
+
export function readJsonFileSync(filePath: string): JsonFileResult {
|
|
37
|
+
let raw: string;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(filePath, "utf8");
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
42
|
+
return { ok: false, reason: "missing" };
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
48
|
+
} catch {
|
|
49
|
+
return { ok: false, reason: "invalid" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
54
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
55
|
}
|
package/src/mcp-config.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AdapterConfigDiscovery: reads
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -37,7 +37,7 @@ export class ProfileCatalogStore {
|
|
|
37
37
|
|
|
38
38
|
/** Validated definitions: missing file → empty; malformed → CatalogError
|
|
39
39
|
* (catalog errors never pass silently, even on the write path).
|
|
40
|
-
* Unknown fields (
|
|
40
|
+
* Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
|
|
41
41
|
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
42
42
|
const result = await readJsonFile(this.#filePath);
|
|
43
43
|
if (!result.ok) {
|
package/src/profile-catalog.ts
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* - A profile references skills, MCP servers, and tools, and may declare
|
|
8
8
|
* instructions and a model preset. Extensions are not a profile resource:
|
|
9
9
|
* every installed extension loads natively in every profile.
|
|
10
|
-
* - schemaVersion 1 is
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* - schemaVersion 1 is the only accepted version: any other value fails
|
|
11
|
+
* loudly instead of guessing at a shape.
|
|
12
|
+
* - Unknown fields (an `extensions` declaration left over from v0.1.0, an
|
|
13
|
+
* inheritance key) are ignored silently; saving drops them, so a written
|
|
14
|
+
* definition always matches the current shape.
|
|
15
|
+
* - The catalog key for MCP servers is `mcps` (plural, like `skills` and
|
|
16
|
+
* `tools`). The legacy name `mcp` is still READ as an alias so an
|
|
17
|
+
* existing catalog keeps its allowlist; `mcps` wins when both keys are
|
|
18
|
+
* present, and a value of the wrong type fails loudly either way.
|
|
19
|
+
* Definitions always carry the canonical `mcps` field in memory, and
|
|
20
|
+
* profile-catalog-store.ts drops a file's legacy key on its next write.
|
|
15
21
|
*
|
|
16
22
|
* Invariants:
|
|
17
23
|
* - The built-in `default` profile never exists in either file and cannot be
|
|
@@ -30,8 +36,6 @@ import path from "node:path";
|
|
|
30
36
|
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
31
37
|
|
|
32
38
|
export const PROFILE_SCHEMA_VERSION = 1;
|
|
33
|
-
/** The number v0.1.0 wrote for the same field shape: read, never written. */
|
|
34
|
-
const LEGACY_SCHEMA_VERSION = 2;
|
|
35
39
|
export const DEFAULT_PROFILE_NAME = "default";
|
|
36
40
|
|
|
37
41
|
export interface ProfileModel {
|
|
@@ -46,7 +50,7 @@ export interface ProfileDefinition {
|
|
|
46
50
|
label?: string;
|
|
47
51
|
description?: string;
|
|
48
52
|
skills?: string[];
|
|
49
|
-
|
|
53
|
+
mcps?: string[];
|
|
50
54
|
tools?: string[];
|
|
51
55
|
model?: ProfileModel;
|
|
52
56
|
instructions?: string;
|
|
@@ -81,6 +85,27 @@ function readStringArray(value: unknown, field: string, profileName: string): st
|
|
|
81
85
|
return value as string[];
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
/** Legacy catalog key names, still accepted on read (canonical name wins).
|
|
89
|
+
* The write paths (/profile CRUD, /mcp enable|disable) delete the alias, so
|
|
90
|
+
* a file migrates to the canonical spelling the next time it is saved. */
|
|
91
|
+
export const LEGACY_FIELD_ALIASES = { mcps: "mcp" } as const;
|
|
92
|
+
|
|
93
|
+
/** Reads an array-of-strings field under its canonical name or a legacy
|
|
94
|
+
* alias. Both spellings are validated when present, so a value of the
|
|
95
|
+
* wrong type never passes silently through either key. */
|
|
96
|
+
function readAliasedStringArray(
|
|
97
|
+
raw: Record<string, unknown>,
|
|
98
|
+
canonical: string,
|
|
99
|
+
legacy: string,
|
|
100
|
+
profileName: string,
|
|
101
|
+
): string[] | undefined {
|
|
102
|
+
for (const key of [canonical, legacy]) {
|
|
103
|
+
if (raw[key] !== undefined) readStringArray(raw[key], key, profileName);
|
|
104
|
+
}
|
|
105
|
+
const source = raw[canonical] !== undefined ? canonical : legacy;
|
|
106
|
+
return readStringArray(raw[source], source, profileName);
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
|
|
85
110
|
if (value === undefined) return undefined;
|
|
86
111
|
if (typeof value !== "string") {
|
|
@@ -91,8 +116,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
|
|
|
91
116
|
|
|
92
117
|
/** Parses one raw profile definition; exported for the write-side store
|
|
93
118
|
* (profile-catalog-store.ts) so anything written is loadable. Unknown
|
|
94
|
-
* fields are ignored by design —
|
|
95
|
-
* dropped silently, exactly like any other unknown key. */
|
|
119
|
+
* fields are ignored by design — an `extensions` key left over from
|
|
120
|
+
* v0.1.0 is dropped silently, exactly like any other unknown key. */
|
|
96
121
|
export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
|
|
97
122
|
if (!isRecord(raw)) {
|
|
98
123
|
throw new CatalogError(`profile "${name}" must be an object`);
|
|
@@ -102,10 +127,12 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
|
|
|
102
127
|
if (label !== undefined) definition.label = label;
|
|
103
128
|
const description = readOptionalString(raw.description, "description", name);
|
|
104
129
|
if (description !== undefined) definition.description = description;
|
|
105
|
-
for (const field of ["skills", "
|
|
130
|
+
for (const field of ["skills", "tools"] as const) {
|
|
106
131
|
const entries = readStringArray(raw[field], field, name);
|
|
107
132
|
if (entries !== undefined) definition[field] = entries;
|
|
108
133
|
}
|
|
134
|
+
const mcps = readAliasedStringArray(raw, "mcps", LEGACY_FIELD_ALIASES.mcps, name);
|
|
135
|
+
if (mcps !== undefined) definition.mcps = mcps;
|
|
109
136
|
if (raw.model !== undefined) {
|
|
110
137
|
if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
|
|
111
138
|
throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
|
|
@@ -125,7 +152,7 @@ export function parseCatalogDocument(value: unknown, filePath: string): Map<stri
|
|
|
125
152
|
throw new CatalogError(`${filePath}: catalog must be an object`);
|
|
126
153
|
}
|
|
127
154
|
const version = value.schemaVersion;
|
|
128
|
-
if (version !== PROFILE_SCHEMA_VERSION
|
|
155
|
+
if (version !== PROFILE_SCHEMA_VERSION) {
|
|
129
156
|
throw new CatalogError(
|
|
130
157
|
`${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
|
|
131
158
|
);
|
package/src/profile-presets.ts
CHANGED
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
* - `instructions` states behavior, never a capability name, and stays
|
|
26
26
|
* short: Pi appends it to the system prompt on every turn.
|
|
27
27
|
*
|
|
28
|
-
* `test/profile-presets.test.ts` enforces every rule above
|
|
29
|
-
* `examples/profiles.json` is exactly this catalog
|
|
28
|
+
* `test/profile-presets.test.ts` enforces every rule above, asserts
|
|
29
|
+
* `examples/profiles.json` is exactly this catalog, and asserts each preset
|
|
30
|
+
* appears verbatim in `examples/profiles.example.json`.
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
import type { ProfileDefinition } from "./profile-catalog.ts";
|
package/src/profile-resolver.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* ADR-0007 semantics:
|
|
6
6
|
* - `skills` resolves to a visibility filter (see skill-selection.ts), not
|
|
7
7
|
* to loaded resources: every skill stays loaded and user-invocable.
|
|
8
|
-
* - `
|
|
8
|
+
* - `mcps` resolves to a runtime server allowlist; a declared MCP intent that
|
|
9
9
|
* cannot be satisfied (adapter absent, literal server unknown) fails the
|
|
10
10
|
* activation before anything is applied.
|
|
11
11
|
* - `tools` resolves to an active tool set; literals the live registry does
|
|
@@ -136,7 +136,7 @@ function resolveMcp(
|
|
|
136
136
|
if (!live.adapterPresent) {
|
|
137
137
|
throw new SelectionError(
|
|
138
138
|
`profile "${profileName}" declares MCP servers but pi-mcp-adapter is not active in this session — ` +
|
|
139
|
-
`install the adapter or remove the "
|
|
139
|
+
`install the adapter or remove the "mcps" declaration`,
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
const selected: string[] = [];
|
|
@@ -201,7 +201,7 @@ export function resolveSelection(input: {
|
|
|
201
201
|
const definition = profile.definition;
|
|
202
202
|
|
|
203
203
|
const skills = resolveSkills(definition.skills, overlay?.disabledSkills ?? [], live.skills);
|
|
204
|
-
const mcp = resolveMcp(definition.
|
|
204
|
+
const mcp = resolveMcp(definition.mcps, overlay?.disabledMcp ?? [], live.mcp, profile.name);
|
|
205
205
|
const tools = input.suppressTools === true
|
|
206
206
|
? { pendingTools: [], warning: { toolsUnmatched: [] } }
|
|
207
207
|
: resolveTools(overlay?.tools ?? definition.tools, live.toolNames);
|
|
@@ -33,6 +33,11 @@ export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd
|
|
|
33
33
|
export interface RuntimeState {
|
|
34
34
|
activeProfile?: string;
|
|
35
35
|
overlay?: RuntimeOverlay;
|
|
36
|
+
/** Patch-only marker: clears `activeProfile` from the store it is sent
|
|
37
|
+
* to, and to that store alone. A profile switch sends it to the OTHER
|
|
38
|
+
* scope's store, so a stale project selection cannot shadow the new
|
|
39
|
+
* global choice on the next startup. Never stored, never read back. */
|
|
40
|
+
otherActiveProfile?: undefined;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface RuntimeOverlay {
|
|
@@ -112,6 +117,7 @@ export class RuntimeStateStore {
|
|
|
112
117
|
if (patch.overlay === undefined) delete next.overlay;
|
|
113
118
|
else next.overlay = patch.overlay;
|
|
114
119
|
}
|
|
120
|
+
if ("otherActiveProfile" in patch) delete next.activeProfile;
|
|
115
121
|
await this.write(next);
|
|
116
122
|
return next;
|
|
117
123
|
}
|