pi-skill-stacks 0.4.1
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/LICENSE +21 -0
- package/README.md +111 -0
- package/extensions/dialogs.ts +182 -0
- package/extensions/frame.ts +39 -0
- package/extensions/header.ts +262 -0
- package/extensions/index.ts +255 -0
- package/extensions/overlay.ts +524 -0
- package/package.json +44 -0
- package/src/core.ts +188 -0
- package/src/markdown.ts +229 -0
- package/src/overlay-model.ts +330 -0
- package/src/store.ts +258 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-stacks - Toggle named groups of skills on/off for context management.
|
|
3
|
+
*
|
|
4
|
+
* - `/stacks` opens a two-pane overlay: toggle stacks on/off, move skills in
|
|
5
|
+
* and out of stacks, create/delete stacks. Changes persist immediately;
|
|
6
|
+
* pi reloads once when the overlay closes, if settings.json changed.
|
|
7
|
+
* - `/stacks on <stack>` / `/stacks off <stack>` toggle a single stack
|
|
8
|
+
* - `/stacks list` prints the current state without changing anything
|
|
9
|
+
*
|
|
10
|
+
* Toggling off writes `!skills/<dir>/SKILL.md` exclusion patterns into the
|
|
11
|
+
* global settings `skills` array (pi's own override mechanism), so disabled
|
|
12
|
+
* skills vanish from the system prompt, `/skill:` commands, and discovery.
|
|
13
|
+
* The extension only ever removes exclusions it wrote itself (tracked in
|
|
14
|
+
* skill-stacks.json managedExclusions); hand-written `pi config` entries are
|
|
15
|
+
* left alone.
|
|
16
|
+
*
|
|
17
|
+
* Config: global stacks + state in ~/.pi/agent/skill-stacks.json; a project
|
|
18
|
+
* may add/override stack definitions in <cwd>/.pi/skill-stacks.json.
|
|
19
|
+
* Overlap rule: a skill stays enabled if any enabled stack contains it.
|
|
20
|
+
* Skills in no stack are never touched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import {
|
|
25
|
+
desiredExclusions,
|
|
26
|
+
mergeStacks,
|
|
27
|
+
missingSkillNames,
|
|
28
|
+
nextDisabledStacks,
|
|
29
|
+
planSkillsSetting,
|
|
30
|
+
scopeManagedExclusions,
|
|
31
|
+
sortNames,
|
|
32
|
+
summarizeStacks,
|
|
33
|
+
type DiscoveredSkills,
|
|
34
|
+
type StackMap,
|
|
35
|
+
type StacksSummary,
|
|
36
|
+
} from "../src/core.ts";
|
|
37
|
+
import {
|
|
38
|
+
ConfigError,
|
|
39
|
+
discoverSkills,
|
|
40
|
+
globalConfigPath,
|
|
41
|
+
globalSettingsPath,
|
|
42
|
+
loadProjectStacks,
|
|
43
|
+
loadStacksConfig,
|
|
44
|
+
readSettingsSkills,
|
|
45
|
+
readSkillContents,
|
|
46
|
+
saveStacksConfig,
|
|
47
|
+
updateSettingsSkills,
|
|
48
|
+
} from "../src/store.ts";
|
|
49
|
+
import { showStacksOverlay, type ApplyOutcome } from "./overlay.ts";
|
|
50
|
+
|
|
51
|
+
/** Everything the command needs about the current cwd's merged stacks. */
|
|
52
|
+
interface StacksView {
|
|
53
|
+
stacks: StackMap;
|
|
54
|
+
stackNames: string[];
|
|
55
|
+
/** Disabled entries for stacks visible here (the global list may hold more). */
|
|
56
|
+
disabledStacks: string[];
|
|
57
|
+
discovered: DiscoveredSkills;
|
|
58
|
+
/** Stack names defined in <cwd>/.pi/skill-stacks.json (membership is read-only in the overlay). */
|
|
59
|
+
projectStackNames: Set<string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadView(cwd: string): StacksView {
|
|
63
|
+
const global = loadStacksConfig();
|
|
64
|
+
const project = loadProjectStacks(cwd);
|
|
65
|
+
const stacks = mergeStacks(global.stacks, project);
|
|
66
|
+
const stackNames = sortNames(Object.keys(stacks));
|
|
67
|
+
return {
|
|
68
|
+
stacks,
|
|
69
|
+
stackNames,
|
|
70
|
+
disabledStacks: global.disabledStacks.filter((name) => stackNames.includes(name)),
|
|
71
|
+
discovered: discoverSkills(),
|
|
72
|
+
projectStackNames: new Set(Object.keys(project ?? {})),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Persist a full stacks state: settings.json exclusions + skill-stacks.json.
|
|
78
|
+
* `stacks` is the MERGED map (global + project) as edited. Project-defined
|
|
79
|
+
* entries are written back untouched; everything else comes from the merged
|
|
80
|
+
* map. State for stacks not visible from this cwd (another project's stacks)
|
|
81
|
+
* is preserved: their disabled entries and the exclusions written for them.
|
|
82
|
+
* Returns the outcome for notification; the caller decides whether to reload.
|
|
83
|
+
*/
|
|
84
|
+
function persistStacksState(view: StacksView, stacks: StackMap, disabledStacks: string[]): ApplyOutcome {
|
|
85
|
+
const global = loadStacksConfig();
|
|
86
|
+
const { projectStackNames, discovered } = view;
|
|
87
|
+
|
|
88
|
+
const globalStacks: StackMap = {};
|
|
89
|
+
for (const [name, skills] of Object.entries(global.stacks)) {
|
|
90
|
+
if (projectStackNames.has(name)) {
|
|
91
|
+
globalStacks[name] = skills; // shadowed by the project; keep as-is
|
|
92
|
+
} else if (name in stacks) {
|
|
93
|
+
globalStacks[name] = stacks[name]!; // possibly edited
|
|
94
|
+
} // else: deleted in the overlay
|
|
95
|
+
}
|
|
96
|
+
for (const [name, skills] of Object.entries(stacks)) {
|
|
97
|
+
if (!projectStackNames.has(name) && !(name in globalStacks)) {
|
|
98
|
+
globalStacks[name] = skills; // created in the overlay
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { inScope, retained } = scopeManagedExclusions(global.managedExclusions, stacks, discovered);
|
|
103
|
+
const current = readSettingsSkills();
|
|
104
|
+
const plan = planSkillsSetting(current, inScope, desiredExclusions(stacks, disabledStacks, discovered));
|
|
105
|
+
const settingsChanged =
|
|
106
|
+
plan.skills.length !== current.length || plan.skills.some((entry, i) => entry !== current[i]);
|
|
107
|
+
if (settingsChanged) updateSettingsSkills(globalSettingsPath(), plan.skills);
|
|
108
|
+
|
|
109
|
+
const visibleNames = new Set([...view.stackNames, ...Object.keys(stacks)]);
|
|
110
|
+
saveStacksConfig(globalConfigPath(), {
|
|
111
|
+
stacks: globalStacks,
|
|
112
|
+
disabledStacks: nextDisabledStacks(global.disabledStacks, visibleNames, disabledStacks),
|
|
113
|
+
managedExclusions: sortNames([...retained.filter((p) => plan.skills.includes(p)), ...plan.managed]),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { summary: summarizeStacks(stacks, disabledStacks, discovered), settingsChanged };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatSummary(summary: StacksSummary) {
|
|
120
|
+
const off = summary.offStacks.length > 0 ? ` · off: ${summary.offStacks.join(", ")}` : "";
|
|
121
|
+
return `${summary.stackCount} stacks · ${summary.activeCount}/${summary.totalCount} skills active${off}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function warnMissingSkills(ctx: ExtensionCommandContext, view: StacksView) {
|
|
125
|
+
const detail = Object.entries(missingSkillNames(view.stacks, view.discovered))
|
|
126
|
+
.map(([stack, names]) => `${stack}: ${names.join(", ")}`)
|
|
127
|
+
.join("; ");
|
|
128
|
+
if (detail) ctx.ui.notify(`Stacks reference unknown skills (${detail})`, "warning");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function listStacks(view: StacksView) {
|
|
132
|
+
const summary = summarizeStacks(view.stacks, view.disabledStacks, view.discovered);
|
|
133
|
+
const rows = view.stackNames.map((name) => {
|
|
134
|
+
const state = view.disabledStacks.includes(name) ? "off" : "on";
|
|
135
|
+
return `${name}: ${state} (${(view.stacks[name] ?? []).length} skills)`;
|
|
136
|
+
});
|
|
137
|
+
return `${formatSummary(summary)}\n${rows.join("\n")}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const usage = "Usage: /stacks [on <stack> | off <stack> | list]";
|
|
141
|
+
|
|
142
|
+
async function runStacksCommand(args: string, ctx: ExtensionCommandContext) {
|
|
143
|
+
const view = loadView(ctx.cwd);
|
|
144
|
+
warnMissingSkills(ctx, view);
|
|
145
|
+
const trimmed = args.trim();
|
|
146
|
+
|
|
147
|
+
if (trimmed === "") {
|
|
148
|
+
if (ctx.mode !== "tui") {
|
|
149
|
+
ctx.ui.notify(listStacks(view), "info");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const result = await showStacksOverlay(
|
|
153
|
+
ctx,
|
|
154
|
+
{
|
|
155
|
+
stacks: view.stacks,
|
|
156
|
+
disabledStacks: view.disabledStacks,
|
|
157
|
+
discovered: view.discovered,
|
|
158
|
+
projectStackNames: view.projectStackNames,
|
|
159
|
+
skillContents: readSkillContents(view.discovered),
|
|
160
|
+
},
|
|
161
|
+
(stacks, disabledStacks) => persistStacksState(view, stacks, disabledStacks),
|
|
162
|
+
);
|
|
163
|
+
if (result.outcome) ctx.ui.notify(formatSummary(result.outcome.summary), "info");
|
|
164
|
+
if (result.settingsDirty) await ctx.reload();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (view.stackNames.length === 0) {
|
|
169
|
+
ctx.ui.notify(`No stacks configured. Run /stacks and press n, or edit ${globalConfigPath()}`, "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (trimmed === "list") {
|
|
174
|
+
ctx.ui.notify(listStacks(view), "info");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const match = /^(on|off)\s+(\S+)$/.exec(trimmed);
|
|
179
|
+
if (!match) {
|
|
180
|
+
ctx.ui.notify(usage, "warning");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const [, action, name] = match;
|
|
184
|
+
if (!view.stackNames.includes(name)) {
|
|
185
|
+
ctx.ui.notify(`Unknown stack "${name}". Stacks: ${view.stackNames.join(", ")}`, "warning");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const isOff = view.disabledStacks.includes(name);
|
|
189
|
+
if ((action === "off") === isOff) {
|
|
190
|
+
ctx.ui.notify(`Stack "${name}" is already ${isOff ? "off" : "on"}`, "info");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const next =
|
|
194
|
+
action === "off"
|
|
195
|
+
? sortNames([...view.disabledStacks, name])
|
|
196
|
+
: view.disabledStacks.filter((entry) => entry !== name);
|
|
197
|
+
const outcome = persistStacksState(view, view.stacks, next);
|
|
198
|
+
ctx.ui.notify(formatSummary(outcome.summary), "info");
|
|
199
|
+
if (outcome.settingsChanged) await ctx.reload();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Completions fire on every keystroke and have no ctx; reuse one disk scan
|
|
203
|
+
// for a short window instead of rescanning the skill roots per key.
|
|
204
|
+
let completionCache: { at: number; view: StacksView | undefined } | undefined;
|
|
205
|
+
function completionView() {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
if (!completionCache || now - completionCache.at > 2_000) {
|
|
208
|
+
let view: StacksView | undefined;
|
|
209
|
+
try {
|
|
210
|
+
view = loadView(process.cwd()); // pi runs in the session cwd
|
|
211
|
+
} catch {
|
|
212
|
+
view = undefined;
|
|
213
|
+
}
|
|
214
|
+
completionCache = { at: now, view };
|
|
215
|
+
}
|
|
216
|
+
return completionCache.view;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export default function skillStacks(pi: ExtensionAPI) {
|
|
220
|
+
pi.registerCommand("stacks", {
|
|
221
|
+
description: "Open the stacks overlay (or: on|off <stack>, list)",
|
|
222
|
+
getArgumentCompletions: (prefix: string) => {
|
|
223
|
+
const view = completionView();
|
|
224
|
+
if (!view) return null;
|
|
225
|
+
const disabled = new Set(view.disabledStacks);
|
|
226
|
+
const stackItems = (action: "on" | "off") =>
|
|
227
|
+
view.stackNames
|
|
228
|
+
.filter((name) => disabled.has(name) === (action === "on"))
|
|
229
|
+
.map((name) => ({ value: `${action} ${name}`, label: name }));
|
|
230
|
+
const items = prefix.startsWith("on ")
|
|
231
|
+
? stackItems("on")
|
|
232
|
+
: prefix.startsWith("off ")
|
|
233
|
+
? stackItems("off")
|
|
234
|
+
: [
|
|
235
|
+
{ value: "on", label: "on <stack> - enable a stack" },
|
|
236
|
+
{ value: "off", label: "off <stack> - disable a stack" },
|
|
237
|
+
{ value: "list", label: "list - show current state" },
|
|
238
|
+
];
|
|
239
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix));
|
|
240
|
+
return filtered.length > 0 ? filtered : null;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
handler: async (args, ctx) => {
|
|
244
|
+
try {
|
|
245
|
+
await runStacksCommand(args, ctx);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof ConfigError) {
|
|
248
|
+
ctx.ui.notify(`skill-stacks: ${error.message} — nothing was written`, "error");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|