@mrclrchtr/supi-extras 8.0.1 → 9.1.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.
@@ -0,0 +1,284 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ captureSessionCapabilityExclusions,
4
+ type SessionCapabilityProfileEntry,
5
+ type SessionCapabilityProfileScope,
6
+ type SessionCapabilityProfileSnapshot,
7
+ type SessionCapabilitySkill,
8
+ type SessionCapabilityState,
9
+ } from "@mrclrchtr/supi-core/session";
10
+ import type { CapabilityProfileAction } from "./session-capabilities-ui.ts";
11
+ import type { CapabilityRow, CapabilityToolItem } from "./session-capabilities-ui-rows.ts";
12
+ import type {
13
+ CapabilityProfileCatalog,
14
+ createCapabilityProfileStore,
15
+ } from "./session-capability-profile-store.ts";
16
+
17
+ /** Current configuration and eligible inventories for profile details. */
18
+ interface ProfileRowOptions {
19
+ catalog: CapabilityProfileCatalog;
20
+ tools: CapabilityToolItem[];
21
+ skills: SessionCapabilitySkill[] | undefined;
22
+ projectTrusted: boolean;
23
+ }
24
+
25
+ /** Dialog context and session state. Only apply can change the session. */
26
+ interface ProfileActionOptions {
27
+ ctx: ExtensionCommandContext;
28
+ store: ReturnType<typeof createCapabilityProfileStore>;
29
+ state: SessionCapabilityState;
30
+ apply(profile: SessionCapabilityProfileSnapshot): void;
31
+ }
32
+
33
+ function defaultRow(
34
+ scope: SessionCapabilityProfileScope,
35
+ catalog: CapabilityProfileCatalog,
36
+ ): CapabilityRow {
37
+ const value = catalog.defaultValues[scope];
38
+ const label = `${scope === "global" ? "Global" : "Project"} default`;
39
+ const currentValue =
40
+ value === undefined
41
+ ? scope === "project"
42
+ ? "Inherit global"
43
+ : "None (not set)"
44
+ : value === null
45
+ ? "None (explicit)"
46
+ : typeof value === "string"
47
+ ? value
48
+ : "Invalid";
49
+ return {
50
+ id: `profile:default:${scope}`,
51
+ label,
52
+ currentValue,
53
+ description: `Source: ${scope}. Stored default: ${JSON.stringify(value) ?? "absent"}. Changes apply only to new sessions.`,
54
+ indent: 2,
55
+ searchText: `${label} ${currentValue}`,
56
+ actions: [{ value: "set-default", label: "Set default" }],
57
+ };
58
+ }
59
+
60
+ function profileRow(
61
+ entry: SessionCapabilityProfileEntry,
62
+ options: ProfileRowOptions,
63
+ ): CapabilityRow {
64
+ const status = [
65
+ entry.source,
66
+ ...(entry.shadowed ? ["Shadowed"] : []),
67
+ ...(!entry.profile ? ["Invalid"] : []),
68
+ ];
69
+ const details = [
70
+ `Source: ${entry.source}.${entry.shadowed ? " Shadowed by the project definition; cannot apply." : ""}`,
71
+ ];
72
+ if (entry.profile) {
73
+ const { excludedTools, hiddenSkills } = entry.profile;
74
+ const toolNames = new Set(options.tools.map((tool) => tool.name));
75
+ const skillNames = new Set(options.skills?.map((skill) => skill.name));
76
+ const unmatchedTools = excludedTools.filter((name) => !toolNames.has(name));
77
+ const unmatchedSkills = hiddenSkills.filter((name) => !skillNames.has(name));
78
+ details.push(
79
+ `Excluded tools: ${JSON.stringify(excludedTools)}. Unmatched tools: ${JSON.stringify(unmatchedTools)}.`,
80
+ `Hidden skills: ${JSON.stringify(hiddenSkills)}. Unmatched skills: ${JSON.stringify(unmatchedSkills)}.`,
81
+ );
82
+ }
83
+ details.push(...entry.errors.map((error) => `${error.path}: ${error.message}`));
84
+ return {
85
+ id: `profile:${entry.source}:${entry.id}`,
86
+ label: entry.id,
87
+ currentValue: status.join(" · "),
88
+ description: details.join("\n"),
89
+ indent: 2,
90
+ searchText: `${entry.id} ${status.join(" ")}`,
91
+ actions: [
92
+ ...(entry.profile && !entry.shadowed ? [{ value: "apply", label: "Apply profile" }] : []),
93
+ { value: "replace", label: "Replace saved profile" },
94
+ { value: "delete", label: "Delete profile" },
95
+ ],
96
+ };
97
+ }
98
+
99
+ /** Build scoped profile rows, including invalid and shadowed definitions. */
100
+ export function buildCapabilityProfileRows(options: ProfileRowOptions): CapabilityRow[] {
101
+ const { catalog, projectTrusted } = options;
102
+ const rows: CapabilityRow[] = [
103
+ {
104
+ id: "profile:save",
105
+ label: "Save current as",
106
+ currentValue: "Save",
107
+ description:
108
+ "Save current exact exclusions, including unmatched names. Select a scope and a new profile ID. The session does not change.",
109
+ indent: 2,
110
+ searchText: "Save current as",
111
+ actions: [{ value: "save", label: "Save current as" }],
112
+ },
113
+ defaultRow("global", catalog),
114
+ ...(projectTrusted ? [defaultRow("project", catalog)] : []),
115
+ ];
116
+ const entries = catalog.entries
117
+ .filter((entry) => entry.source === "global" || projectTrusted)
118
+ .sort(
119
+ (left, right) => left.id.localeCompare(right.id) || left.source.localeCompare(right.source),
120
+ );
121
+ rows.push(...entries.map((entry) => profileRow(entry, options)));
122
+ for (const [index, error] of catalog.errors.entries()) {
123
+ if (error.scope === "project" && !projectTrusted) continue;
124
+ rows.push({
125
+ id: `profile:error:${index}`,
126
+ label: "Configuration error",
127
+ currentValue: error.scope,
128
+ description: `Source: ${error.scope}. ${error.path}: ${error.message}`,
129
+ indent: 2,
130
+ searchText: `Configuration error ${error.scope} ${error.path} ${error.message}`,
131
+ });
132
+ }
133
+ return rows;
134
+ }
135
+
136
+ function requireEntry(
137
+ catalog: CapabilityProfileCatalog,
138
+ scope: SessionCapabilityProfileScope,
139
+ id: string,
140
+ ): SessionCapabilityProfileEntry {
141
+ const entry = catalog.entries.find((item) => item.source === scope && item.id === id);
142
+ if (!entry) throw new Error(`Profile ${id} (${scope}) is no longer available.`);
143
+ return entry;
144
+ }
145
+
146
+ async function saveCurrentAs({ ctx, store, state }: ProfileActionOptions): Promise<void> {
147
+ // Require a selection even when Global is the only permitted scope.
148
+ const label = await ctx.ui.select(
149
+ "Save profile in",
150
+ ctx.isProjectTrusted() ? ["Global", "Project"] : ["Global"],
151
+ );
152
+ if (label === undefined) return;
153
+ const scope = label === "Global" ? "global" : label === "Project" ? "project" : undefined;
154
+ if (!scope) throw new Error("Select Global or Project to save a profile.");
155
+ const id = await ctx.ui.input(`New profile ID (${label})`, "code-review");
156
+ if (id === undefined) return;
157
+ store.save(scope, id.trim(), captureSessionCapabilityExclusions(state));
158
+ }
159
+
160
+ async function setDefault(
161
+ scope: SessionCapabilityProfileScope,
162
+ catalog: CapabilityProfileCatalog,
163
+ { ctx, store }: ProfileActionOptions,
164
+ ): Promise<void> {
165
+ const choices = catalog.effectiveProfiles.map((profile) => ({
166
+ label: `${profile.id} (${profile.source})`,
167
+ value: profile.id as string | null | undefined,
168
+ }));
169
+ choices.push(
170
+ { label: "None", value: null },
171
+ {
172
+ label: scope === "project" ? "Inherit global default" : "Reset global default",
173
+ value: undefined,
174
+ },
175
+ );
176
+ const label = await ctx.ui.select(
177
+ `Set ${scope} default`,
178
+ choices.map((choice) => choice.label),
179
+ );
180
+ if (label === undefined) return;
181
+ const choice = choices.find((item) => item.label === label);
182
+ if (!choice) throw new Error("Select an available default option.");
183
+ store.setDefault(scope, choice.value);
184
+ }
185
+
186
+ function deletionMessage(
187
+ entry: SessionCapabilityProfileEntry,
188
+ catalog: CapabilityProfileCatalog,
189
+ ): string {
190
+ const references = (["global", "project"] as const).filter(
191
+ (scope) => catalog.defaultValues[scope] === entry.id,
192
+ );
193
+ const fallback =
194
+ entry.source === "project"
195
+ ? catalog.entries.find((item) => item.source === "global" && item.id === entry.id)
196
+ : undefined;
197
+ return [
198
+ `Delete ${entry.id} (${entry.source})?`,
199
+ references.length
200
+ ? `Known default references: ${references.join(", ")}. These references will not change.`
201
+ : "No known default references.",
202
+ ...(fallback
203
+ ? [
204
+ `Deletion will expose the global definition ${entry.id}${fallback.profile ? "." : ", which is invalid."}`,
205
+ ]
206
+ : []),
207
+ "Current session exclusions and the applied snapshot will not change.",
208
+ ].join("\n");
209
+ }
210
+
211
+ async function runScopedAction(
212
+ action: CapabilityProfileAction,
213
+ entry: SessionCapabilityProfileEntry,
214
+ catalog: CapabilityProfileCatalog,
215
+ options: ProfileActionOptions,
216
+ ): Promise<void> {
217
+ const { ctx, store, state, apply } = options;
218
+ const { id, source } = entry;
219
+ switch (action.value) {
220
+ case "apply": {
221
+ const profile = catalog.effectiveProfiles.find(
222
+ (item) => item.id === id && item.source === source,
223
+ );
224
+ if (!profile || entry.shadowed)
225
+ throw new Error(`Profile ${id} (${source}) is invalid or shadowed.`);
226
+ apply(profile);
227
+ return;
228
+ }
229
+ case "replace":
230
+ if (
231
+ await ctx.ui.confirm(
232
+ "Replace saved profile?",
233
+ `Replace ${id} (${source}) with current exclusions? The session will not change.`,
234
+ )
235
+ ) {
236
+ requireEntry(store.read(), source, id);
237
+ store.save(source, id, captureSessionCapabilityExclusions(state), true);
238
+ }
239
+ return;
240
+ case "delete":
241
+ if (await ctx.ui.confirm("Delete profile?", deletionMessage(entry, catalog)))
242
+ store.delete(source, id);
243
+ return;
244
+ default:
245
+ throw new Error(`Unknown profile action: ${action.value}.`);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Run dialogs after the selector closes. Read current configuration and trust for each action.
251
+ * Throw read, validation, and write failures to the command loop. Writes do not apply profiles.
252
+ */
253
+ export async function runCapabilityProfileAction(
254
+ action: CapabilityProfileAction,
255
+ options: ProfileActionOptions,
256
+ ): Promise<void> {
257
+ const catalog = options.store.read();
258
+ if (action.id === "profile:save" && action.value === "save") {
259
+ await saveCurrentAs(options);
260
+ return;
261
+ }
262
+ const defaultScope =
263
+ action.id === "profile:default:global"
264
+ ? "global"
265
+ : action.id === "profile:default:project"
266
+ ? "project"
267
+ : undefined;
268
+ if (defaultScope && action.value === "set-default") {
269
+ if (defaultScope === "project" && !options.ctx.isProjectTrusted()) {
270
+ throw new Error("Project trust is required to change the project default.");
271
+ }
272
+ await setDefault(defaultScope, catalog, options);
273
+ return;
274
+ }
275
+ const scope = action.id.startsWith("profile:global:")
276
+ ? "global"
277
+ : action.id.startsWith("profile:project:")
278
+ ? "project"
279
+ : undefined;
280
+ if (!scope) throw new Error(`Unknown profile action: ${action.id}.`);
281
+ const id = action.id.slice(`profile:${scope}:`.length);
282
+ const entry = requireEntry(catalog, scope, id);
283
+ await runScopedAction(action, entry, catalog, options);
284
+ }