pi-profile-switch 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -36
- package/README.zh-CN.md +10 -36
- package/examples/profiles.example.json +74 -0
- package/examples/profiles.json +6 -28
- package/extensions/pi-profile-switch/index.ts +213 -18
- package/package.json +1 -1
- package/schemas/profiles.schema.json +2 -8
- package/src/adapter-presence.ts +75 -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-badge.ts +142 -0
- package/src/profile-catalog-store.ts +4 -5
- package/src/profile-catalog.ts +17 -43
- package/src/profile-presets.ts +61 -0
- package/src/profile-resolver.ts +51 -18
- package/src/runtime-state-store.ts +13 -0
- package/src/startup-mcp-scope.ts +262 -0
- package/src/startup-selection.ts +1 -4
- package/src/switching/activate-profile.ts +8 -6
- package/src/switching/list-profiles.ts +8 -11
- package/src/switching/profile-wizard.ts +39 -6
package/src/profile-catalog.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
13
15
|
*
|
|
14
16
|
* Invariants:
|
|
15
17
|
* - The built-in `default` profile never exists in either file and cannot be
|
|
@@ -27,7 +29,7 @@ import path from "node:path";
|
|
|
27
29
|
|
|
28
30
|
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
29
31
|
|
|
30
|
-
export const PROFILE_SCHEMA_VERSION =
|
|
32
|
+
export const PROFILE_SCHEMA_VERSION = 1;
|
|
31
33
|
export const DEFAULT_PROFILE_NAME = "default";
|
|
32
34
|
|
|
33
35
|
export interface ProfileModel {
|
|
@@ -62,13 +64,6 @@ export interface ResolvedProfile {
|
|
|
62
64
|
definition: ProfileDefinition;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
/** One parsed catalog file: definitions plus non-fatal compatibility
|
|
66
|
-
* warnings the caller surfaces once per activation. */
|
|
67
|
-
export interface CatalogDocument {
|
|
68
|
-
profiles: Map<string, ProfileDefinition>;
|
|
69
|
-
warnings: string[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
67
|
export class CatalogError extends Error {
|
|
73
68
|
constructor(message: string) {
|
|
74
69
|
super(message);
|
|
@@ -94,8 +89,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
|
|
|
94
89
|
|
|
95
90
|
/** Parses one raw profile definition; exported for the write-side store
|
|
96
91
|
* (profile-catalog-store.ts) so anything written is loadable. Unknown
|
|
97
|
-
* fields are ignored by design — `extensions`
|
|
98
|
-
*
|
|
92
|
+
* fields are ignored by design — an `extensions` key left over from
|
|
93
|
+
* v0.1.0 is dropped silently, exactly like any other unknown key. */
|
|
99
94
|
export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
|
|
100
95
|
if (!isRecord(raw)) {
|
|
101
96
|
throw new CatalogError(`profile "${name}" must be an object`);
|
|
@@ -123,22 +118,16 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
|
|
|
123
118
|
|
|
124
119
|
/** Parses one catalog document. Missing files are handled by the caller;
|
|
125
120
|
* this function sees only parsed JSON. */
|
|
126
|
-
export function parseCatalogDocument(value: unknown, filePath: string):
|
|
121
|
+
export function parseCatalogDocument(value: unknown, filePath: string): Map<string, ProfileDefinition> {
|
|
127
122
|
if (!isRecord(value)) {
|
|
128
123
|
throw new CatalogError(`${filePath}: catalog must be an object`);
|
|
129
124
|
}
|
|
130
|
-
const warnings: string[] = [];
|
|
131
125
|
const version = value.schemaVersion;
|
|
132
|
-
if (version !==
|
|
126
|
+
if (version !== PROFILE_SCHEMA_VERSION) {
|
|
133
127
|
throw new CatalogError(
|
|
134
128
|
`${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
|
|
135
129
|
);
|
|
136
130
|
}
|
|
137
|
-
if (version === 1) {
|
|
138
|
-
warnings.push(
|
|
139
|
-
`${filePath}: schemaVersion 1 is read as version ${PROFILE_SCHEMA_VERSION}; profiles declaring "extensions" are upgraded with that field ignored`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
131
|
if (!isRecord(value.profiles)) {
|
|
143
132
|
throw new CatalogError(`${filePath}: "profiles" must be an object mapping names to definitions`);
|
|
144
133
|
}
|
|
@@ -149,21 +138,16 @@ export function parseCatalogDocument(value: unknown, filePath: string): CatalogD
|
|
|
149
138
|
`${filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
|
|
150
139
|
);
|
|
151
140
|
}
|
|
152
|
-
if (isRecord(raw) && raw.extensions !== undefined) {
|
|
153
|
-
warnings.push(
|
|
154
|
-
`${filePath}: profile "${name}" declares "extensions"; extensions are always loaded natively (ADR-0007) and the field is ignored — manage extensions with pi install`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
141
|
profiles.set(name, parseProfileDefinition(name, raw));
|
|
158
142
|
}
|
|
159
|
-
return
|
|
143
|
+
return profiles;
|
|
160
144
|
}
|
|
161
145
|
|
|
162
146
|
/** Reads one catalog file; missing → empty map, malformed → CatalogError. */
|
|
163
|
-
async function loadCatalogFile(catalogPath: string): Promise<
|
|
147
|
+
async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
|
|
164
148
|
const result = await readJsonFile(catalogPath);
|
|
165
149
|
if (!result.ok) {
|
|
166
|
-
if (result.reason === "missing") return
|
|
150
|
+
if (result.reason === "missing") return new Map();
|
|
167
151
|
throw new CatalogError(`invalid JSON in ${catalogPath}`);
|
|
168
152
|
}
|
|
169
153
|
return parseCatalogDocument(result.value, catalogPath);
|
|
@@ -171,17 +155,9 @@ async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
|
|
|
171
155
|
|
|
172
156
|
export class ProfileCatalog {
|
|
173
157
|
readonly #profiles: ReadonlyMap<string, CatalogEntry>;
|
|
174
|
-
readonly #warnings: readonly string[];
|
|
175
158
|
|
|
176
|
-
private constructor(profiles: ReadonlyMap<string, CatalogEntry
|
|
159
|
+
private constructor(profiles: ReadonlyMap<string, CatalogEntry>) {
|
|
177
160
|
this.#profiles = profiles;
|
|
178
|
-
this.#warnings = warnings;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Compatibility warnings from reading the catalog files (v1 schema,
|
|
182
|
-
* ignored `extensions` fields). Empty for a current, well-formed pair. */
|
|
183
|
-
get warnings(): readonly string[] {
|
|
184
|
-
return this.#warnings;
|
|
185
161
|
}
|
|
186
162
|
|
|
187
163
|
/**
|
|
@@ -193,18 +169,16 @@ export class ProfileCatalog {
|
|
|
193
169
|
static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
|
|
194
170
|
const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
|
|
195
171
|
const profiles = new Map<string, CatalogEntry>();
|
|
196
|
-
const
|
|
197
|
-
for (const [name, definition] of global.profiles) {
|
|
172
|
+
for (const [name, definition] of global) {
|
|
198
173
|
profiles.set(name, { source: "global", definition });
|
|
199
174
|
}
|
|
200
175
|
if (options?.projectDir !== undefined) {
|
|
201
176
|
const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
|
|
202
|
-
|
|
203
|
-
for (const [name, definition] of project.profiles) {
|
|
177
|
+
for (const [name, definition] of project) {
|
|
204
178
|
profiles.set(name, { source: "project", definition });
|
|
205
179
|
}
|
|
206
180
|
}
|
|
207
|
-
return new ProfileCatalog(profiles
|
|
181
|
+
return new ProfileCatalog(profiles);
|
|
208
182
|
}
|
|
209
183
|
|
|
210
184
|
/** Resolves a profile by name. `default` always resolves to the built-in
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfilePresets: the starting points `/profile create` offers.
|
|
3
|
+
*
|
|
4
|
+
* A preset is DATA, not a profile. It never appears in `/profile list` and
|
|
5
|
+
* cannot be activated until the create wizard copies it into a user catalog,
|
|
6
|
+
* so `default` stays the only built-in profile and nothing here is ever
|
|
7
|
+
* silently in effect. Once copied, the definition belongs to the user: the
|
|
8
|
+
* preset is not tracked, and later changes to it do not reach existing
|
|
9
|
+
* profiles.
|
|
10
|
+
*
|
|
11
|
+
* Every preset must work on a machine with no skills installed, no MCP
|
|
12
|
+
* adapter, and no provider credentials:
|
|
13
|
+
*
|
|
14
|
+
* - no `mcp`: a declared MCP intent fails the whole activation when
|
|
15
|
+
* `pi-mcp-adapter` is absent or the server was never discovered
|
|
16
|
+
* (ADR-0002).
|
|
17
|
+
* - no `model`: `provider` and `id` are required, and an unauthenticated
|
|
18
|
+
* model also fails the whole activation.
|
|
19
|
+
* - no `skills`: a literal reference warns once the skill turns out to be
|
|
20
|
+
* missing, and `[]` hides every skill — omitting the field keeps Pi's full
|
|
21
|
+
* visibility.
|
|
22
|
+
* - `tools` names Pi's built-in tools only, because extension and MCP tool
|
|
23
|
+
* names may never register, and it always contains `read`: Pi emits the
|
|
24
|
+
* prompt's skills section only while `read` or `bash` is active.
|
|
25
|
+
* - `instructions` states behavior, never a capability name, and stays
|
|
26
|
+
* short: Pi appends it to the system prompt on every turn.
|
|
27
|
+
*
|
|
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`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { ProfileDefinition } from "./profile-catalog.ts";
|
|
34
|
+
|
|
35
|
+
export interface ProfilePreset {
|
|
36
|
+
/** Catalog key the create wizard offers as the new profile's name. */
|
|
37
|
+
name: string;
|
|
38
|
+
definition: ProfileDefinition;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The read-only behavior contract: describe, do not mutate. */
|
|
42
|
+
const READ_ONLY_INSTRUCTIONS = [
|
|
43
|
+
"Read-only session: inspect and report; never create, edit, rename, or delete files.",
|
|
44
|
+
"If a change is needed, describe it in your reply instead of applying it.",
|
|
45
|
+
"Do not run commands that modify state (installs, formatters, commits, pushes, network writes).",
|
|
46
|
+
"Prefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.",
|
|
47
|
+
"Ground claims in evidence: cite file:line and separate verified facts from inferences.",
|
|
48
|
+
"Reply in English.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
|
|
51
|
+
export const PROFILE_PRESETS: readonly ProfilePreset[] = [
|
|
52
|
+
{
|
|
53
|
+
name: "read-only",
|
|
54
|
+
definition: {
|
|
55
|
+
label: "Read-only",
|
|
56
|
+
description: "Read-only session; no skills or MCP servers assumed — add your own.",
|
|
57
|
+
tools: ["read", "grep", "find", "ls"],
|
|
58
|
+
instructions: READ_ONLY_INSTRUCTIONS,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
package/src/profile-resolver.ts
CHANGED
|
@@ -23,7 +23,11 @@ import type { RuntimeOverlay } from "./runtime-state-store.ts";
|
|
|
23
23
|
|
|
24
24
|
/** The live resource view a resolution runs against. */
|
|
25
25
|
export interface LiveResources {
|
|
26
|
-
skills
|
|
26
|
+
/** Pi's loaded skills. `undefined` when the caller cannot read them yet:
|
|
27
|
+
* Pi exposes the list on command contexts and on the `before_agent_start`
|
|
28
|
+
* event, not in `session_start`'s event context. An unknown set leaves the
|
|
29
|
+
* visibility filter intact and reports nothing (see `skillWarnings`). */
|
|
30
|
+
skills?: Array<{ name: string; filePath: string }>;
|
|
27
31
|
toolNames: string[];
|
|
28
32
|
/** MCP adapter state: presence plus discovered server names. */
|
|
29
33
|
mcp: { adapterPresent: boolean; servers: string[] };
|
|
@@ -77,17 +81,21 @@ export class SelectionError extends Error {
|
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
/** Existence warnings for skill references against one live skill set.
|
|
85
|
+
* `undefined` means "not known yet" — Pi's skill list is only readable from
|
|
86
|
+
* a command context or from the `before_agent_start` event, so a startup
|
|
87
|
+
* activation reports nothing instead of every reference as unloaded. The
|
|
88
|
+
* same function re-checks the references on the first turn, when the list
|
|
89
|
+
* (including skills contributed through `resources_discover`) is complete. */
|
|
90
|
+
export function skillWarnings(
|
|
91
|
+
refs: readonly string[],
|
|
83
92
|
live: LiveResources["skills"],
|
|
84
|
-
):
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
93
|
+
): Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> {
|
|
94
|
+
const warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> = {
|
|
95
|
+
skillsUnresolved: [],
|
|
96
|
+
skillsUnmatched: [],
|
|
97
|
+
};
|
|
98
|
+
if (live === undefined) return warning;
|
|
91
99
|
for (const ref of refs) {
|
|
92
100
|
const hits = live.filter((skill) => matchesReference(ref, skill.name));
|
|
93
101
|
if (hits.length === 0) {
|
|
@@ -95,7 +103,22 @@ function resolveSkills(
|
|
|
95
103
|
else warning.skillsUnresolved.push({ reference: ref, suggestions: suggestNames(ref, live.map((s) => s.name)) });
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
|
-
return
|
|
106
|
+
return warning;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveSkills(
|
|
110
|
+
declared: string[] | undefined,
|
|
111
|
+
disabled: string[],
|
|
112
|
+
live: LiveResources["skills"],
|
|
113
|
+
): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
|
|
114
|
+
if (declared === undefined) {
|
|
115
|
+
// The profile declares nothing; an overlay may still hide skills.
|
|
116
|
+
return {
|
|
117
|
+
...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}),
|
|
118
|
+
warning: skillWarnings([], live),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { filter: { refs: declared, disabled }, warning: skillWarnings(declared, live) };
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
function resolveMcp(
|
|
@@ -211,23 +234,33 @@ export function resolveSelection(input: {
|
|
|
211
234
|
return selection;
|
|
212
235
|
}
|
|
213
236
|
|
|
214
|
-
/** User-facing
|
|
215
|
-
|
|
237
|
+
/** User-facing lines for one set of skill warnings. Shared by the startup
|
|
238
|
+
* report and the first-turn re-check, so both read identically. */
|
|
239
|
+
export function formatSkillWarnings(
|
|
240
|
+
profile: string,
|
|
241
|
+
warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched">,
|
|
242
|
+
): string[] {
|
|
216
243
|
const lines: string[] = [];
|
|
217
|
-
for (const unresolved of
|
|
244
|
+
for (const unresolved of warning.skillsUnresolved) {
|
|
218
245
|
const hint =
|
|
219
246
|
unresolved.suggestions.length > 0
|
|
220
247
|
? ` — did you mean: ${unresolved.suggestions.map((name) => JSON.stringify(name)).join(", ")}?`
|
|
221
248
|
: "";
|
|
222
249
|
lines.push(
|
|
223
|
-
`profile "${
|
|
250
|
+
`profile "${profile}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
|
|
224
251
|
);
|
|
225
252
|
}
|
|
226
|
-
if (
|
|
253
|
+
if (warning.skillsUnmatched.length > 0) {
|
|
227
254
|
lines.push(
|
|
228
|
-
`profile "${
|
|
255
|
+
`profile "${profile}": skill glob(s) ${warning.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
|
|
229
256
|
);
|
|
230
257
|
}
|
|
258
|
+
return lines;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** User-facing warning lines for one resolved selection. */
|
|
262
|
+
export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
|
|
263
|
+
const lines: string[] = formatSkillWarnings(selection.name, selection.warnings);
|
|
231
264
|
if (selection.warnings.mcpUnmatched.length > 0) {
|
|
232
265
|
lines.push(
|
|
233
266
|
`profile "${selection.name}": MCP glob(s) ${selection.warnings.mcpUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
|
|
@@ -42,6 +42,19 @@ export interface RuntimeOverlay {
|
|
|
42
42
|
tools?: string[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** True when an overlay actually narrows the active profile. An overlay whose
|
|
46
|
+
* fields were all removed again (`/profile customize enable …`) is not a
|
|
47
|
+
* difference from the catalog, and `parseOverlay` drops an empty overlay on
|
|
48
|
+
* read. A `tools: []` override is a difference: it selects no tools. */
|
|
49
|
+
export function overlayNarrows(overlay: RuntimeOverlay | undefined): boolean {
|
|
50
|
+
if (overlay === undefined) return false;
|
|
51
|
+
return (
|
|
52
|
+
(overlay.disabledSkills?.length ?? 0) > 0 ||
|
|
53
|
+
(overlay.disabledMcp?.length ?? 0) > 0 ||
|
|
54
|
+
overlay.tools !== undefined
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
function parseOverlay(value: unknown): RuntimeOverlay | undefined {
|
|
46
59
|
if (!isRecord(value)) return undefined;
|
|
47
60
|
const overlay: RuntimeOverlay = {};
|
|
@@ -0,0 +1,262 @@
|
|
|
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 saved selection for this run: project state wins over global state
|
|
77
|
+
* (project state only when trusted), then the built-in default. */
|
|
78
|
+
export function resolveStartupProfileNameSync(input: {
|
|
79
|
+
agentDir: string;
|
|
80
|
+
cwd: string;
|
|
81
|
+
projectTrusted: boolean;
|
|
82
|
+
argv: readonly string[];
|
|
83
|
+
}): string {
|
|
84
|
+
const requested = readFlagFromArgv(input.argv, "profile");
|
|
85
|
+
if (requested !== undefined && requested.length > 0) return requested;
|
|
86
|
+
const project = input.projectTrusted
|
|
87
|
+
? readActiveProfile(path.join(input.cwd, ".pi", "pi-profile-state.json"))
|
|
88
|
+
: undefined;
|
|
89
|
+
return project ?? readActiveProfile(path.join(input.agentDir, "pi-profile-state.json")) ?? DEFAULT_PROFILE_NAME;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface McpOverlaySyncInput {
|
|
93
|
+
agentDir: string;
|
|
94
|
+
cwd: string;
|
|
95
|
+
/** Pi's trust decision. Omitted on the extension-load pass, where Pi has
|
|
96
|
+
* not resolved trust yet; the module mirrors Pi's own order then. */
|
|
97
|
+
projectTrusted?: boolean;
|
|
98
|
+
/** Effective `--mcp-config`; undefined means the managed overlay path. */
|
|
99
|
+
overridePath?: string;
|
|
100
|
+
/** The command line, for the load pass (Pi applies flag values only after
|
|
101
|
+
* extension loading, so `--profile` is read from here). */
|
|
102
|
+
argv?: readonly string[];
|
|
103
|
+
homeDir?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface McpOverlaySyncResult {
|
|
107
|
+
overlayPath: string;
|
|
108
|
+
/** False when a foreign `--mcp-config` owns the adapter's slot. */
|
|
109
|
+
managed: boolean;
|
|
110
|
+
changed: boolean;
|
|
111
|
+
/** Set when nothing was written; the caller surfaces it as a warning. */
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Load-time (and `session_start` re-check) pass. `profileName` short-circuits
|
|
116
|
+
* the state lookup when the caller already activated a profile. */
|
|
117
|
+
export function syncStartupMcpOverlay(
|
|
118
|
+
input: McpOverlaySyncInput & { profileName?: string; mcpRefs?: readonly string[] | undefined },
|
|
119
|
+
): McpOverlaySyncResult {
|
|
120
|
+
try {
|
|
121
|
+
const trust = input.projectTrusted;
|
|
122
|
+
const profileName =
|
|
123
|
+
input.profileName ??
|
|
124
|
+
resolveStartupProfileNameSync({
|
|
125
|
+
agentDir: input.agentDir,
|
|
126
|
+
cwd: input.cwd,
|
|
127
|
+
projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
|
|
128
|
+
argv: input.argv ?? [],
|
|
129
|
+
});
|
|
130
|
+
const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
|
|
131
|
+
return writeOverlayForRefs({ ...input, refs });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
overlayPath: mcpSlotPath(input.agentDir),
|
|
135
|
+
managed: true,
|
|
136
|
+
changed: false,
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Switch pass: the caller already resolved the allowlist. */
|
|
143
|
+
export function syncMcpOverlayForSelection(
|
|
144
|
+
input: McpOverlaySyncInput & { allowed: readonly string[] | "all" },
|
|
145
|
+
): McpOverlaySyncResult {
|
|
146
|
+
try {
|
|
147
|
+
return writeOverlay({ ...input, refs: input.allowed });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
overlayPath: mcpSlotPath(input.agentDir),
|
|
151
|
+
managed: true,
|
|
152
|
+
changed: false,
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function writeOverlayForRefs(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "unknown" }): McpOverlaySyncResult {
|
|
159
|
+
// An unresolvable profile is not an error here: the generated overlay
|
|
160
|
+
// then filters nothing and the activation reports the real problem.
|
|
161
|
+
const refs = input.refs === "unknown" ? undefined : input.refs;
|
|
162
|
+
return writeOverlay({ ...input, refs });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeOverlay(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "all" }): McpOverlaySyncResult {
|
|
166
|
+
const overlayPath = mcpSlotPath(input.agentDir);
|
|
167
|
+
if (input.overridePath !== undefined && path.resolve(input.overridePath) !== overlayPath) {
|
|
168
|
+
return { overlayPath, managed: false, changed: false };
|
|
169
|
+
}
|
|
170
|
+
const projectTrusted = input.projectTrusted ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
|
|
171
|
+
const discovery = {
|
|
172
|
+
agentDir: input.agentDir,
|
|
173
|
+
cwd: input.cwd,
|
|
174
|
+
projectTrusted,
|
|
175
|
+
...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
|
|
176
|
+
};
|
|
177
|
+
// The slot is ours; a slot written by hand is adopted into the sidecar
|
|
178
|
+
// before the first overwrite, so a user's Pi-global servers survive.
|
|
179
|
+
const sourcePath = mcpSourcePath(input.agentDir);
|
|
180
|
+
adoptHandWrittenSlotSync(overlayPath, sourcePath);
|
|
181
|
+
const sourceDocument = readMcpDocumentSync(sourcePath);
|
|
182
|
+
const sourceNames = isRecord(sourceDocument?.mcpServers) ? Object.keys(sourceDocument.mcpServers) : [];
|
|
183
|
+
const otherNames = readAdapterOtherServerNamesSync(discovery);
|
|
184
|
+
const allowed =
|
|
185
|
+
input.refs === "all"
|
|
186
|
+
? "all"
|
|
187
|
+
: resolveAllowedServers(input.refs, [...new Set([...sourceNames, ...otherNames])].sort());
|
|
188
|
+
const content = serializeMcpOverlay(
|
|
189
|
+
buildMcpOverlay({
|
|
190
|
+
...(sourceDocument === undefined ? {} : { slotDocument: sourceDocument }),
|
|
191
|
+
otherServerNames: otherNames,
|
|
192
|
+
allowed,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
return { overlayPath, managed: true, changed: writeMcpOverlayIfChangedSync(overlayPath, content) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A slot file without the generated marker holds the user's own Pi-global
|
|
199
|
+
* servers (from before this package managed the slot, or from a manual
|
|
200
|
+
* edit). Move them — plus the slot's non-server keys — into the sidecar
|
|
201
|
+
* before the slot is overwritten. Stub entries are skipped: they describe
|
|
202
|
+
* servers owned by the adapter's other sources. */
|
|
203
|
+
function adoptHandWrittenSlotSync(slotPath: string, sourcePath: string): void {
|
|
204
|
+
const slot = readMcpDocumentSync(slotPath);
|
|
205
|
+
if (slot === undefined || isGeneratedOverlay(slot)) return;
|
|
206
|
+
const sidecar = readMcpDocumentSync(sourcePath) ?? {};
|
|
207
|
+
const sidecarServers = isRecord(sidecar.mcpServers) ? sidecar.mcpServers : {};
|
|
208
|
+
const servers: Record<string, unknown> = { ...sidecarServers };
|
|
209
|
+
const slotServers = isRecord(slot.mcpServers) ? slot.mcpServers : {};
|
|
210
|
+
for (const [name, definition] of Object.entries(slotServers)) {
|
|
211
|
+
if (isDisabledStub(definition)) continue;
|
|
212
|
+
servers[name] = definition;
|
|
213
|
+
}
|
|
214
|
+
const document: Record<string, unknown> = { ...sidecar };
|
|
215
|
+
delete document[MCP_GENERATED_MARKER];
|
|
216
|
+
for (const [key, value] of Object.entries(slot)) {
|
|
217
|
+
if (key === "mcpServers" || key === MCP_GENERATED_MARKER) continue;
|
|
218
|
+
document[key] = value;
|
|
219
|
+
}
|
|
220
|
+
document.mcpServers = Object.fromEntries(Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)));
|
|
221
|
+
writeMcpOverlayIfChangedSync(sourcePath, `${JSON.stringify(document, null, 2)}\n`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readActiveProfile(statePath: string): string | undefined {
|
|
225
|
+
const result = readJsonFileSync(statePath);
|
|
226
|
+
if (!result.ok || !isRecord(result.value)) return undefined;
|
|
227
|
+
const name = result.value.activeProfile;
|
|
228
|
+
return typeof name === "string" && name.length > 0 ? name : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The profile's raw `mcp` references; `"unknown"` when the profile or its
|
|
232
|
+
* catalog cannot be read (caller falls back to "no filtering"). */
|
|
233
|
+
function readProfileMcpRefsSync(
|
|
234
|
+
input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
|
|
235
|
+
): readonly string[] | undefined | "unknown" {
|
|
236
|
+
if (input.name === DEFAULT_PROFILE_NAME) return undefined;
|
|
237
|
+
const trusted = input.trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
|
|
238
|
+
const global = readCatalogSync(path.join(input.agentDir, "profiles.json"));
|
|
239
|
+
const project = trusted
|
|
240
|
+
? readCatalogSync(path.join(input.cwd, ".pi", "profiles.json"))
|
|
241
|
+
: new Map<string, ProfileDefinition>();
|
|
242
|
+
if (global === "error" || project === "error") return "unknown";
|
|
243
|
+
const definition = project.get(input.name) ?? global.get(input.name);
|
|
244
|
+
return definition === undefined ? "unknown" : definition.mcp;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
|
|
248
|
+
const result = readJsonFileSync(filePath);
|
|
249
|
+
if (!result.ok) return result.reason === "missing" ? new Map<string, ProfileDefinition>() : "error";
|
|
250
|
+
try {
|
|
251
|
+
return parseCatalogDocument(result.value, filePath);
|
|
252
|
+
} catch {
|
|
253
|
+
return "error";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readDefaultProjectTrustSync(agentDir: string): string | undefined {
|
|
258
|
+
const result = readJsonFileSync(path.join(agentDir, "settings.json"));
|
|
259
|
+
if (!result.ok || !isRecord(result.value)) return undefined;
|
|
260
|
+
const value = result.value.defaultProjectTrust;
|
|
261
|
+
return typeof value === "string" ? value : undefined;
|
|
262
|
+
}
|
package/src/startup-selection.ts
CHANGED
|
@@ -105,10 +105,7 @@ export async function resolveStartupProfile(input: {
|
|
|
105
105
|
const catalog = await ProfileCatalog.load(input.agentDir, {
|
|
106
106
|
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
107
107
|
});
|
|
108
|
-
const warnings: string[] =
|
|
109
|
-
...catalog.warnings,
|
|
110
|
-
...(await legacyRegistryWarnings(input)),
|
|
111
|
-
];
|
|
108
|
+
const warnings: string[] = await legacyRegistryWarnings(input);
|
|
112
109
|
|
|
113
110
|
if (input.requested !== undefined) {
|
|
114
111
|
if (catalog.resolve(input.requested) === undefined) {
|
|
@@ -53,7 +53,10 @@ export interface ActivationDeps {
|
|
|
53
53
|
|
|
54
54
|
export interface ActivationResult {
|
|
55
55
|
selection: ResolvedSelection;
|
|
56
|
-
|
|
56
|
+
/** The overlay this activation applied; absent when the runtime runs the
|
|
57
|
+
* profile exactly as it is declared. Callers surface it (the footer badge)
|
|
58
|
+
* instead of re-reading the state file. */
|
|
59
|
+
overlay?: RuntimeOverlay;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** Resolves a profile against the live resources without applying it.
|
|
@@ -65,7 +68,7 @@ export async function resolveProfileSelection(
|
|
|
65
68
|
name: string,
|
|
66
69
|
deps: Pick<ActivationDeps, "agentDir" | "cwd" | "projectTrusted" | "live" | "presetInputs">,
|
|
67
70
|
overlay?: RuntimeOverlay,
|
|
68
|
-
): Promise<
|
|
71
|
+
): Promise<ResolvedSelection> {
|
|
69
72
|
const catalog = await ProfileCatalog.load(deps.agentDir, {
|
|
70
73
|
projectDir: deps.projectTrusted ? deps.cwd : undefined,
|
|
71
74
|
});
|
|
@@ -76,8 +79,7 @@ export async function resolveProfileSelection(
|
|
|
76
79
|
);
|
|
77
80
|
}
|
|
78
81
|
const suppressTools = !deps.presetInputs.force && deps.presetInputs.explicit.tools;
|
|
79
|
-
|
|
80
|
-
return { selection, warnings: [...catalog.warnings] };
|
|
82
|
+
return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/** Activates a profile: resolve, validate, optionally persist, apply. */
|
|
@@ -87,7 +89,7 @@ export async function activateProfile(
|
|
|
87
89
|
options?: { overlay?: RuntimeOverlay | null; persist?: boolean },
|
|
88
90
|
): Promise<ActivationResult> {
|
|
89
91
|
const overlay = options?.overlay ?? undefined;
|
|
90
|
-
const
|
|
92
|
+
const selection = await resolveProfileSelection(name, deps, overlay);
|
|
91
93
|
const preset = decidePreset({ model: selection.model, ...deps.presetInputs });
|
|
92
94
|
|
|
93
95
|
// Validate before touching anything: the model preset is the only
|
|
@@ -109,5 +111,5 @@ export async function activateProfile(
|
|
|
109
111
|
throw new ActivationError(result.error ?? `profile "${name}" could not be applied`);
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
return { selection,
|
|
114
|
+
return { selection, ...(overlay === undefined ? {} : { overlay }) };
|
|
113
115
|
}
|