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.
Files changed (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -1,280 +1,274 @@
1
1
  /**
2
- * ProfileResolver: turns one profile definition plus an optional runtime
3
- * overlay into an immutable selection, resolved against Pi's LIVE resources.
2
+ * ProfileResolver: a pure function from profile + registries to an immutable
3
+ * ActivationPlan.
4
4
  *
5
- * ADR-0007 semantics:
6
- * - `skills` resolves to a visibility filter (see skill-selection.ts), not
7
- * to loaded resources: every skill stays loaded and user-invocable.
8
- * - `mcps` resolves to a runtime server allowlist; a declared MCP intent that
9
- * cannot be satisfied (adapter absent, literal server unknown) fails the
10
- * activation before anything is applied.
11
- * - `tools` resolves to an active tool set; literals the live registry does
12
- * not provide yet become `pendingTools` and are retried, because MCP and
13
- * extension tools register after session start.
14
- * - `model` and `instructions` pass through unchanged; applying them is the
15
- * caller's job.
16
- *
17
- * The resolver is a pure function: same inputs, same selection, no I/O.
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 { isGlob, matchesReference, suggestNames } from "./name-matching.ts";
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
- /** The skill references of a visibility filter: literals/globs, or `"all"`
37
- * when the profile declares none and only the overlay narrows (nothing is
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
- /** The skill visibility filter handed to the prompt builder each turn. */
42
- export interface SkillsFilter {
43
- refs: SkillRefs;
44
- /** Skill name references removed from the visible set. */
45
- disabled: string[];
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
- /** A literal reference no live resource provides, with near-name hints. */
49
- export interface UnresolvedRef {
50
- reference: string;
51
- suggestions: string[];
46
+ export class ActivationError extends Error {
47
+ constructor(message: string) {
48
+ super(message);
49
+ this.name = "ActivationError";
50
+ }
52
51
  }
53
52
 
54
- export interface SelectionWarnings {
55
- skillsUnresolved: UnresolvedRef[];
56
- skillsUnmatched: string[];
57
- mcpUnmatched: string[];
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
- export interface ResolvedSelection {
62
- name: string;
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
- instructions?: string;
65
- model?: ProfileModel;
66
- /** Undefined means no visibility filtering (the whole loaded set). */
67
- skills?: SkillsFilter;
68
- /** Runtime MCP allowlist; undefined means publish nothing. */
69
- mcp?: string[];
70
- /** Active tool names; undefined means leave Pi's active set untouched. */
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
- /** Tool literals the live registry does not provide yet. */
73
- pendingTools: string[];
74
- warnings: SelectionWarnings;
75
- }
76
-
77
- export class SelectionError extends Error {
78
- constructor(message: string) {
79
- super(message);
80
- this.name = "SelectionError";
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
- /** 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[],
92
- live: LiveResources["skills"],
93
- ): Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> {
94
- const warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> = {
95
- skillsUnresolved: [],
96
- skillsUnmatched: [],
97
- };
98
- if (live === undefined) return warning;
99
- for (const ref of refs) {
100
- const hits = live.filter((skill) => matchesReference(ref, skill.name));
101
- if (hits.length === 0) {
102
- if (isGlob(ref)) warning.skillsUnmatched.push(ref);
103
- else warning.skillsUnresolved.push({ reference: ref, suggestions: suggestNames(ref, live.map((s) => s.name)) });
104
- }
105
- }
106
- return warning;
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 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) };
118
+ function isGlob(reference: string): boolean {
119
+ return reference.includes("*") || reference.includes("?");
122
120
  }
123
121
 
124
- function resolveMcp(
125
- declared: string[] | undefined,
126
- disabled: string[],
127
- live: LiveResources["mcp"],
128
- profileName: string,
129
- ): { servers?: string[]; warning: Pick<SelectionWarnings, "mcpUnmatched"> } {
130
- const warning = { mcpUnmatched: [] as string[] };
131
- if (declared === undefined) {
132
- // No declared intent: only an overlay narrowing publishes an allowlist.
133
- if (disabled.length === 0 || !live.adapterPresent) return { warning };
134
- return { servers: live.servers.filter((server) => !disabled.includes(server)), warning };
135
- }
136
- if (!live.adapterPresent) {
137
- throw new SelectionError(
138
- `profile "${profileName}" declares MCP servers but pi-mcp-adapter is not active in this session — ` +
139
- `install the adapter or remove the "mcps" declaration`,
140
- );
141
- }
142
- const selected: string[] = [];
143
- const missing: string[] = [];
144
- for (const ref of declared) {
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
- if (isGlob(ref)) warning.mcpUnmatched.push(ref);
151
- else missing.push(ref);
152
- }
153
- if (missing.length > 0) {
154
- throw new SelectionError(
155
- `profile "${profileName}": unknown MCP server ${missing.map((name) => JSON.stringify(name)).join(", ")} — ` +
156
- `adapter discovered: [${live.servers.join(", ")}]`,
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
- const servers = [...new Set(selected)].filter((server) => !disabled.includes(server));
160
- return { servers, warning };
156
+ return [...selected.values()];
161
157
  }
162
158
 
163
- function resolveTools(
164
- refs: string[] | undefined,
165
- live: LiveResources["toolNames"],
166
- ): {
167
- tools?: string[];
168
- pendingTools: string[];
169
- warning: Pick<SelectionWarnings, "toolsUnmatched">;
170
- } {
171
- const warning = { toolsUnmatched: [] as string[] };
172
- if (refs === undefined) return { pendingTools: [], warning };
173
- const selected: string[] = [];
174
- const pending: string[] = [];
175
- for (const ref of refs) {
176
- const hits = live.filter((name) => matchesReference(ref, name));
177
- if (hits.length > 0) {
178
- selected.push(...hits);
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
- if (isGlob(ref)) warning.toolsUnmatched.push(ref);
182
- else pending.push(ref);
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
- /** Resolves one profile (plus overlay) against the live resources. Throws
188
- * SelectionError when the profile's declared MCP intent cannot be
189
- * satisfied; the caller applies nothing in that case.
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
- const skills = resolveSkills(definition.skills, overlay?.disabledSkills ?? [], live.skills);
204
- const mcp = resolveMcp(definition.mcps, overlay?.disabledMcp ?? [], live.mcp, profile.name);
205
- const tools = input.suppressTools === true
206
- ? { pendingTools: [], warning: { toolsUnmatched: [] } }
207
- : resolveTools(overlay?.tools ?? definition.tools, live.toolNames);
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
- const selection: ResolvedSelection = {
210
- name: profile.name,
211
- source: profile.source,
212
- pendingTools: tools.pendingTools,
213
- warnings: {
214
- ...skills.warning,
215
- ...mcp.warning,
216
- ...tools.warning,
217
- },
218
- };
219
- if (definition.instructions !== undefined && definition.instructions.length > 0) {
220
- selection.instructions = definition.instructions;
221
- }
222
- if (definition.model !== undefined) {
223
- selection.model = definition.model;
224
- }
225
- if (skills.filter !== undefined) {
226
- selection.skills = skills.filter;
227
- }
228
- if (mcp.servers !== undefined) {
229
- selection.mcp = mcp.servers;
230
- }
231
- if (tools.tools !== undefined) {
232
- selection.tools = tools.tools;
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
- /** 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[] {
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
- /** User-facing warning lines for one resolved selection. */
262
- export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
263
- const lines: string[] = formatSkillWarnings(selection.name, selection.warnings);
264
- if (selection.warnings.mcpUnmatched.length > 0) {
265
- lines.push(
266
- `profile "${selection.name}": MCP glob(s) ${selection.warnings.mcpUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
267
- );
268
- }
269
- if (selection.warnings.toolsUnmatched.length > 0) {
270
- lines.push(
271
- `profile "${selection.name}": tool glob(s) ${selection.warnings.toolsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
272
- );
273
- }
274
- if (selection.pendingTools.length > 0) {
275
- lines.push(
276
- `profile "${selection.name}": tool(s) ${selection.pendingTools.map((name) => JSON.stringify(name)).join(", ")} are not registered yet — applied when they appear`,
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
- return lines;
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
+ }