@mrclrchtr/supi-extras 8.0.0 → 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.
- package/README.md +35 -4
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +9 -4
- package/node_modules/@mrclrchtr/supi-core/src/session-capabilities.ts +55 -1
- package/node_modules/@mrclrchtr/supi-core/src/session-capability-profiles.ts +240 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +22 -0
- package/package.json +2 -2
- package/src/session-capabilities-selector.ts +67 -0
- package/src/session-capabilities-tools.ts +0 -1
- package/src/session-capabilities-ui-rows.ts +16 -7
- package/src/session-capabilities-ui.ts +56 -22
- package/src/session-capabilities.ts +97 -48
- package/src/session-capability-profile-store.ts +176 -0
- package/src/session-capability-profile-ui.ts +284 -0
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
truncateToWidth,
|
|
10
10
|
visibleWidth,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import
|
|
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 {
|
|
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
|
-
[
|
|
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.
|
|
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.
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
201
|
+
restoreAllTools(pi, state, options.restorableNames);
|
|
202
|
+
saveState(pi, ctx, state);
|
|
206
203
|
return;
|
|
207
204
|
case "reset-skills":
|
|
208
|
-
|
|
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
|
-
/**
|
|
228
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
}
|