pi-profile-switch 0.3.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- package/src/switching/apply-profile.ts +0 -131
package/src/skill-selection.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SkillSelection: the skills visibility filter (ADR-0007).
|
|
3
|
-
*
|
|
4
|
-
* A profile's `skills` references do not load or unload anything. Pi loads
|
|
5
|
-
* every installed skill; `/skill:name` stays available to the user for all
|
|
6
|
-
* of them. What the profile controls is what the MODEL sees: the
|
|
7
|
-
* `<available_skills>` section of the system prompt.
|
|
8
|
-
*
|
|
9
|
-
* The filter recomputes that section with Pi's own exported formatter over
|
|
10
|
-
* the profile's selection and replaces it inside the chained system prompt.
|
|
11
|
-
* Because the original section was produced by the same formatter from the
|
|
12
|
-
* same skill array (`BuildSystemPromptOptions.skills`), the recomputation is
|
|
13
|
-
* byte-identical; when it is not found the turn proceeds unfiltered and the
|
|
14
|
-
* caller reports a warning.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { formatSkillsForPrompt, type BuildSystemPromptOptions, type Skill } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
|
|
19
|
-
import { matchesReference } from "./name-matching.ts";
|
|
20
|
-
import type { SkillsFilter } from "./profile-resolver.ts";
|
|
21
|
-
|
|
22
|
-
export type SkillsFilterOutcome =
|
|
23
|
-
/** The prompt's skills section was replaced with the filtered one. */
|
|
24
|
-
| "filtered"
|
|
25
|
-
/** No filter applies (default profile without an overlay). */
|
|
26
|
-
| "no-filter"
|
|
27
|
-
/** Neither `read` nor `bash` is active, so Pi emitted no skills section. */
|
|
28
|
-
| "no-read-tool"
|
|
29
|
-
/** The expected section is absent from the prompt; left unfiltered. */
|
|
30
|
-
| "section-missing";
|
|
31
|
-
|
|
32
|
-
/** The skills the model may see. `undefined` means "no filtering". */
|
|
33
|
-
export function visibleSkills(skills: Skill[], filter: SkillsFilter | undefined): Skill[] | undefined {
|
|
34
|
-
if (filter === undefined) return undefined;
|
|
35
|
-
const visible = new Set(visibleSkillNames(skills, filter) ?? []);
|
|
36
|
-
return skills.filter((skill) => visible.has(skill.name));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** The visible skill names, for status reporting and prompt filtering.
|
|
40
|
-
* `undefined` means "no filtering". */
|
|
41
|
-
export function visibleSkillNames(
|
|
42
|
-
all: Array<{ name: string }>,
|
|
43
|
-
filter: SkillsFilter | undefined,
|
|
44
|
-
): string[] | undefined {
|
|
45
|
-
if (filter === undefined) return undefined;
|
|
46
|
-
const refs = filter.refs;
|
|
47
|
-
const base = refs === "all" ? all : all.filter((skill) => refs.some((ref) => matchesReference(ref, skill.name)));
|
|
48
|
-
return base
|
|
49
|
-
.filter((skill) => !filter.disabled.some((ref) => matchesReference(ref, skill.name)))
|
|
50
|
-
.map((skill) => skill.name);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Replaces the system prompt's skills section with the filtered one. */
|
|
54
|
-
export function applySkillsFilter(input: {
|
|
55
|
-
systemPrompt: string;
|
|
56
|
-
options: BuildSystemPromptOptions;
|
|
57
|
-
filter: SkillsFilter | undefined;
|
|
58
|
-
}): { systemPrompt: string; outcome: SkillsFilterOutcome } {
|
|
59
|
-
const { systemPrompt, options, filter } = input;
|
|
60
|
-
if (filter === undefined) {
|
|
61
|
-
return { systemPrompt, outcome: "no-filter" };
|
|
62
|
-
}
|
|
63
|
-
// Pi emits the section only when a skill-reading tool is active; without
|
|
64
|
-
// one there is nothing to replace.
|
|
65
|
-
const fileReadTool = (["read", "bash"] as const).find((tool) => options.selectedTools?.includes(tool));
|
|
66
|
-
if (fileReadTool === undefined) {
|
|
67
|
-
return { systemPrompt, outcome: "no-read-tool" };
|
|
68
|
-
}
|
|
69
|
-
const all = options.skills ?? [];
|
|
70
|
-
const original = formatSkillsForPrompt(all, fileReadTool);
|
|
71
|
-
if (original.length === 0 || !systemPrompt.includes(original)) {
|
|
72
|
-
return { systemPrompt, outcome: "section-missing" };
|
|
73
|
-
}
|
|
74
|
-
const filtered = formatSkillsForPrompt(visibleSkills(all, filter) ?? [], fileReadTool);
|
|
75
|
-
return { systemPrompt: systemPrompt.replace(original, filtered), outcome: "filtered" };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The per-profile instructions block appended after the (filtered) prompt. */
|
|
79
|
-
export function formatInstructionsBlock(profile: string, instructions: string): string {
|
|
80
|
-
return `\n\n<profile_instructions name="${profile}">\n${instructions}\n</profile_instructions>`;
|
|
81
|
-
}
|
package/src/startup-mcp-scope.ts
DELETED
|
@@ -1,271 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* StartupSelection: how a session decides which profile to activate.
|
|
3
|
-
*
|
|
4
|
-
* - `--profile <name>` (a flag this extension registers) is an explicit,
|
|
5
|
-
* one-run selection: it is never written back to runtime state.
|
|
6
|
-
* - Without the flag the saved selection applies: the trusted project's
|
|
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.
|
|
14
|
-
* - A saved selection that no longer resolves falls back to `default` with
|
|
15
|
-
* a warning — restore is a convenience, not a commitment. An explicit
|
|
16
|
-
* flag value never falls back: an unknown name is a loud error.
|
|
17
|
-
*
|
|
18
|
-
* Explicit CLI declarations (`--model`, `--thinking`, `--tools`,
|
|
19
|
-
* `--exclude-tools`) outrank the profile's matching declaration. They are
|
|
20
|
-
* detected by re-parsing this process's argv with Pi's own exported parser,
|
|
21
|
-
* so the aliases and value forms stay Pi's rather than a hand-rolled copy.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { parseArgs } from "@earendil-works/pi-coding-agent";
|
|
25
|
-
import { stat } from "node:fs/promises";
|
|
26
|
-
import path from "node:path";
|
|
27
|
-
|
|
28
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
-
|
|
30
|
-
import { DEFAULT_PROFILE_NAME, ProfileCatalog } from "./profile-catalog.ts";
|
|
31
|
-
import { RuntimeStateStore } from "./runtime-state-store.ts";
|
|
32
|
-
|
|
33
|
-
/** Which settings the user stated on the command line this run. */
|
|
34
|
-
export interface ExplicitDeclarations {
|
|
35
|
-
model: boolean;
|
|
36
|
-
thinking: boolean;
|
|
37
|
-
tools: boolean;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const PROFILE_FLAG = "profile";
|
|
41
|
-
|
|
42
|
-
/** Registers the `--profile <name>` CLI flag. Pi has no flag of that name;
|
|
43
|
-
* unknown flags are collected by its parser and validated against flags
|
|
44
|
-
* registered by loaded extensions. */
|
|
45
|
-
export function registerProfileFlag(pi: ExtensionAPI): void {
|
|
46
|
-
pi.registerFlag(PROFILE_FLAG, {
|
|
47
|
-
type: "string",
|
|
48
|
-
description: "pi-profile-switch: activate a profile for this run (not saved)",
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The `--profile` value, when the user passed one. */
|
|
53
|
-
export function readProfileFlag(pi: ExtensionAPI): string | undefined {
|
|
54
|
-
const value = pi.getFlag(PROFILE_FLAG);
|
|
55
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Detects explicit CLI declarations by re-parsing argv with Pi's parser. */
|
|
59
|
-
export function detectExplicitDeclarations(argv: string[]): ExplicitDeclarations {
|
|
60
|
-
const parsed = parseArgs(argv);
|
|
61
|
-
return {
|
|
62
|
-
model: parsed.model !== undefined,
|
|
63
|
-
thinking: parsed.thinking !== undefined,
|
|
64
|
-
tools: parsed.tools !== undefined || parsed.excludeTools !== undefined,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface StartupProfile {
|
|
69
|
-
name: string;
|
|
70
|
-
/** Non-fatal notices (e.g. a saved profile that no longer exists). */
|
|
71
|
-
warnings: string[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function exists(filePath: string): Promise<boolean> {
|
|
75
|
-
try {
|
|
76
|
-
await stat(filePath);
|
|
77
|
-
return true;
|
|
78
|
-
} catch {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Files the retired extension registry lived in; they are no longer read
|
|
84
|
-
* (ADR-0007), and a leftover file is called out once per startup. */
|
|
85
|
-
async function legacyRegistryWarnings(input: {
|
|
86
|
-
agentDir: string;
|
|
87
|
-
cwd: string;
|
|
88
|
-
projectTrusted: boolean;
|
|
89
|
-
}): Promise<string[]> {
|
|
90
|
-
const candidates = [path.join(input.agentDir, "resources.json")];
|
|
91
|
-
if (input.projectTrusted) candidates.push(path.join(input.cwd, ".pi", "resources.json"));
|
|
92
|
-
const warnings: string[] = [];
|
|
93
|
-
for (const filePath of candidates) {
|
|
94
|
-
if (await exists(filePath)) {
|
|
95
|
-
warnings.push(
|
|
96
|
-
`${filePath} is no longer read (ADR-0007): extensions load natively in every profile — manage them with pi install`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return warnings;
|
|
101
|
-
}
|
|
102
|
-
|
|
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. */
|
|
143
|
-
export async function resolveStartupProfile(input: {
|
|
144
|
-
agentDir: string;
|
|
145
|
-
cwd: string;
|
|
146
|
-
projectTrusted: boolean;
|
|
147
|
-
requested?: string;
|
|
148
|
-
continuation?: string;
|
|
149
|
-
}): Promise<StartupProfile> {
|
|
150
|
-
const catalog = await ProfileCatalog.load(input.agentDir, {
|
|
151
|
-
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
152
|
-
});
|
|
153
|
-
const warnings: string[] = await legacyRegistryWarnings(input);
|
|
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
|
-
|
|
167
|
-
if (input.requested !== undefined) {
|
|
168
|
-
if (catalog.resolve(input.requested) === undefined) {
|
|
169
|
-
throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
|
|
170
|
-
}
|
|
171
|
-
return { name: input.requested, warnings };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
let saved: string | undefined;
|
|
175
|
-
if (input.projectTrusted) {
|
|
176
|
-
saved = (await new RuntimeStateStore(path.join(input.cwd, ".pi")).read()).activeProfile;
|
|
177
|
-
}
|
|
178
|
-
saved ??= (await new RuntimeStateStore(input.agentDir).read()).activeProfile;
|
|
179
|
-
|
|
180
|
-
if (saved === undefined || saved === DEFAULT_PROFILE_NAME) {
|
|
181
|
-
return { name: DEFAULT_PROFILE_NAME, warnings };
|
|
182
|
-
}
|
|
183
|
-
if (catalog.resolve(saved) === undefined) {
|
|
184
|
-
warnings.push(
|
|
185
|
-
`saved profile ${JSON.stringify(saved)} no longer exists — starting with "${DEFAULT_PROFILE_NAME}"; ` +
|
|
186
|
-
`available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
|
|
187
|
-
);
|
|
188
|
-
return { name: DEFAULT_PROFILE_NAME, warnings };
|
|
189
|
-
}
|
|
190
|
-
return { name: saved, warnings };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export class UnknownProfileError extends Error {
|
|
194
|
-
readonly requested: string;
|
|
195
|
-
|
|
196
|
-
constructor(requested: string, available: string[]) {
|
|
197
|
-
super(`unknown profile ${JSON.stringify(requested)} — available: [${available.join(", ")}]`);
|
|
198
|
-
this.name = "UnknownProfileError";
|
|
199
|
-
this.requested = requested;
|
|
200
|
-
}
|
|
201
|
-
}
|