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,330 @@
|
|
|
1
|
+
// Pure state machine behind the /stacks overlay: selection, scrolling, and
|
|
2
|
+
// the mutation operations (toggle stack on/off, add/remove members,
|
|
3
|
+
// create/delete stacks). No I/O and no TUI here — extensions/overlay.ts
|
|
4
|
+
// renders it and persists each mutation through a callback.
|
|
5
|
+
//
|
|
6
|
+
// The model holds the MERGED stack map (global + project). Project-defined
|
|
7
|
+
// stacks are visible and toggleable but their membership is read-only; edits
|
|
8
|
+
// to them would be shadowed by the project config on the next merge.
|
|
9
|
+
|
|
10
|
+
import { renderMarkdown, type MarkdownStyler } from "./markdown.ts";
|
|
11
|
+
import { computeExcludedSkills, sortNames, type DiscoveredSkills, type StackMap } from "./core.ts";
|
|
12
|
+
|
|
13
|
+
export type OverlayFocus = "stacks" | "members" | "viewer";
|
|
14
|
+
|
|
15
|
+
export interface StacksOverlayInit {
|
|
16
|
+
stacks: StackMap;
|
|
17
|
+
disabledStacks: string[];
|
|
18
|
+
discovered: DiscoveredSkills;
|
|
19
|
+
projectStackNames?: ReadonlySet<string>;
|
|
20
|
+
/** Skill name → full SKILL.md text (frontmatter included); powers the viewer pane. */
|
|
21
|
+
skillContents?: ReadonlyMap<string, string>;
|
|
22
|
+
/** How viewer text is styled; defaults to plain. */
|
|
23
|
+
styler?: MarkdownStyler;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StackRow {
|
|
27
|
+
name: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
project: boolean;
|
|
30
|
+
/** Discovered members only. */
|
|
31
|
+
found: number;
|
|
32
|
+
total: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MemberRow {
|
|
36
|
+
name: string;
|
|
37
|
+
/** In the stack definition but not on disk. */
|
|
38
|
+
missing: boolean;
|
|
39
|
+
/** False when the skill is currently excluded (no enabled stack has it). */
|
|
40
|
+
active: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Window<T> {
|
|
44
|
+
start: number;
|
|
45
|
+
items: T[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type MembershipChange = "added" | "removed" | "blocked";
|
|
49
|
+
export type DeleteResult = "deleted" | "blocked" | "none";
|
|
50
|
+
|
|
51
|
+
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
|
52
|
+
|
|
53
|
+
/** Scroll offset that keeps `index` inside a window of `rows` starting at `offset`. */
|
|
54
|
+
function followIndex(offset: number, index: number, rows: number) {
|
|
55
|
+
const visible = Math.max(1, rows);
|
|
56
|
+
if (index < offset) return index;
|
|
57
|
+
if (index >= offset + visible) return index - visible + 1;
|
|
58
|
+
return offset;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class StacksOverlayModel {
|
|
62
|
+
focus: OverlayFocus = "stacks";
|
|
63
|
+
stackIndex = 0;
|
|
64
|
+
private stackOffset = 0;
|
|
65
|
+
memberIndex = 0;
|
|
66
|
+
private memberOffset = 0;
|
|
67
|
+
private viewerOffset = 0;
|
|
68
|
+
|
|
69
|
+
private readonly discovered: DiscoveredSkills;
|
|
70
|
+
private readonly projectStackNames: ReadonlySet<string>;
|
|
71
|
+
private readonly skillContents: ReadonlyMap<string, string>;
|
|
72
|
+
private readonly styler: MarkdownStyler;
|
|
73
|
+
private stackMap: StackMap;
|
|
74
|
+
private names: string[];
|
|
75
|
+
private disabled: Set<string>;
|
|
76
|
+
private excluded: Set<string>;
|
|
77
|
+
private viewerCache: { width: number; skill: string; lines: string[] } | undefined;
|
|
78
|
+
|
|
79
|
+
constructor(init: StacksOverlayInit) {
|
|
80
|
+
this.discovered = init.discovered;
|
|
81
|
+
this.projectStackNames = init.projectStackNames ?? new Set();
|
|
82
|
+
this.skillContents = init.skillContents ?? new Map();
|
|
83
|
+
this.styler = init.styler ?? { fg: (_color, text) => text, bold: (text) => text };
|
|
84
|
+
this.stackMap = copyStacks(init.stacks);
|
|
85
|
+
this.names = sortNames(Object.keys(this.stackMap));
|
|
86
|
+
this.disabled = new Set(init.disabledStacks.filter((name) => this.names.includes(name)));
|
|
87
|
+
this.excluded = computeExcludedSkills(this.stackMap, [...this.disabled]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- state accessors ----
|
|
91
|
+
|
|
92
|
+
get stackCount() {
|
|
93
|
+
return this.names.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get selectedStack(): string | undefined {
|
|
97
|
+
return this.names[this.stackIndex];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Discovered skills that are not excluded right now. */
|
|
101
|
+
get activeSkillCount() {
|
|
102
|
+
let count = 0;
|
|
103
|
+
for (const name of this.discovered.keys()) if (!this.excluded.has(name)) count += 1;
|
|
104
|
+
return count;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get discoveredCount() {
|
|
108
|
+
return this.discovered.size;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stackList() {
|
|
112
|
+
return [...this.names];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
isDisabled(name: string) {
|
|
116
|
+
return this.disabled.has(name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
isProjectStack(name: string) {
|
|
120
|
+
return this.projectStackNames.has(name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
membersOf(name: string) {
|
|
124
|
+
return sortNames(this.stackMap[name] ?? []);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
isActiveSkill(name: string) {
|
|
128
|
+
return !this.excluded.has(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Discovered skills that no stack (enabled or not) contains, sorted. */
|
|
132
|
+
unstackedSkills() {
|
|
133
|
+
const stacked = new Set(Object.values(this.stackMap).flat());
|
|
134
|
+
return sortNames([...this.discovered.keys()].filter((skill) => !stacked.has(skill)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Deep copy for persistence; disabled list sorted for stable config output. */
|
|
138
|
+
snapshot() {
|
|
139
|
+
return { stacks: copyStacks(this.stackMap), disabledStacks: sortNames(this.disabled) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- navigation ----
|
|
143
|
+
|
|
144
|
+
setFocus(focus: OverlayFocus) {
|
|
145
|
+
this.focus = focus;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
moveStack(delta: number, rows: number) {
|
|
149
|
+
if (this.names.length === 0) return;
|
|
150
|
+
this.stackIndex = clamp(this.stackIndex + delta, 0, this.names.length - 1);
|
|
151
|
+
this.stackOffset = followIndex(this.stackOffset, this.stackIndex, rows);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
moveMember(delta: number, rows: number) {
|
|
155
|
+
const total = this.selectedMembers.length;
|
|
156
|
+
if (total === 0) return;
|
|
157
|
+
this.memberIndex = clamp(this.memberIndex + delta, 0, total - 1);
|
|
158
|
+
this.memberOffset = followIndex(this.memberOffset, this.memberIndex, rows);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- windows for rendering ----
|
|
162
|
+
|
|
163
|
+
stackWindow(rows: number): Window<StackRow> {
|
|
164
|
+
const visible = Math.max(1, rows);
|
|
165
|
+
const start = clamp(this.stackOffset, 0, Math.max(0, this.names.length - visible));
|
|
166
|
+
return {
|
|
167
|
+
start,
|
|
168
|
+
items: this.names.slice(start, start + visible).map((name) => this.stackRow(name)),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
stackRow(name: string): StackRow {
|
|
173
|
+
const members = this.stackMap[name] ?? [];
|
|
174
|
+
return {
|
|
175
|
+
name,
|
|
176
|
+
enabled: !this.disabled.has(name),
|
|
177
|
+
project: this.projectStackNames.has(name),
|
|
178
|
+
found: members.filter((skill) => this.discovered.has(skill)).length,
|
|
179
|
+
total: members.length,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
memberWindow(rows: number): Window<MemberRow> {
|
|
184
|
+
const visible = Math.max(1, rows);
|
|
185
|
+
const start = clamp(this.memberOffset, 0, Math.max(0, this.selectedMembers.length - visible));
|
|
186
|
+
return {
|
|
187
|
+
start,
|
|
188
|
+
items: this.selectedMembers.slice(start, start + visible).map((name) => ({
|
|
189
|
+
name,
|
|
190
|
+
missing: !this.discovered.has(name),
|
|
191
|
+
active: this.isActiveSkill(name),
|
|
192
|
+
})),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The member under the cursor, or undefined for an empty pane. */
|
|
197
|
+
get selectedMember(): string | undefined {
|
|
198
|
+
return this.selectedMembers[this.memberIndex];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** True while the viewer pane is open (it is visible exactly when focused). */
|
|
202
|
+
get viewerOpen() {
|
|
203
|
+
return this.focus === "viewer";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Open the viewer on the selected member. False when nothing viewable is selected. */
|
|
207
|
+
openViewer(): boolean {
|
|
208
|
+
const skill = this.selectedMember;
|
|
209
|
+
if (!skill || !this.discovered.has(skill)) return false;
|
|
210
|
+
this.focus = "viewer";
|
|
211
|
+
this.viewerOffset = 0;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
closeViewer() {
|
|
216
|
+
this.focus = "members";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The selected member's SKILL.md, rendered to styled lines at `width` columns (cached). */
|
|
220
|
+
viewerLines(width: number): string[] {
|
|
221
|
+
const w = Math.max(1, Math.floor(width));
|
|
222
|
+
const skill = this.selectedMember ?? "";
|
|
223
|
+
if (!this.viewerCache || this.viewerCache.width !== w || this.viewerCache.skill !== skill) {
|
|
224
|
+
this.viewerCache = {
|
|
225
|
+
width: w,
|
|
226
|
+
skill,
|
|
227
|
+
lines: renderMarkdown(this.skillContents.get(skill) ?? "", w, this.styler),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return this.viewerCache.lines;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
moveViewer(delta: number, width: number, rows: number) {
|
|
234
|
+
const total = this.viewerLines(width).length;
|
|
235
|
+
const maxStart = Math.max(0, total - Math.max(1, rows));
|
|
236
|
+
this.viewerOffset = clamp(this.viewerOffset + delta, 0, maxStart);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
viewerWindow(width: number, rows: number): Window<string> {
|
|
240
|
+
const visible = Math.max(1, rows);
|
|
241
|
+
const lines = this.viewerLines(width);
|
|
242
|
+
const start = clamp(this.viewerOffset, 0, Math.max(0, lines.length - visible));
|
|
243
|
+
return { start, items: lines.slice(start, start + visible) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- mutations (each leaves the model consistent; caller persists) ----
|
|
247
|
+
|
|
248
|
+
/** Flip the selected stack on/off. Always changes state. */
|
|
249
|
+
toggleStack() {
|
|
250
|
+
const name = this.selectedStack;
|
|
251
|
+
if (!name) return false;
|
|
252
|
+
if (this.disabled.has(name)) this.disabled.delete(name);
|
|
253
|
+
else this.disabled.add(name);
|
|
254
|
+
this.recomputeExcluded();
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Remove the member under the cursor from the selected stack. Blocked for project-defined stacks. */
|
|
259
|
+
removeMember(): MembershipChange {
|
|
260
|
+
const stack = this.selectedStack;
|
|
261
|
+
if (!stack || this.projectStackNames.has(stack)) return "blocked";
|
|
262
|
+
const skill = this.selectedMembers[this.memberIndex];
|
|
263
|
+
if (!skill) return "blocked";
|
|
264
|
+
this.stackMap[stack] = (this.stackMap[stack] ?? []).filter((entry) => entry !== skill);
|
|
265
|
+
this.afterMembershipChange();
|
|
266
|
+
return "removed";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Add skills to the selected stack (duplicates ignored). Blocked for project stacks or an empty list. */
|
|
270
|
+
addSkills(names: readonly string[]): MembershipChange {
|
|
271
|
+
const stack = this.selectedStack;
|
|
272
|
+
if (!stack || this.projectStackNames.has(stack)) return "blocked";
|
|
273
|
+
const current = this.stackMap[stack] ?? [];
|
|
274
|
+
const fresh = [...new Set(names)].filter((name) => !current.includes(name));
|
|
275
|
+
if (fresh.length === 0) return "blocked";
|
|
276
|
+
this.stackMap[stack] = [...current, ...fresh];
|
|
277
|
+
this.afterMembershipChange();
|
|
278
|
+
return "added";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Create an empty stack and select it. False when the name is taken. */
|
|
282
|
+
createStack(name: string) {
|
|
283
|
+
const trimmed = name.trim();
|
|
284
|
+
if (!trimmed || trimmed in this.stackMap) return false;
|
|
285
|
+
this.stackMap[trimmed] = [];
|
|
286
|
+
this.names = sortNames(Object.keys(this.stackMap));
|
|
287
|
+
this.stackIndex = this.names.indexOf(trimmed);
|
|
288
|
+
this.stackOffset = followIndex(this.stackOffset, this.stackIndex, 1);
|
|
289
|
+
this.resetMemberCursor();
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Remove the selected stack's definition (and any disabled entry). */
|
|
294
|
+
deleteSelectedStack(): DeleteResult {
|
|
295
|
+
const name = this.selectedStack;
|
|
296
|
+
if (!name) return "none";
|
|
297
|
+
if (this.projectStackNames.has(name)) return "blocked";
|
|
298
|
+
delete this.stackMap[name];
|
|
299
|
+
this.names = sortNames(Object.keys(this.stackMap));
|
|
300
|
+
this.disabled.delete(name);
|
|
301
|
+
this.stackIndex = clamp(this.stackIndex, 0, Math.max(0, this.names.length - 1));
|
|
302
|
+
this.resetMemberCursor();
|
|
303
|
+
this.recomputeExcluded();
|
|
304
|
+
return "deleted";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---- internals ----
|
|
308
|
+
|
|
309
|
+
private get selectedMembers() {
|
|
310
|
+
const stack = this.selectedStack;
|
|
311
|
+
return stack ? this.membersOf(stack) : [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private recomputeExcluded() {
|
|
315
|
+
this.excluded = computeExcludedSkills(this.stackMap, [...this.disabled]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private afterMembershipChange() {
|
|
319
|
+
this.recomputeExcluded();
|
|
320
|
+
this.memberIndex = clamp(this.memberIndex, 0, Math.max(0, this.selectedMembers.length - 1));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private resetMemberCursor() {
|
|
324
|
+
this.memberIndex = 0;
|
|
325
|
+
this.memberOffset = 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const copyStacks = (stacks: StackMap): StackMap =>
|
|
330
|
+
Object.fromEntries(Object.entries(stacks).map(([name, skills]) => [name, [...skills]]));
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// File I/O for skill stacks: the extension's own config, skill discovery on
|
|
2
|
+
// disk, and surgical edits to the settings.json `skills` array.
|
|
3
|
+
//
|
|
4
|
+
// Global config lives at <agentDir>/skill-stacks.json. A project may add or
|
|
5
|
+
// override stack definitions in <cwd>/.pi/skill-stacks.json (stacks only;
|
|
6
|
+
// on/off state and managed exclusions stay global).
|
|
7
|
+
//
|
|
8
|
+
// Malformed files throw a ConfigError rather than degrading to an empty
|
|
9
|
+
// config: every load here sits in front of a write, and defaulting would let
|
|
10
|
+
// one bad entry erase the user's stacks or settings.
|
|
11
|
+
//
|
|
12
|
+
// Editing settings.json directly is safe alongside pi: SettingsManager
|
|
13
|
+
// persists with a read-modify-write that only touches fields modified through
|
|
14
|
+
// its own setters, so an external `skills` change survives unless the user
|
|
15
|
+
// also edits skills via `pi config` in the same session.
|
|
16
|
+
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { basename, join, relative, sep } from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
mergeStacks,
|
|
22
|
+
summarizeStacks,
|
|
23
|
+
type DiscoveredSkills,
|
|
24
|
+
type StackMap,
|
|
25
|
+
} from "./core.ts";
|
|
26
|
+
|
|
27
|
+
export interface StacksConfig {
|
|
28
|
+
stacks: StackMap;
|
|
29
|
+
disabledStacks: string[];
|
|
30
|
+
managedExclusions: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A skill root plus the baseDir pi matches exclusion patterns against (its parent). */
|
|
34
|
+
export interface SkillRoot {
|
|
35
|
+
dir: string;
|
|
36
|
+
baseDir: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ConfigError extends Error {
|
|
40
|
+
constructor(path: string, detail: string) {
|
|
41
|
+
super(`${path}: ${detail}`);
|
|
42
|
+
this.name = "ConfigError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Same lookup as pi's getAgentDir(): PI_CODING_AGENT_DIR override, else ~/.pi/agent. */
|
|
47
|
+
export function agentDir() {
|
|
48
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
49
|
+
if (override) return override.startsWith("~") ? join(homedir(), override.slice(1)) : override;
|
|
50
|
+
return join(homedir(), ".pi", "agent");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const globalConfigPath = () => join(agentDir(), "skill-stacks.json");
|
|
54
|
+
export const globalSettingsPath = () => join(agentDir(), "settings.json");
|
|
55
|
+
export const projectConfigPath = (cwd: string) => join(cwd, ".pi", "skill-stacks.json");
|
|
56
|
+
|
|
57
|
+
/** The skill roots this extension manages, with the baseDir patterns are relative to. */
|
|
58
|
+
export function defaultSkillRoots(): SkillRoot[] {
|
|
59
|
+
const agents = join(homedir(), ".agents");
|
|
60
|
+
return [
|
|
61
|
+
{ dir: join(agents, "skills"), baseDir: agents },
|
|
62
|
+
{ dir: join(agentDir(), "skills"), baseDir: agentDir() },
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isStringArray = (value: unknown): value is string[] =>
|
|
67
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
68
|
+
|
|
69
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
70
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
|
|
72
|
+
function parseStackMap(path: string, value: unknown): StackMap {
|
|
73
|
+
if (value === undefined) return {};
|
|
74
|
+
if (!isRecord(value)) throw new ConfigError(path, "`stacks` must be an object of name → skill names");
|
|
75
|
+
const stacks: StackMap = {};
|
|
76
|
+
for (const [name, skills] of Object.entries(value)) {
|
|
77
|
+
if (!isStringArray(skills)) {
|
|
78
|
+
throw new ConfigError(path, `stack "${name}" must be an array of skill names`);
|
|
79
|
+
}
|
|
80
|
+
stacks[name] = skills;
|
|
81
|
+
}
|
|
82
|
+
return stacks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseNameList(path: string, key: string, value: unknown) {
|
|
86
|
+
if (value === undefined) return [];
|
|
87
|
+
if (!isStringArray(value)) throw new ConfigError(path, `\`${key}\` must be an array of strings`);
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parsed JSON, undefined when the file is absent. Throws ConfigError on unreadable JSON. */
|
|
92
|
+
function readJson(path: string): unknown {
|
|
93
|
+
if (!existsSync(path)) return undefined;
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new ConfigError(path, `not valid JSON (${error instanceof Error ? error.message : error})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readJsonObject(path: string) {
|
|
102
|
+
const raw = readJson(path);
|
|
103
|
+
if (raw === undefined) return undefined;
|
|
104
|
+
if (!isRecord(raw)) throw new ConfigError(path, "top level must be a JSON object");
|
|
105
|
+
return raw;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function loadStacksConfig(path = globalConfigPath()): StacksConfig {
|
|
109
|
+
const record = readJsonObject(path);
|
|
110
|
+
if (!record) return { stacks: {}, disabledStacks: [], managedExclusions: [] };
|
|
111
|
+
// disabledStacks/managedExclusions are kept even when stacks is empty: the
|
|
112
|
+
// user may run only project-defined stacks, and their on/off state lives here.
|
|
113
|
+
return {
|
|
114
|
+
stacks: parseStackMap(path, record.stacks),
|
|
115
|
+
disabledStacks: parseNameList(path, "disabledStacks", record.disabledStacks),
|
|
116
|
+
managedExclusions: parseNameList(path, "managedExclusions", record.managedExclusions),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function saveStacksConfig(path: string, config: StacksConfig) {
|
|
121
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stack definitions from <cwd>/.pi/skill-stacks.json, or undefined when absent or empty. */
|
|
125
|
+
export function loadProjectStacks(cwd: string) {
|
|
126
|
+
const path = projectConfigPath(cwd);
|
|
127
|
+
const record = readJsonObject(path);
|
|
128
|
+
if (!record) return undefined;
|
|
129
|
+
const stacks = parseStackMap(path, record.stacks);
|
|
130
|
+
return Object.keys(stacks).length > 0 ? stacks : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const toPosix = (path: string) => (sep === "/" ? path : path.split(sep).join("/"));
|
|
134
|
+
|
|
135
|
+
/** `name:` from YAML frontmatter, if present; pi falls back to the directory name. */
|
|
136
|
+
function frontmatterName(skillFile: string) {
|
|
137
|
+
try {
|
|
138
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(skillFile, "utf-8"));
|
|
139
|
+
const nameLine = match?.[1].split(/\r?\n/).find((line) => /^name\s*:/.test(line));
|
|
140
|
+
const name = nameLine?.replace(/^name\s*:\s*/, "").trim().replace(/^["']|["']$/g, "");
|
|
141
|
+
return name || undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Mirrors pi's loadSkillsFromDir: a directory with SKILL.md is a skill and is
|
|
148
|
+
// not recursed into; otherwise recurse, skipping dot-dirs and node_modules and
|
|
149
|
+
// following symlinks. Broken symlinks are skipped.
|
|
150
|
+
function collectSkills(dir: string, baseDir: string, into: Map<string, string>) {
|
|
151
|
+
const entries = readDirEntries(dir);
|
|
152
|
+
const skillFile = join(dir, "SKILL.md");
|
|
153
|
+
if (entries.some((entry) => entry.name === "SKILL.md") && isFile(skillFile)) {
|
|
154
|
+
const name = frontmatterName(skillFile) ?? basename(dir);
|
|
155
|
+
if (!into.has(name)) into.set(name, toPosix(relative(baseDir, skillFile)));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
160
|
+
const full = join(dir, entry.name);
|
|
161
|
+
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(full))) {
|
|
162
|
+
collectSkills(full, baseDir, into);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readDirEntries(dir: string) {
|
|
168
|
+
try {
|
|
169
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
170
|
+
} catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isFile(path: string) {
|
|
176
|
+
try {
|
|
177
|
+
return statSync(path).isFile();
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isDirectory(path: string) {
|
|
184
|
+
try {
|
|
185
|
+
return statSync(path).isDirectory();
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Every skill pi would discover under the given roots, as name → baseDir-relative
|
|
193
|
+
* SKILL.md path (the form exclusion patterns take). First root wins on a name
|
|
194
|
+
* clash, matching pi's "keep the first one found" rule. Missing roots are skipped.
|
|
195
|
+
*/
|
|
196
|
+
export function discoverSkills(roots: SkillRoot[] = defaultSkillRoots()): DiscoveredSkills {
|
|
197
|
+
const skills = new Map<string, string>();
|
|
198
|
+
for (const root of roots) {
|
|
199
|
+
if (existsSync(root.dir)) collectSkills(root.dir, root.baseDir, skills);
|
|
200
|
+
}
|
|
201
|
+
return skills;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Full SKILL.md text per discovered skill, for the overlay's viewer pane.
|
|
206
|
+
* Roots are tried in discovery order, so a name clash resolves the same way
|
|
207
|
+
* discovery did. Unreadable skills map to "".
|
|
208
|
+
*/
|
|
209
|
+
export function readSkillContents(
|
|
210
|
+
discovered: DiscoveredSkills,
|
|
211
|
+
roots: SkillRoot[] = defaultSkillRoots(),
|
|
212
|
+
): Map<string, string> {
|
|
213
|
+
const contents = new Map<string, string>();
|
|
214
|
+
const done = new Set<string>();
|
|
215
|
+
for (const root of roots) {
|
|
216
|
+
for (const [name, relativePath] of discovered) {
|
|
217
|
+
if (done.has(name)) continue;
|
|
218
|
+
const full = join(root.baseDir, relativePath);
|
|
219
|
+
if (isFile(full)) {
|
|
220
|
+
contents.set(name, readFileSync(full, "utf-8"));
|
|
221
|
+
done.add(name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return contents;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function readSettingsSkills(path = globalSettingsPath()) {
|
|
229
|
+
const record = readJsonObject(path);
|
|
230
|
+
const skills = record?.skills;
|
|
231
|
+
if (skills !== undefined && !isStringArray(skills)) {
|
|
232
|
+
throw new ConfigError(path, "`skills` must be an array of strings");
|
|
233
|
+
}
|
|
234
|
+
return skills ?? [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Replace the `skills` array in settings.json, leaving every other key as-is.
|
|
239
|
+
* Written without a trailing newline to match pi's own SettingsManager output,
|
|
240
|
+
* so a subsequent pi save doesn't produce a spurious diff.
|
|
241
|
+
*/
|
|
242
|
+
export function updateSettingsSkills(path: string, skills: string[]) {
|
|
243
|
+
const settings = readJsonObject(path) ?? {};
|
|
244
|
+
settings.skills = skills;
|
|
245
|
+
writeFileSync(path, JSON.stringify(settings, null, 2), "utf-8");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Fresh-from-disk summary for the compact `[Skills]` header line.
|
|
250
|
+
* Returns undefined when no stacks are configured (leave pi's section alone).
|
|
251
|
+
* Throws ConfigError on malformed config; the header catches and falls back.
|
|
252
|
+
*/
|
|
253
|
+
export function loadStacksSummary(cwd: string) {
|
|
254
|
+
const global = loadStacksConfig();
|
|
255
|
+
const stacks = mergeStacks(global.stacks, loadProjectStacks(cwd));
|
|
256
|
+
if (Object.keys(stacks).length === 0) return undefined;
|
|
257
|
+
return summarizeStacks(stacks, global.disabledStacks, discoverSkills());
|
|
258
|
+
}
|