@mrclrchtr/supi-extras 8.0.1 → 9.0.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.
@@ -9,7 +9,11 @@ import {
9
9
  truncateToWidth,
10
10
  visibleWidth,
11
11
  } from "@earendil-works/pi-tui";
12
- import type { SessionCapabilitySkill, SessionCapabilityState } from "@mrclrchtr/supi-core/session";
12
+ import {
13
+ isSessionCapabilityProfileModified,
14
+ type SessionCapabilitySkill,
15
+ type SessionCapabilityState,
16
+ } from "@mrclrchtr/supi-core/session";
13
17
  import {
14
18
  createSelectListMenu,
15
19
  formatTuiTitleLine,
@@ -19,13 +23,20 @@ import {
19
23
  } from "@mrclrchtr/supi-core/tui";
20
24
  import {
21
25
  buildCapabilityRows,
26
+ type CapabilityProfileAction,
22
27
  type CapabilityRow,
23
28
  type CapabilitySection,
24
29
  type CapabilityToolItem,
25
30
  } from "./session-capabilities-ui-rows.ts";
26
31
 
27
- export type { CapabilityToolItem } from "./session-capabilities-ui-rows.ts";
32
+ export type {
33
+ CapabilityProfileAction,
34
+ CapabilityRow,
35
+ CapabilitySection,
36
+ CapabilityToolItem,
37
+ } from "./session-capabilities-ui-rows.ts";
28
38
 
39
+ const SECTIONS: CapabilitySection[] = ["tools", "skills", "profiles"];
29
40
  const MAX_VISIBLE_ROWS = 10;
30
41
  const HEADER_HEIGHT = 2;
31
42
 
@@ -37,8 +48,12 @@ interface SelectorComponent {
37
48
  }
38
49
 
39
50
  interface CapabilitySelectorOptions {
40
- done: (result: undefined) => void;
51
+ done: (result: CapabilityProfileAction | undefined) => void;
41
52
  onChange: (id: string, value: string) => void;
53
+ /** Rows read from profile configuration before this selector opens. */
54
+ profileRows?: CapabilityRow[];
55
+ /** Reopen Profiles after a profile action. Defaults to Tools. */
56
+ initialSection?: CapabilitySection;
42
57
  skills: SessionCapabilitySkill[] | undefined;
43
58
  state: SessionCapabilityState;
44
59
  theme: ExtensionUIContext["theme"];
@@ -124,12 +139,7 @@ class CapabilityList implements SelectorComponent {
124
139
  }
125
140
 
126
141
  private buildRows(): CapabilityRow[] {
127
- return buildCapabilityRows(
128
- this.section,
129
- this.options.tools,
130
- this.options.skills,
131
- this.options.state,
132
- );
142
+ return buildCapabilityRows({ ...this.options, section: this.section });
133
143
  }
134
144
 
135
145
  private filteredRows(): CapabilityRow[] {
@@ -206,7 +216,13 @@ class CapabilityList implements SelectorComponent {
206
216
 
207
217
  private renderHint(width: number): string {
208
218
  return renderTuiHintLine(
209
- ["Type to search", "Enter for actions", "Space to toggle", "Tab to switch", "Esc to close"],
219
+ [
220
+ "Type to search",
221
+ "Enter for actions",
222
+ ...(this.section === "profiles" ? [] : ["Space to toggle"]),
223
+ "Tab to switch",
224
+ "Esc to close",
225
+ ],
210
226
  this.options.theme,
211
227
  width,
212
228
  );
@@ -268,8 +284,7 @@ class CapabilityList implements SelectorComponent {
268
284
  const row = this.filteredRows()[this.selectedIndex];
269
285
  if (!row?.toggleValues) return;
270
286
  const [enabledValue, disabledValue] = row.toggleValues;
271
- this.options.onChange(row.id, row.currentValue === enabledValue ? disabledValue : enabledValue);
272
- this.refreshRows(row.id);
287
+ this.changeRow(row.id, row.currentValue === enabledValue ? disabledValue : enabledValue);
273
288
  }
274
289
 
275
290
  private activateSelected(): void {
@@ -281,8 +296,7 @@ class CapabilityList implements SelectorComponent {
281
296
  theme: this.options.theme,
282
297
  onSelect: (item) => {
283
298
  this.menu = null;
284
- this.options.onChange(row.id, item.value);
285
- this.refreshRows(row.id);
299
+ this.changeRow(row.id, item.value);
286
300
  },
287
301
  onCancel: () => {
288
302
  this.menu = null;
@@ -294,6 +308,15 @@ class CapabilityList implements SelectorComponent {
294
308
  this.options.tui.requestRender();
295
309
  }
296
310
 
311
+ private changeRow(id: string, value: string): void {
312
+ if (id.startsWith("profile:")) {
313
+ this.options.done({ id, value });
314
+ return;
315
+ }
316
+ this.options.onChange(id, value);
317
+ this.refreshRows(id);
318
+ }
319
+
297
320
  private refreshRows(selectedId: string): void {
298
321
  this.rows = this.buildRows();
299
322
  const rows = this.filteredRows();
@@ -308,17 +331,25 @@ class CapabilityList implements SelectorComponent {
308
331
  /** Build the searchable selector for the current session. */
309
332
  export function createCapabilitySelector(options: CapabilitySelectorOptions): SelectorComponent {
310
333
  const { theme, tui } = options;
311
- let section: CapabilitySection = "tools";
334
+ let section: CapabilitySection = options.initialSection ?? "tools";
312
335
  let list = new CapabilityList(section, options);
313
336
  const topBorder = new DynamicBorder((text: string) => theme.fg("borderMuted", text));
314
337
  const bottomBorder = new DynamicBorder((text: string) => theme.fg("borderMuted", text));
315
338
 
316
339
  const renderTitle = (width: number): string => {
317
- const tabs =
318
- section === "tools"
319
- ? `${theme.fg("accent", theme.bold("[Tools]"))} ${theme.fg("dim", "Skills")}`
320
- : `${theme.fg("dim", "Tools")} ${theme.fg("accent", theme.bold("[Skills]"))}`;
321
- return truncateToWidth(formatTuiTitleLine("Session Capabilities", theme, tabs), width);
340
+ const tabs = SECTIONS.map((tab) => {
341
+ const label = tab.charAt(0).toUpperCase() + tab.slice(1);
342
+ return tab === section
343
+ ? theme.fg("accent", theme.bold(`[${label}]`))
344
+ : theme.fg("dim", label);
345
+ }).join(" ");
346
+ const snapshot = options.state.profileSnapshot;
347
+ const profile = snapshot ? ` · ${snapshot.id} (${snapshot.source})` : "";
348
+ const modified = isSessionCapabilityProfileModified(options.state) ? " · Modified" : "";
349
+ return truncateToWidth(
350
+ formatTuiTitleLine(`Session Capabilities${profile}${modified}`, theme, tabs),
351
+ width,
352
+ );
322
353
  };
323
354
 
324
355
  return {
@@ -336,8 +367,11 @@ export function createCapabilitySelector(options: CapabilitySelectorOptions): Se
336
367
  list.invalidate();
337
368
  },
338
369
  handleInput(data) {
339
- if (matchesKey(data, Key.tab) && !list.hasOpenMenu()) {
340
- section = section === "tools" ? "skills" : "tools";
370
+ const reverse = matchesKey(data, Key.shift("tab"));
371
+ if ((matchesKey(data, Key.tab) || reverse) && !list.hasOpenMenu()) {
372
+ const index =
373
+ (SECTIONS.indexOf(section) + (reverse ? -1 : 1) + SECTIONS.length) % SECTIONS.length;
374
+ section = SECTIONS[index] ?? "tools";
341
375
  list = new CapabilityList(section, options);
342
376
  tui.requestRender();
343
377
  return;
@@ -6,11 +6,13 @@ import {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { FOOTER_INVALIDATE_EVENT, footerContributions } from "@mrclrchtr/supi-core/footer-registry";
8
8
  import {
9
+ applySessionCapabilityProfile,
9
10
  clearPendingSessionCapabilityForks,
10
11
  createEmptySessionCapabilityState,
11
12
  decodeSessionCapabilities,
12
13
  findSessionCapabilities,
13
14
  getSessionCapabilitySkillProvider,
15
+ isSessionCapabilityProfileModified,
14
16
  readPendingSessionCapabilityFork,
15
17
  SESSION_CAPABILITIES_ENTRY,
16
18
  type SessionCapabilitySkill,
@@ -18,6 +20,7 @@ import {
18
20
  sessionCapabilityState,
19
21
  setPendingSessionCapabilityFork,
20
22
  } from "@mrclrchtr/supi-core/session";
23
+ import { openCapabilitySelector } from "./session-capabilities-selector.ts";
21
24
  import {
22
25
  addActiveToolsToBaseline,
23
26
  baselineFromState,
@@ -31,7 +34,8 @@ import {
31
34
  observeNewTools,
32
35
  reconcileToolInventory,
33
36
  } from "./session-capabilities-tools.ts";
34
- import { type CapabilityToolItem, createCapabilitySelector } from "./session-capabilities-ui.ts";
37
+ import type { CapabilityToolItem } from "./session-capabilities-ui.ts";
38
+ import { createCapabilityProfileStore } from "./session-capability-profile-store.ts";
35
39
 
36
40
  const COMMAND_NAME = "supi-capabilities";
37
41
  const FOOTER_KEY = "supi-capabilities";
@@ -60,11 +64,15 @@ function updateFooter(
60
64
  state.toolDenylist.length +
61
65
  (state.toolEnabledNames?.length ?? 0) +
62
66
  state.hiddenSkillNames.length;
63
- if (count === 0) {
67
+ const profile = state.profileSnapshot;
68
+ if (count === 0 && !profile) {
64
69
  footerContributions.unregister(FOOTER_KEY);
65
70
  ctx.ui.setStatus(FOOTER_KEY, undefined);
66
71
  } else {
67
- const render = (): string => ctx.ui.theme.fg("accent", `${FOOTER_ICON}${count}`);
72
+ const label = profile
73
+ ? ` ${profile.id}${isSessionCapabilityProfileModified(state) ? " Modified" : ""}`
74
+ : "";
75
+ const render = (): string => ctx.ui.theme.fg("accent", `${FOOTER_ICON}${count}${label}`);
68
76
  footerContributions.register({ key: FOOTER_KEY, placement: "stats-end", render });
69
77
  ctx.ui.setStatus(FOOTER_KEY, render());
70
78
  }
@@ -125,21 +133,6 @@ function restoreAllTools(
125
133
  applyToolDenylist(pi, state, restoreNames, restorableNames);
126
134
  }
127
135
 
128
- function resetTools(
129
- pi: ExtensionAPI,
130
- ctx: ExtensionContext,
131
- state: SessionCapabilityState,
132
- restorableNames: ReadonlySet<string>,
133
- ): void {
134
- restoreAllTools(pi, state, restorableNames);
135
- saveState(pi, ctx, state);
136
- }
137
-
138
- function resetSkills(pi: ExtensionAPI, ctx: ExtensionContext, state: SessionCapabilityState): void {
139
- state.hiddenSkillNames = [];
140
- saveState(pi, ctx, state);
141
- }
142
-
143
136
  function resetAll(
144
137
  pi: ExtensionAPI,
145
138
  ctx: ExtensionContext,
@@ -148,6 +141,7 @@ function resetAll(
148
141
  ): void {
149
142
  restoreAllTools(pi, state, restorableNames);
150
143
  state.hiddenSkillNames = [];
144
+ delete state.profileSnapshot;
151
145
  saveState(pi, ctx, state);
152
146
  }
153
147
 
@@ -198,14 +192,18 @@ function handleSelectorChange(options: SelectorChangeOptions): void {
198
192
  saveState(pi, ctx, state);
199
193
  return;
200
194
  case "hide-all-skills":
201
- state.hiddenSkillNames = Array.from(new Set((skills ?? []).map((skill) => skill.name)));
195
+ state.hiddenSkillNames = Array.from(
196
+ new Set([...state.hiddenSkillNames, ...(skills ?? []).map((skill) => skill.name)]),
197
+ );
202
198
  saveState(pi, ctx, state);
203
199
  return;
204
200
  case "reset-tools":
205
- resetTools(pi, ctx, state, options.restorableNames);
201
+ restoreAllTools(pi, state, options.restorableNames);
202
+ saveState(pi, ctx, state);
206
203
  return;
207
204
  case "reset-skills":
208
- resetSkills(pi, ctx, state);
205
+ state.hiddenSkillNames = [];
206
+ saveState(pi, ctx, state);
209
207
  return;
210
208
  case "reset-all":
211
209
  resetAll(pi, ctx, state, options.restorableNames);
@@ -224,8 +222,51 @@ function handleSelectorChange(options: SelectorChangeOptions): void {
224
222
  }
225
223
  }
226
224
 
227
- /** Register session-only controls for extension tools and model-visible skills. */
228
- export default function sessionCapabilities(pi: ExtensionAPI): void {
225
+ /** Check all branches for history. Initial model and thinking entries are not history. */
226
+ function shouldApplyDefault(
227
+ ctx: ExtensionContext,
228
+ reason: string,
229
+ storedState: SessionCapabilityState | undefined,
230
+ ): boolean {
231
+ if (reason === "new") return true;
232
+ if (reason !== "startup" || storedState || ctx.sessionManager.getHeader()?.parentSession)
233
+ return false;
234
+ return !ctx.sessionManager
235
+ .getEntries()
236
+ .some((entry) =>
237
+ ["message", "custom_message", "compaction", "branch_summary", "context_edit"].includes(
238
+ entry.type,
239
+ ),
240
+ );
241
+ }
242
+
243
+ function applyDefaultProfile(
244
+ state: SessionCapabilityState,
245
+ ctx: ExtensionContext,
246
+ homeDir: string | undefined,
247
+ ): SessionCapabilityState {
248
+ const catalog = createCapabilityProfileStore({
249
+ cwd: ctx.cwd,
250
+ homeDir,
251
+ isProjectTrusted: () => ctx.isProjectTrusted(),
252
+ }).read();
253
+ for (const error of catalog.errors) {
254
+ const message = `Session capability profiles (${error.scope}, ${error.path}): ${error.message}`;
255
+ if (ctx.mode === "print" || ctx.mode === "json") {
256
+ // biome-ignore lint/suspicious/noConsole: non-interactive startup has no notification UI
257
+ console.warn(message);
258
+ } else ctx.ui.notify(message, "warning");
259
+ }
260
+ return catalog.default.status === "resolved"
261
+ ? (applySessionCapabilityProfile(state, catalog.default.profile) ?? state)
262
+ : state;
263
+ }
264
+
265
+ /** Register session controls and saved profiles for extension tools and model-visible skills. */
266
+ export default function sessionCapabilities(
267
+ pi: ExtensionAPI,
268
+ options: { homeDir?: string } = {},
269
+ ): void {
229
270
  let toolBaseline: ToolBaseline | undefined;
230
271
  let knownToolNames = new Set<string>();
231
272
  let restorableToolNames = new Set<string>();
@@ -237,6 +278,8 @@ export default function sessionCapabilities(pi: ExtensionAPI): void {
237
278
  return;
238
279
  }
239
280
 
281
+ if (!ctx.isIdle()) ctx.ui.notify("Waiting for the agent to become idle.", "info");
282
+ await ctx.waitForIdle();
240
283
  const sessionId = ctx.sessionManager.getSessionId();
241
284
  const state =
242
285
  currentSessionState(ctx) ??
@@ -250,36 +293,37 @@ export default function sessionCapabilities(pi: ExtensionAPI): void {
250
293
  if (provider) {
251
294
  try {
252
295
  skills = await provider.listEligibleSkills(ctx);
253
- const names = new Set(skills.map((skill) => skill.name));
254
- const previous = state.hiddenSkillNames;
255
- state.hiddenSkillNames = previous.filter((name) => names.has(name));
256
- if (previous.length !== state.hiddenSkillNames.length) saveState(pi, ctx, state);
257
296
  } catch {
258
297
  ctx.ui.notify("Could not load the skill list", "warning");
259
298
  }
260
299
  }
261
300
 
262
- await ctx.ui.custom((tui, theme, _keybindings, done) =>
263
- createCapabilitySelector({
264
- state,
265
- tools: toolInventory.tools,
266
- skills,
267
- theme,
268
- tui,
269
- done,
270
- onChange: (id, value) =>
271
- handleSelectorChange({
272
- pi,
273
- ctx,
274
- state,
275
- tools: toolInventory.tools,
276
- skills,
277
- id,
278
- value,
279
- restorableNames: restorableToolNames,
280
- }),
281
- }),
282
- );
301
+ await openCapabilitySelector({
302
+ ctx,
303
+ homeDir: options.homeDir,
304
+ state,
305
+ tools: toolInventory.tools,
306
+ skills,
307
+ onChange: (id, value) =>
308
+ handleSelectorChange({
309
+ pi,
310
+ ctx,
311
+ state,
312
+ tools: toolInventory.tools,
313
+ skills,
314
+ id,
315
+ value,
316
+ restorableNames: restorableToolNames,
317
+ }),
318
+ onApply: (profile) => {
319
+ const next = applySessionCapabilityProfile(state, profile);
320
+ if (!next) throw new Error("Invalid session capability profile.");
321
+ const restoreNames = [...state.toolDenylist];
322
+ Object.assign(state, next);
323
+ applyToolDenylist(pi, state, restoreNames, restorableToolNames);
324
+ saveState(pi, ctx, state);
325
+ },
326
+ });
283
327
  },
284
328
  });
285
329
 
@@ -309,6 +353,9 @@ export default function sessionCapabilities(pi: ExtensionAPI): void {
309
353
  event.reason !== "new" && state ? baselineFromState(state) : captureToolBaseline(pi);
310
354
  addActiveToolsToBaseline(pi, toolBaseline);
311
355
  state ??= initialSessionCapabilityState(toolBaseline);
356
+ if (shouldApplyDefault(ctx, event.reason, currentEntryState)) {
357
+ state = applyDefaultProfile(state, ctx, options.homeDir);
358
+ }
312
359
  const toolStateChanged = reconcileToolInventory(pi, state).changed;
313
360
  sessionCapabilityState.set(ctx.sessionManager.getSessionId(), state);
314
361
  applyToolDenylist(pi, state, state.toolEnabledNames ?? [], restorableToolNames);
@@ -323,6 +370,7 @@ export default function sessionCapabilities(pi: ExtensionAPI): void {
323
370
  const state = currentSessionState(ctx);
324
371
  if (!state) return;
325
372
  observeNewTools(pi, state, knownToolNames, restorableToolNames);
373
+ if (reconcileToolInventory(pi, state).changed) saveState(pi, ctx, state);
326
374
  applyToolDenylist(pi, state, state.toolEnabledNames ?? [], restorableToolNames);
327
375
  });
328
376
 
@@ -330,6 +378,7 @@ export default function sessionCapabilities(pi: ExtensionAPI): void {
330
378
  const state = currentSessionState(ctx);
331
379
  if (!state) return;
332
380
  observeNewTools(pi, state, knownToolNames, restorableToolNames);
381
+ if (reconcileToolInventory(pi, state).changed) saveState(pi, ctx, state);
333
382
  applyToolDenylist(pi, state, state.toolEnabledNames ?? [], restorableToolNames);
334
383
  });
335
384
 
@@ -0,0 +1,176 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { getSupiConfigPath, replaceSupiConfigSection } from "@mrclrchtr/supi-core/config";
3
+ import {
4
+ isSessionCapabilityProfileId,
5
+ resolveSessionCapabilityProfiles,
6
+ type SessionCapabilityProfileDiagnostic,
7
+ type SessionCapabilityProfileScope,
8
+ type SessionCapabilityProfilesResolution,
9
+ validateSessionCapabilityProfile,
10
+ } from "@mrclrchtr/supi-core/session";
11
+
12
+ const SECTION = "sessionCapabilities";
13
+
14
+ /** Config locations and the current project trust decision. */
15
+ export interface CapabilityProfileStoreOptions {
16
+ cwd: string;
17
+ /** Override the OS home directory for global configuration. */
18
+ homeDir?: string;
19
+ /** Get the current trust decision before project access. */
20
+ isProjectTrusted: () => boolean;
21
+ }
22
+
23
+ /** Resolved profiles and the raw default values for scoped actions. */
24
+ export interface CapabilityProfileCatalog extends SessionCapabilityProfilesResolution {
25
+ defaultValues: { global?: unknown; project?: unknown };
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /** Read strictly so malformed JSON cannot be treated as an absent config file. */
33
+ function readSection(path: string): unknown {
34
+ try {
35
+ const config: unknown = JSON.parse(readFileSync(path, "utf-8"));
36
+ if (!isRecord(config)) throw new Error("Config root must be an object.");
37
+ return config[SECTION];
38
+ } catch (error) {
39
+ if (isRecord(error) && error.code === "ENOENT") return undefined;
40
+ throw new Error(
41
+ `Cannot read config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
42
+ { cause: error },
43
+ );
44
+ }
45
+ }
46
+
47
+ function readCatalogSection(
48
+ path: string,
49
+ scope: SessionCapabilityProfileScope,
50
+ errors: SessionCapabilityProfileDiagnostic[],
51
+ ): unknown {
52
+ try {
53
+ return readSection(path);
54
+ } catch (error) {
55
+ errors.push({ scope, path, message: error instanceof Error ? error.message : String(error) });
56
+ // A failed read is invalid, not absent. It must block default inheritance.
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function readWritableSection(
62
+ scope: SessionCapabilityProfileScope,
63
+ options: CapabilityProfileStoreOptions,
64
+ ): Record<string, unknown> {
65
+ if (scope === "project" && !options.isProjectTrusted()) {
66
+ throw new Error("Project trust is required to change capability profiles.");
67
+ }
68
+ const path = getSupiConfigPath(scope, options.cwd, options);
69
+ const section = readSection(path);
70
+ if (section === undefined) return {};
71
+ if (!isRecord(section)) throw new Error(`sessionCapabilities must be an object: ${path}`);
72
+ if (Object.hasOwn(section, "profiles") && !isRecord(section.profiles)) {
73
+ throw new Error(`profiles must be an object: ${path}`);
74
+ }
75
+ return section;
76
+ }
77
+
78
+ /** Check section removal because the shared writer ignores file-removal errors. */
79
+ function writeSection(
80
+ scope: SessionCapabilityProfileScope,
81
+ section: Record<string, unknown>,
82
+ options: CapabilityProfileStoreOptions,
83
+ ): void {
84
+ replaceSupiConfigSection({ section: SECTION, scope, cwd: options.cwd }, section, options);
85
+ if (Object.keys(section).length === 0) {
86
+ const path = getSupiConfigPath(scope, options.cwd, options);
87
+ if (readSection(path) !== undefined) throw new Error(`Cannot remove ${SECTION} at ${path}.`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Read and change scoped configuration without a cache or changes to session state.
93
+ * Read errors become diagnostics. Mutation errors are thrown to the caller.
94
+ */
95
+ export function createCapabilityProfileStore(options: CapabilityProfileStoreOptions) {
96
+ const store = {
97
+ /** Read current profiles and diagnostics. Ignore untrusted project configuration. */
98
+ read(): CapabilityProfileCatalog {
99
+ const projectTrusted = options.isProjectTrusted();
100
+ const readErrors: SessionCapabilityProfileDiagnostic[] = [];
101
+ const global = readCatalogSection(
102
+ getSupiConfigPath("global", options.cwd, options),
103
+ "global",
104
+ readErrors,
105
+ );
106
+ const project = projectTrusted
107
+ ? readCatalogSection(
108
+ getSupiConfigPath("project", options.cwd, options),
109
+ "project",
110
+ readErrors,
111
+ )
112
+ : undefined;
113
+ const resolution = resolveSessionCapabilityProfiles({ global, project, projectTrusted });
114
+ return {
115
+ ...resolution,
116
+ errors: [
117
+ ...readErrors,
118
+ ...resolution.errors.filter(
119
+ (error) => !readErrors.some((read) => read.scope === error.scope),
120
+ ),
121
+ ],
122
+ defaultValues: {
123
+ ...(isRecord(global) && Object.hasOwn(global, "defaultProfile")
124
+ ? { global: global.defaultProfile }
125
+ : {}),
126
+ ...(isRecord(project) && Object.hasOwn(project, "defaultProfile")
127
+ ? { project: project.defaultProfile }
128
+ : {}),
129
+ },
130
+ };
131
+ },
132
+ /** Save exact exclusion sets. Replacement requires explicit approval from the caller. */
133
+ save(
134
+ scope: SessionCapabilityProfileScope,
135
+ id: string,
136
+ profile: unknown,
137
+ replace = false,
138
+ ): void {
139
+ if (!isSessionCapabilityProfileId(id)) {
140
+ throw new Error("Invalid profile ID: use lowercase words with single hyphens.");
141
+ }
142
+ const result = validateSessionCapabilityProfile(profile);
143
+ if (!result.profile) throw new Error(result.errors.join(" "));
144
+ const section = readWritableSection(scope, options);
145
+ const profiles = isRecord(section.profiles) ? section.profiles : {};
146
+ if (Object.hasOwn(profiles, id) && replace !== true) {
147
+ throw new Error(`Profile ${id} already exists in ${scope}. Confirm replacement first.`);
148
+ }
149
+ section.profiles = { ...profiles, [id]: result.profile };
150
+ writeSection(scope, section, options);
151
+ },
152
+ /** Delete one scoped definition. Leave all default references unchanged. */
153
+ delete(scope: SessionCapabilityProfileScope, id: string): void {
154
+ const section = readWritableSection(scope, options);
155
+ if (!isRecord(section.profiles) || !Object.hasOwn(section.profiles, id)) return;
156
+ delete section.profiles[id];
157
+ if (Object.keys(section.profiles).length === 0) delete section.profiles;
158
+ writeSection(scope, section, options);
159
+ },
160
+ /** Set a default ID, store null for none, or remove the key with undefined. */
161
+ setDefault(scope: SessionCapabilityProfileScope, id: string | null | undefined): void {
162
+ const section = readWritableSection(scope, options);
163
+ if (
164
+ id !== undefined &&
165
+ id !== null &&
166
+ !store.read().effectiveProfiles.some((profile) => profile.id === id)
167
+ ) {
168
+ throw new Error(`Default profile ${id} is missing or invalid.`);
169
+ }
170
+ if (id === undefined) delete section.defaultProfile;
171
+ else section.defaultProfile = id;
172
+ writeSection(scope, section, options);
173
+ },
174
+ };
175
+ return store;
176
+ }