pi-profile-switch 0.9.2 → 0.11.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.
@@ -67,6 +67,10 @@ export interface ActivationPlan {
67
67
  extensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }>;
68
68
  /** Expanded tool allowlist; undefined when the profile declares no tools. */
69
69
  tools?: string[];
70
+ /** Overlay tool disable entries (names or globs, verbatim); undefined
71
+ * when the overlay disables no tools. The session-start application
72
+ * subtracts their live matches from the base expansion. */
73
+ disabledTools?: string[];
70
74
  /** The raw tool references (globs included) for extension-side expansion
71
75
  * against Pi's live tool registry, which includes extension-provided
72
76
  * tools the pre-spawn expansion cannot know. Set iff `tools` is set. */
@@ -114,12 +118,54 @@ export interface ResolveInput {
114
118
  * any resolved reference may be narrowed or disabled.
115
119
  */
116
120
  overlay?: RuntimeOverlay;
121
+ /**
122
+ * Pi's live tool names (`pi.getAllTools()`), supplied by the in-session
123
+ * switch path. Required when the overlay disables tools on a profile
124
+ * without declared `tools` — the live registry is then the base set the
125
+ * entries disable from. The launcher never passes an overlay, so it never
126
+ * needs this.
127
+ */
128
+ liveToolNames?: string[];
117
129
  }
118
130
 
119
131
  function isGlob(reference: string): boolean {
120
132
  return reference.includes("*") || reference.includes("?");
121
133
  }
122
134
 
135
+ /** Expands overlay disable entries against the names the profile resolved.
136
+ * Entries are names or globs stored as written, re-expanded at every
137
+ * resolution with the same matcher as profile references (ADR-0009 tiering):
138
+ * an unmatched literal fails and identifies the entry; a zero-match glob is
139
+ * reported through `onZeroMatch` instead of failing. Returns the resolved
140
+ * names to disable. */
141
+ function expandDisableEntries(
142
+ entries: string[],
143
+ activeNames: readonly string[],
144
+ kind: string,
145
+ profileName: string,
146
+ onZeroMatch: (entry: string) => void,
147
+ ): Set<string> {
148
+ const disabled = new Set<string>();
149
+ for (const entry of entries) {
150
+ if (isGlob(entry)) {
151
+ let matched = 0;
152
+ for (const name of activeNames) {
153
+ if (minimatch(name, entry)) {
154
+ disabled.add(name);
155
+ matched += 1;
156
+ }
157
+ }
158
+ if (matched === 0) onZeroMatch(entry);
159
+ continue;
160
+ }
161
+ if (!activeNames.includes(entry)) {
162
+ throw new ActivationError(`profile "${profileName}": overlay disables unknown ${kind} "${entry}"`);
163
+ }
164
+ disabled.add(entry);
165
+ }
166
+ return disabled;
167
+ }
168
+
123
169
  /** Expands one reference list against a named universe. Literal misses fail
124
170
  * when `literalMustExist`; globs expand to zero or more matches, and a
125
171
  * zero-match glob is reported through `onZeroMatch`. */
@@ -195,42 +241,48 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
195
241
  );
196
242
 
197
243
  // --- overlay narrowing (ticket 06) ---
198
- // Overlay references must name resources the profile actually resolves
199
- // (typos fail loudly). Overlays can disable any resolved extension.
200
- let toolReferences = definition.tools;
244
+ // Overlay disable entries are names or globs stored as written and
245
+ // re-expanded at every resolution; unmatched literals fail and identify
246
+ // the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
247
+ // Overlays can disable any resolved extension.
248
+ const toolReferences = definition.tools;
201
249
  if (overlay !== undefined) {
202
250
  if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
203
- const active = new Set(selectedSkills.map((skill) => skill.name));
204
- for (const name of overlay.disabledSkills) {
205
- if (!active.has(name)) {
206
- throw new ActivationError(`profile "${profile.name}": overlay disables unknown skill "${name}"`);
207
- }
208
- }
209
- const disabled = new Set(overlay.disabledSkills);
251
+ const disabled = expandDisableEntries(
252
+ overlay.disabledSkills,
253
+ selectedSkills.map((skill) => skill.name),
254
+ "skill",
255
+ profile.name,
256
+ (entry) => unmatched.push(`overlay skill:${entry}`),
257
+ );
210
258
  selectedSkills = selectedSkills.filter((skill) => !disabled.has(skill.name));
211
259
  }
212
260
  if (overlay.disabledExtensions !== undefined && overlay.disabledExtensions.length > 0) {
213
- const activeIds = new Set(planExtensions.map((entry) => entry.id));
214
- for (const id of overlay.disabledExtensions) {
215
- if (!activeIds.has(id)) {
216
- throw new ActivationError(`profile "${profile.name}": overlay disables unknown extension "${id}"`);
217
- }
218
- }
219
- const disabled = new Set(overlay.disabledExtensions);
261
+ const disabled = expandDisableEntries(
262
+ overlay.disabledExtensions,
263
+ planExtensions.map((entry) => entry.id),
264
+ "extension",
265
+ profile.name,
266
+ (entry) => unmatched.push(`overlay extension:${entry}`),
267
+ );
220
268
  planExtensions = planExtensions.filter((entry) => !disabled.has(entry.id));
221
269
  }
222
270
  if (overlay.disabledMcps !== undefined && overlay.disabledMcps.length > 0) {
223
- const active = new Set(mcps ?? []);
224
- for (const name of overlay.disabledMcps) {
225
- if (!active.has(name)) {
226
- throw new ActivationError(`profile "${profile.name}": overlay disables unknown MCP server "${name}"`);
227
- }
271
+ const active = mcps ?? [];
272
+ const disabled = expandDisableEntries(
273
+ overlay.disabledMcps,
274
+ active,
275
+ "MCP server",
276
+ profile.name,
277
+ (entry) => unmatched.push(`overlay mcp:${entry}`),
278
+ );
279
+ // A profile that declared no `mcps` has no MCP restriction; overlay
280
+ // narrowing must not turn that into an empty whitelist. Literals
281
+ // still fail and zero-match globs still warn — the expansion above
282
+ // runs either way — only the reassignment is guarded.
283
+ if (mcps !== undefined) {
284
+ mcps = active.filter((name) => !disabled.has(name));
228
285
  }
229
- const disabled = new Set(overlay.disabledMcps);
230
- mcps = (mcps ?? []).filter((name) => !disabled.has(name));
231
- }
232
- if (overlay.tools !== undefined) {
233
- toolReferences = overlay.tools;
234
286
  }
235
287
  }
236
288
 
@@ -241,6 +293,40 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
241
293
  });
242
294
  }
243
295
 
296
+ // Tool disable entries join the uniform grammar: the base set is the
297
+ // profile's resolved tool references when declared (expanded against the
298
+ // live registry, which the in-session switch path always supplies) and
299
+ // the live registry itself when the profile declares no tools. The
300
+ // plan carries the entries verbatim; session-start application subtracts
301
+ // their live matches from the base expansion at that moment.
302
+ let disabledTools: string[] | undefined;
303
+ if (overlay?.disabledTools !== undefined && overlay.disabledTools.length > 0) {
304
+ if (input.liveToolNames === undefined) {
305
+ throw new ActivationError(
306
+ `profile "${profile.name}": overlay disables tools but no live tool registry is available`,
307
+ );
308
+ }
309
+ const base =
310
+ toolReferences !== undefined
311
+ ? expandReferences(toolReferences, input.liveToolNames, (name) => name, "tool", {
312
+ literalMustExist: false,
313
+ })
314
+ : input.liveToolNames;
315
+ const disabled = expandDisableEntries(
316
+ overlay.disabledTools,
317
+ base,
318
+ "tool",
319
+ profile.name,
320
+ (entry) => unmatched.push(`overlay tool:${entry}`),
321
+ );
322
+ disabledTools = [...overlay.disabledTools];
323
+ // The pre-computed boot baseline additionally subtracts the disabled
324
+ // matches (a no-op for tools only the live registry knows).
325
+ if (tools !== undefined) {
326
+ tools = tools.filter((name) => !disabled.has(name));
327
+ }
328
+ }
329
+
244
330
  let model: ProfileModel | undefined;
245
331
  const declaredModel = extractModel(definition);
246
332
  if (declaredModel !== undefined) {
@@ -267,6 +353,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
267
353
  skills: selectedSkills,
268
354
  extensions: planExtensions.map((entry) => ({ id: entry.id, entry: entry.entry })),
269
355
  ...(tools !== undefined && toolReferences !== undefined ? { tools, toolReferences: [...toolReferences] } : {}),
356
+ ...(disabledTools !== undefined ? { disabledTools } : {}),
270
357
  ...(model !== undefined ? { model } : {}),
271
358
  ...(definition.instructions !== undefined ? { instructions: definition.instructions } : {}),
272
359
  ...(mcps !== undefined ? { mcps } : {}),
@@ -32,14 +32,13 @@ export interface RuntimeOverlay {
32
32
  disabledSkills?: string[];
33
33
  disabledExtensions?: string[];
34
34
  disabledMcps?: string[];
35
- /** Replaces the profile's tool references when set. */
36
- tools?: string[];
35
+ disabledTools?: string[];
37
36
  }
38
37
 
39
38
  function parseOverlay(value: unknown): RuntimeOverlay | undefined {
40
39
  if (!isRecord(value)) return undefined;
41
40
  const overlay: RuntimeOverlay = {};
42
- for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "tools"] as const) {
41
+ for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "disabledTools"] as const) {
43
42
  const list = value[key];
44
43
  if (Array.isArray(list) && list.every((entry) => typeof entry === "string")) {
45
44
  overlay[key] = list;
@@ -77,7 +76,7 @@ export class RuntimeStateStore {
77
76
  }
78
77
 
79
78
  /** Read-modify-write merge. A field set to `undefined` is deleted; absent
80
- * fields keep their stored value. Used by the switch/customize paths so
79
+ * fields keep their stored value. Used by the switch/overlay paths so
81
80
  * one concern (selection, anchor, overlay) never clobbers another. */
82
81
  async update(patch: Partial<RuntimeState>): Promise<RuntimeState> {
83
82
  const current = await this.read();
@@ -401,6 +401,7 @@ export async function writeRuntimeFiles(
401
401
  agentDir: options.agentDir,
402
402
  ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
403
403
  ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
404
+ ...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
404
405
  ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
405
406
  // The resolved sets feed /profile status (absolute paths) and the
406
407
  // glob-delta diff against the previous activation.