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/profile-resolver.ts
CHANGED
|
@@ -1,280 +1,274 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ProfileResolver:
|
|
3
|
-
*
|
|
2
|
+
* ProfileResolver: a pure function from profile + registries to an immutable
|
|
3
|
+
* ActivationPlan.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - Glob references (`*`, `?`) expand against the full registry at every
|
|
7
|
+
* resolution; zero matches is fine (new matches join on the next start) and
|
|
8
|
+
* is reported in the plan's `unmatched` list so typos are visible.
|
|
9
|
+
* - Literal references must exist; a missing literal fails activation.
|
|
10
|
+
* Extension references resolve through ResourceRegistry.select (ADR-0006):
|
|
11
|
+
* registry ID, installed package name/alias, loose-file stem, or an
|
|
12
|
+
* on-disk path — no pre-registration required.
|
|
13
|
+
* - `alwaysOn` resources and the recursive `dependsOn` closure join every
|
|
14
|
+
* plan; cycles and missing entries fail loudly (via ResourceRegistry).
|
|
15
|
+
* - Undeclared model/thinking/instructions never enter the plan, so Pi's
|
|
16
|
+
* current state stays untouched.
|
|
17
|
+
* - MCP references (`mcp`) expand against the server names the launcher
|
|
18
|
+
* discovered from pi-mcp-adapter's pi-native config files: literal misses
|
|
19
|
+
* fail loudly; globs expand to zero or more matches (consistent with
|
|
20
|
+
* skills/extensions). Adapter presence is checked separately by the
|
|
21
|
+
* launcher/extension (ADR-0002).
|
|
22
|
+
* - Tool globs expand against Pi's built-in tool names only: extension- and
|
|
23
|
+
* MCP-provided tool names are unknowable before spawn (extension code must
|
|
24
|
+
* not execute here), so literal tool names pass through unvalidated and
|
|
25
|
+
* glob matching for contributed tools happens when the extension applies
|
|
26
|
+
* the plan against Pi's actual registrations (tickets 05+).
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
|
-
import {
|
|
21
|
-
import type { ProfileModel, ProfileSource, ResolvedProfile } from "./profile-catalog.ts";
|
|
22
|
-
import type { RuntimeOverlay } from "./runtime-state-store.ts";
|
|
23
|
-
|
|
24
|
-
/** The live resource view a resolution runs against. */
|
|
25
|
-
export interface LiveResources {
|
|
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 }>;
|
|
31
|
-
toolNames: string[];
|
|
32
|
-
/** MCP adapter state: presence plus discovered server names. */
|
|
33
|
-
mcp: { adapterPresent: boolean; servers: string[] };
|
|
34
|
-
}
|
|
29
|
+
import { minimatch } from "minimatch";
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* hidden by omission). An empty array means "no skill is visible". */
|
|
39
|
-
export type SkillRefs = string[] | "all";
|
|
31
|
+
import type { DiscoveredExtensions } from "./extension-discovery.ts";
|
|
32
|
+
import type { ProfileDefinition, ProfileModel, ProfileSource, ResolvedProfile } from "./profile-catalog.ts";
|
|
40
33
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
/** Extracts a ProfileModel from flat definition keys, if declared. */
|
|
35
|
+
function extractModel(definition: ProfileDefinition): ProfileModel | undefined {
|
|
36
|
+
if (definition.defaultProvider === undefined || definition.defaultModel === undefined) return undefined;
|
|
37
|
+
return {
|
|
38
|
+
provider: definition.defaultProvider,
|
|
39
|
+
id: definition.defaultModel,
|
|
40
|
+
...(definition.defaultThinkingLevel !== undefined ? { thinkingLevel: definition.defaultThinkingLevel } : {}),
|
|
41
|
+
};
|
|
46
42
|
}
|
|
43
|
+
import type { RuntimeOverlay } from "./runtime-state-store.ts";
|
|
44
|
+
import type { SkillEntry } from "./skill-registry.ts";
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
export class ActivationError extends Error {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ActivationError";
|
|
50
|
+
}
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
toolsUnmatched: string[];
|
|
59
|
-
}
|
|
53
|
+
/** Pi's built-in tool names (pi 0.85.1 `allToolNames`; not exported by the
|
|
54
|
+
* SDK). The integration suite guards drift. Literal tool names pass through
|
|
55
|
+
* regardless — extension-provided tools are unknowable before spawn. */
|
|
56
|
+
export const BUILTIN_TOOL_NAMES = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"] as const;
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
const VALID_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
59
|
+
|
|
60
|
+
/** Immutable, fully resolved activation set. */
|
|
61
|
+
export interface ActivationPlan {
|
|
62
|
+
profile: string;
|
|
63
63
|
source: ProfileSource;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
skills
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
/**
|
|
64
|
+
/** "none" exposes everything Pi can discover (the default profile). */
|
|
65
|
+
filter: "none" | "selection";
|
|
66
|
+
/** Selected skills with their resolved SKILL.md paths. Empty for default. */
|
|
67
|
+
skills: SkillEntry[];
|
|
68
|
+
/** Selected extensions. Empty for default. */
|
|
69
|
+
extensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }>;
|
|
70
|
+
/** Expanded tool allowlist; undefined when the profile declares no tools. */
|
|
71
71
|
tools?: string[];
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
/** The raw tool references (globs included) for extension-side expansion
|
|
73
|
+
* against Pi's live tool registry, which includes extension-provided
|
|
74
|
+
* tools the pre-spawn expansion cannot know. Set iff `tools` is set. */
|
|
75
|
+
toolReferences?: string[];
|
|
76
|
+
/** Declared model; undefined leaves Pi's current model untouched. */
|
|
77
|
+
model?: ProfileModel;
|
|
78
|
+
/** Declared instructions; appended to Pi's system prompt by the extension. */
|
|
79
|
+
instructions?: string;
|
|
80
|
+
/** Expanded MCP server allowlist for pi-mcp-adapter coordination;
|
|
81
|
+
* undefined when the profile declares no `mcps` (no coordination). */
|
|
82
|
+
mcps?: string[];
|
|
83
|
+
/** Glob references (skills/extensions/MCP) that matched nothing this
|
|
84
|
+
* resolution — surfaced as warnings so zero-match typos are never silent.
|
|
85
|
+
* Tool globs are excluded: extension-contributed tools are unknowable
|
|
86
|
+
* before spawn, so a pre-spawn zero-match proves nothing. */
|
|
87
|
+
unmatched?: string[];
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
90
|
+
export interface ResolveInput {
|
|
91
|
+
profile: ResolvedProfile;
|
|
92
|
+
/** The full SkillRegistry result (not just selected skills). */
|
|
93
|
+
skills: SkillEntry[];
|
|
94
|
+
extensions: DiscoveredExtensions;
|
|
95
|
+
/**
|
|
96
|
+
* Validates a declared model (exists and is authenticated) against the
|
|
97
|
+
* user's real model/auth state. Returns an error message or undefined.
|
|
98
|
+
* The launcher always provides this; a declared model without a validator
|
|
99
|
+
* fails activation rather than silently skipping the check.
|
|
100
|
+
*/
|
|
101
|
+
validateModel?: (model: ProfileModel) => Promise<string | undefined>;
|
|
102
|
+
/**
|
|
103
|
+
* Server names discovered from pi-mcp-adapter's pi-native config files
|
|
104
|
+
* (see mcp-config.ts). Required when the profile declares `mcp`: without
|
|
105
|
+
* the discovered names a reference cannot be validated, so activation
|
|
106
|
+
* fails rather than passing references through unchecked.
|
|
107
|
+
*/
|
|
108
|
+
discoveredMcpServers?: string[];
|
|
109
|
+
/**
|
|
110
|
+
* The runtime overlay (ticket 06): temporary narrowing applied on top of
|
|
111
|
+
* the profile definition at every resolution. Overlay references must
|
|
112
|
+
* name resources the profile actually resolves (typos fail loudly), and
|
|
113
|
+
* `alwaysOn` extensions and their dependency chains cannot be disabled.
|
|
114
|
+
*/
|
|
115
|
+
overlay?: RuntimeOverlay;
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
function
|
|
110
|
-
|
|
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) };
|
|
118
|
+
function isGlob(reference: string): boolean {
|
|
119
|
+
return reference.includes("*") || reference.includes("?");
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const hits = live.servers.filter((server) => matchesReference(ref, server));
|
|
146
|
-
if (hits.length > 0) {
|
|
147
|
-
selected.push(...hits);
|
|
122
|
+
/** Expands one reference list against a named universe. Literal misses fail
|
|
123
|
+
* when `literalMustExist`; globs expand to zero or more matches, and a
|
|
124
|
+
* zero-match glob is reported through `onZeroMatch`. */
|
|
125
|
+
function expandReferences<T>(
|
|
126
|
+
references: string[],
|
|
127
|
+
universe: readonly T[],
|
|
128
|
+
nameOf: (item: T) => string,
|
|
129
|
+
kind: string,
|
|
130
|
+
options?: { literalMustExist?: boolean; onZeroMatch?: (reference: string) => void },
|
|
131
|
+
): T[] {
|
|
132
|
+
const selected = new Map<string, T>();
|
|
133
|
+
for (const reference of references) {
|
|
134
|
+
if (isGlob(reference)) {
|
|
135
|
+
let matched = 0;
|
|
136
|
+
for (const item of universe) {
|
|
137
|
+
if (minimatch(nameOf(item), reference)) {
|
|
138
|
+
selected.set(nameOf(item), item);
|
|
139
|
+
matched += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (matched === 0) options?.onZeroMatch?.(reference);
|
|
148
143
|
continue;
|
|
149
144
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
const item = universe.find((candidate) => nameOf(candidate) === reference);
|
|
146
|
+
if (item === undefined) {
|
|
147
|
+
if (options?.literalMustExist === false) {
|
|
148
|
+
// Pass-through (e.g. extension-provided tool names).
|
|
149
|
+
selected.set(reference, reference as T);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw new ActivationError(`unknown ${kind}: "${reference}" does not match any discovered ${kind}`);
|
|
153
|
+
}
|
|
154
|
+
selected.set(reference, item);
|
|
158
155
|
}
|
|
159
|
-
|
|
160
|
-
return { servers, warning };
|
|
156
|
+
return [...selected.values()];
|
|
161
157
|
}
|
|
162
158
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
continue;
|
|
159
|
+
/** The built-in default profile: everything Pi can discover, no filtering. */
|
|
160
|
+
export function defaultPlan(): ActivationPlan {
|
|
161
|
+
return { profile: "default", source: "builtin", filter: "none", skills: [], extensions: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function resolveProfile(input: ResolveInput): Promise<ActivationPlan> {
|
|
165
|
+
const { profile, skills, extensions, overlay } = input;
|
|
166
|
+
const definition: ProfileDefinition = profile.definition;
|
|
167
|
+
const unmatched: string[] = [];
|
|
168
|
+
|
|
169
|
+
let mcps: string[] | undefined;
|
|
170
|
+
if (definition.mcps !== undefined && definition.mcps.length > 0) {
|
|
171
|
+
if (input.discoveredMcpServers === undefined) {
|
|
172
|
+
throw new ActivationError(
|
|
173
|
+
`profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
|
|
174
|
+
);
|
|
180
175
|
}
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
|
|
177
|
+
onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
|
|
178
|
+
});
|
|
183
179
|
}
|
|
184
|
-
return { tools: [...new Set([...selected, ...pending])], pendingTools: [...new Set(pending)], warning };
|
|
185
|
-
}
|
|
186
180
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
191
|
-
* `suppressTools` drops the profile's tool selection entirely, so a
|
|
192
|
-
* CLI-declared `--tools`/`--exclude-tools` keeps owning the active set
|
|
193
|
-
* (the preference table in docs/product/prd.md). */
|
|
194
|
-
export function resolveSelection(input: {
|
|
195
|
-
profile: ResolvedProfile;
|
|
196
|
-
overlay?: RuntimeOverlay;
|
|
197
|
-
live: LiveResources;
|
|
198
|
-
suppressTools?: boolean;
|
|
199
|
-
}): ResolvedSelection {
|
|
200
|
-
const { profile, overlay, live } = input;
|
|
201
|
-
const definition = profile.definition;
|
|
181
|
+
let selectedSkills = expandReferences(definition.skills ?? [], skills, (skill) => skill.name, "skill", {
|
|
182
|
+
onZeroMatch: (reference) => unmatched.push(`skill:${reference}`),
|
|
183
|
+
});
|
|
202
184
|
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
185
|
+
// Extension references resolve directly against discovered extensions.
|
|
186
|
+
const selection = await extensions.select(definition.extensions ?? []);
|
|
187
|
+
for (const reference of selection.unmatched) unmatched.push(`extension:${reference}`);
|
|
188
|
+
let planExtensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }> = selection.entries.map(
|
|
189
|
+
(entry) => ({
|
|
190
|
+
id: entry.id,
|
|
191
|
+
entry: entry.entry,
|
|
192
|
+
...(entry.origin ? { origin: entry.origin } : {}),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
208
195
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
196
|
+
// --- overlay narrowing (ticket 06) ---
|
|
197
|
+
// Overlay references must name resources the profile actually resolves
|
|
198
|
+
// (typos fail loudly). Overlays can disable any resolved extension.
|
|
199
|
+
let toolReferences = definition.tools;
|
|
200
|
+
if (overlay !== undefined) {
|
|
201
|
+
if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
|
|
202
|
+
const active = new Set(selectedSkills.map((skill) => skill.name));
|
|
203
|
+
for (const name of overlay.disabledSkills) {
|
|
204
|
+
if (!active.has(name)) {
|
|
205
|
+
throw new ActivationError(`profile "${profile.name}": overlay disables unknown skill "${name}"`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const disabled = new Set(overlay.disabledSkills);
|
|
209
|
+
selectedSkills = selectedSkills.filter((skill) => !disabled.has(skill.name));
|
|
210
|
+
}
|
|
211
|
+
if (overlay.disabledExtensions !== undefined && overlay.disabledExtensions.length > 0) {
|
|
212
|
+
const activeIds = new Set(planExtensions.map((entry) => entry.id));
|
|
213
|
+
for (const id of overlay.disabledExtensions) {
|
|
214
|
+
if (!activeIds.has(id)) {
|
|
215
|
+
throw new ActivationError(`profile "${profile.name}": overlay disables unknown extension "${id}"`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const disabled = new Set(overlay.disabledExtensions);
|
|
219
|
+
planExtensions = planExtensions.filter((entry) => !disabled.has(entry.id));
|
|
220
|
+
}
|
|
221
|
+
if (overlay.disabledMcps !== undefined && overlay.disabledMcps.length > 0) {
|
|
222
|
+
const active = new Set(mcps ?? []);
|
|
223
|
+
for (const name of overlay.disabledMcps) {
|
|
224
|
+
if (!active.has(name)) {
|
|
225
|
+
throw new ActivationError(`profile "${profile.name}": overlay disables unknown MCP server "${name}"`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const disabled = new Set(overlay.disabledMcps);
|
|
229
|
+
mcps = (mcps ?? []).filter((name) => !disabled.has(name));
|
|
230
|
+
}
|
|
231
|
+
if (overlay.tools !== undefined) {
|
|
232
|
+
toolReferences = overlay.tools;
|
|
233
|
+
}
|
|
233
234
|
}
|
|
234
|
-
return selection;
|
|
235
|
-
}
|
|
236
235
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
): string[] {
|
|
243
|
-
const lines: string[] = [];
|
|
244
|
-
for (const unresolved of warning.skillsUnresolved) {
|
|
245
|
-
const hint =
|
|
246
|
-
unresolved.suggestions.length > 0
|
|
247
|
-
? ` — did you mean: ${unresolved.suggestions.map((name) => JSON.stringify(name)).join(", ")}?`
|
|
248
|
-
: "";
|
|
249
|
-
lines.push(
|
|
250
|
-
`profile "${profile}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
if (warning.skillsUnmatched.length > 0) {
|
|
254
|
-
lines.push(
|
|
255
|
-
`profile "${profile}": skill glob(s) ${warning.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
|
|
256
|
-
);
|
|
236
|
+
let tools: string[] | undefined;
|
|
237
|
+
if (toolReferences !== undefined) {
|
|
238
|
+
tools = expandReferences(toolReferences, BUILTIN_TOOL_NAMES, (name) => name, "tool", {
|
|
239
|
+
literalMustExist: false,
|
|
240
|
+
});
|
|
257
241
|
}
|
|
258
|
-
return lines;
|
|
259
|
-
}
|
|
260
242
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
`profile "${
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
243
|
+
let model: ProfileModel | undefined;
|
|
244
|
+
const declaredModel = extractModel(definition);
|
|
245
|
+
if (declaredModel !== undefined) {
|
|
246
|
+
const declared = declaredModel;
|
|
247
|
+
if (declared.thinkingLevel !== undefined && !VALID_THINKING_LEVELS.has(declared.thinkingLevel)) {
|
|
248
|
+
throw new ActivationError(
|
|
249
|
+
`profile "${profile.name}": invalid thinkingLevel ${JSON.stringify(declared.thinkingLevel)}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (input.validateModel === undefined) {
|
|
253
|
+
throw new ActivationError(`profile "${profile.name}" declares a model but no model validator is available`);
|
|
254
|
+
}
|
|
255
|
+
const error = await input.validateModel(declared);
|
|
256
|
+
if (error !== undefined) {
|
|
257
|
+
throw new ActivationError(`profile "${profile.name}": model ${declared.provider}/${declared.id}: ${error}`);
|
|
258
|
+
}
|
|
259
|
+
model = declared;
|
|
278
260
|
}
|
|
279
|
-
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
profile: profile.name,
|
|
264
|
+
source: profile.source,
|
|
265
|
+
filter: "selection",
|
|
266
|
+
skills: selectedSkills,
|
|
267
|
+
extensions: planExtensions.map((entry) => ({ id: entry.id, entry: entry.entry })),
|
|
268
|
+
...(tools !== undefined && toolReferences !== undefined ? { tools, toolReferences: [...toolReferences] } : {}),
|
|
269
|
+
...(model !== undefined ? { model } : {}),
|
|
270
|
+
...(definition.instructions !== undefined ? { instructions: definition.instructions } : {}),
|
|
271
|
+
...(mcps !== undefined ? { mcps } : {}),
|
|
272
|
+
...(unmatched.length > 0 ? { unmatched } : {}),
|
|
273
|
+
};
|
|
280
274
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project trust resolution for the launcher — pi-profile is the sole
|
|
3
|
+
* gatekeeper for project resources, because generated settings carry
|
|
4
|
+
* `defaultProjectTrust: "never"` and Pi therefore never auto-discovers them.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors Pi's own trust decision order (`resolveProjectTrusted`), with
|
|
7
|
+
* pi-profile's catalog/registry/state files added to the trust-requiring
|
|
8
|
+
* resource set (they are pi-profile's project attack surface; Pi doesn't
|
|
9
|
+
* know about them):
|
|
10
|
+
* 1. one-run `--approve` / `--no-approve` override (consumed by the
|
|
11
|
+
* launcher, never forwarded for named profiles)
|
|
12
|
+
* 2. a project with no trust-requiring resources at all is trusted —
|
|
13
|
+
* there is nothing project-scoped to gate
|
|
14
|
+
* 3. the stored decision in the real `trust.json` (nearest ancestor wins)
|
|
15
|
+
* 4. the user's `defaultProjectTrust` setting — but only "always" grants;
|
|
16
|
+
* "ask" cannot prompt here (the launcher has no trust UI), so it falls
|
|
17
|
+
* through to untrusted
|
|
18
|
+
* 5. otherwise untrusted: project catalogs, registries, resources, and
|
|
19
|
+
* state files are not read at all
|
|
20
|
+
*
|
|
21
|
+
* Known divergence from Pi: extension `project_trust` event handlers are not
|
|
22
|
+
* consulted — that would require executing extension code in the launcher,
|
|
23
|
+
* which pi-profile never does. In named-profile sessions the event is moot
|
|
24
|
+
* anyway (generated settings carry `defaultProjectTrust: "never"`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
|
|
32
|
+
export interface ProjectTrustInput {
|
|
33
|
+
cwd: string;
|
|
34
|
+
agentDir: string;
|
|
35
|
+
/** One-run override from --approve (true) / --no-approve (false). */
|
|
36
|
+
trustOverride?: boolean;
|
|
37
|
+
/** The user's real global `defaultProjectTrust` setting. */
|
|
38
|
+
userDefaultProjectTrust?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveProjectTrust(input: ProjectTrustInput): boolean {
|
|
42
|
+
if (input.trustOverride !== undefined) {
|
|
43
|
+
return input.trustOverride;
|
|
44
|
+
}
|
|
45
|
+
if (!hasTrustRequiringProjectResources(input.cwd) && !hasPiProfileProjectFiles(input.cwd)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
let stored = new ProjectTrustStore(input.agentDir).get(input.cwd);
|
|
49
|
+
if (stored === null) {
|
|
50
|
+
// Defensive fallback for raw/symlinked entries in trust.json
|
|
51
|
+
// that ProjectTrustStore's canonicalizePath missed.
|
|
52
|
+
try {
|
|
53
|
+
const trustFile = path.join(input.agentDir, "trust.json");
|
|
54
|
+
if (existsSync(trustFile)) {
|
|
55
|
+
const content = JSON.parse(readFileSync(trustFile, "utf8"));
|
|
56
|
+
if (typeof content === "object" && content !== null) {
|
|
57
|
+
let curr: string | undefined = path.resolve(input.cwd);
|
|
58
|
+
while (curr && curr !== path.dirname(curr)) {
|
|
59
|
+
if (content[curr] === true || content[curr] === false) {
|
|
60
|
+
stored = content[curr];
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
curr = path.dirname(curr);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
if (stored !== null) {
|
|
70
|
+
return stored;
|
|
71
|
+
}
|
|
72
|
+
return input.userDefaultProjectTrust === "always";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** pi-profile's own project files are trust-requiring even though Pi's
|
|
76
|
+
* native list doesn't know them: a committed catalog/state file would
|
|
77
|
+
* otherwise inject profile definitions (and instructions) unguarded. */
|
|
78
|
+
function hasPiProfileProjectFiles(cwd: string): boolean {
|
|
79
|
+
return ["profiles.json", "pi-profile-state.json"].some((name) =>
|
|
80
|
+
existsSync(path.join(cwd, ".pi", name)),
|
|
81
|
+
);
|
|
82
|
+
}
|