simple-skills-manager 1.0.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/CHANGELOG.md +100 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/config.ts +736 -0
- package/docs/config.md +117 -0
- package/docs/discovery.md +93 -0
- package/index.ts +419 -0
- package/menu.ts +1292 -0
- package/package.json +31 -0
- package/scan.ts +397 -0
- package/simple-skills-manager.example.json +114 -0
- package/tests/fixtures/bad/malformed/SKILL.md +5 -0
- package/tests/fixtures/bad/unparseable/SKILL.md +7 -0
- package/tests/fixtures/single-skill/SKILL.md +8 -0
- package/tests/fixtures/single-skill/references/notes.md +1 -0
- package/tests/fixtures/skills-dir/dupe-a/SKILL.md +6 -0
- package/tests/fixtures/skills-dir/dupe-b/SKILL.md +6 -0
- package/tests/fixtures/skills-dir/grouped/doc/SKILL.md +8 -0
- package/tests/fixtures/skills-dir/grouped/scripted/SKILL.md +8 -0
- package/tests/fixtures/skills-dir/grouped/scripted/scripts/run.sh +2 -0
- package/tests/fixtures/skills-dir/hidden/SKILL.md +9 -0
- package/tests/fixtures/skills-dir/plain/SKILL.md +8 -0
- package/tests/smoke-extension.ts +577 -0
- package/tests/smoke.sh +247 -0
- package/tsconfig.json +20 -0
- package/util.ts +74 -0
package/menu.ts
ADDED
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /skills-manager TUI: the library tree navigator (browse + exposure
|
|
3
|
+
* checkboxes), adding skills to the store (editor / .md import / directory
|
|
4
|
+
* import), adding roots (guided or adopting a native pi location), the
|
|
5
|
+
* scan-and-approve ceremony, enabling, refreshing, removal, and extension
|
|
6
|
+
* settings. Every mutating action validates through config.ts, saves
|
|
7
|
+
* through the atomic writer, and tells the caller to reload pi.
|
|
8
|
+
*
|
|
9
|
+
* Native pi roots are rendered honestly: pi exposes their skills through
|
|
10
|
+
* its own discovery whether the manager likes it or not, so their leaves
|
|
11
|
+
* are red and locked (mirrored for visibility) rather than toggleable,
|
|
12
|
+
* with the footer explaining how to revoke native exposure (remove the
|
|
13
|
+
* files, or launch pi with --no-skills).
|
|
14
|
+
*
|
|
15
|
+
* The core (registration and the scan cache) lives in index.ts; this
|
|
16
|
+
* module only manages the single config file and the store interactively.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { lstat, mkdir, readFile, rm } from "node:fs/promises";
|
|
21
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
22
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { DynamicBorder, getMarkdownTheme, getSettingsListTheme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Container, Markdown, matchesKey, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
25
|
+
import {
|
|
26
|
+
CONFIG_FILE,
|
|
27
|
+
CONFIG_PATH,
|
|
28
|
+
DEFAULT_SETTINGS,
|
|
29
|
+
MAX_SKILL_NAME_CHARS,
|
|
30
|
+
SKILL_NAME_RE,
|
|
31
|
+
STORE_DIR,
|
|
32
|
+
computeExposedSkills,
|
|
33
|
+
expandUserPath,
|
|
34
|
+
findRoot,
|
|
35
|
+
findStoreRoot,
|
|
36
|
+
fingerprint,
|
|
37
|
+
isNativePath,
|
|
38
|
+
loadManagerConfig,
|
|
39
|
+
manifestSkills,
|
|
40
|
+
mutablePathWarning,
|
|
41
|
+
nativeDiscoveryWarning,
|
|
42
|
+
removeRootEntry,
|
|
43
|
+
resolvedRootPath,
|
|
44
|
+
saveManagerConfig,
|
|
45
|
+
scanSummary,
|
|
46
|
+
skillFilePath,
|
|
47
|
+
skillGroup,
|
|
48
|
+
upsertRoot,
|
|
49
|
+
validateRoot,
|
|
50
|
+
writeConfigText,
|
|
51
|
+
type ManagerConfig,
|
|
52
|
+
type ManagedRoot,
|
|
53
|
+
type RootConfig,
|
|
54
|
+
type SkillManifest,
|
|
55
|
+
type SkillSnapshot,
|
|
56
|
+
} from "./config.ts";
|
|
57
|
+
import { copyTreeInto, parseFrontmatter, renderDirTree, scanRoot, snapshotSkillFile } from "./scan.ts";
|
|
58
|
+
import { bulletList, errText, sanitizeExternalText, stripJsonComments, writeFileAtomic } from "./util.ts";
|
|
59
|
+
|
|
60
|
+
// ─── TUI helpers ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function showMarkdown(title: string, content: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
63
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
64
|
+
const container = new Container();
|
|
65
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
66
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
67
|
+
container.addChild(new Markdown(content, 1, 1, getMarkdownTheme()));
|
|
68
|
+
container.addChild(new Text(theme.fg("dim", "Enter or Esc to close"), 1, 0));
|
|
69
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
70
|
+
return {
|
|
71
|
+
render: (width: number) => container.render(width),
|
|
72
|
+
invalidate: () => container.invalidate(),
|
|
73
|
+
handleInput: (data: string) => {
|
|
74
|
+
if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Surface a validation rejection as a markdown issue list. */
|
|
81
|
+
async function showIssues(
|
|
82
|
+
ctx: ExtensionCommandContext,
|
|
83
|
+
title: string,
|
|
84
|
+
issues: string[],
|
|
85
|
+
note?: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
await showMarkdown(title, `${bulletList(issues)}${note ? `\n\n${note}` : ""}`, ctx);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function loadConfigForMenu(ctx: ExtensionCommandContext): Promise<ManagerConfig | undefined> {
|
|
91
|
+
try {
|
|
92
|
+
return await loadManagerConfig();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const reason = errText(error);
|
|
95
|
+
await showMarkdown(
|
|
96
|
+
"simple-skills-manager configuration error",
|
|
97
|
+
`The config file could not be loaded:\n\n\`\`\`text\n${sanitizeExternalText(reason)}\n\`\`\`\n\n` +
|
|
98
|
+
`Fix the file at \`${CONFIG_PATH}\` and retry. You can open it for editing from this menu ` +
|
|
99
|
+
`(**Extension settings → Edit the config file**).`,
|
|
100
|
+
ctx,
|
|
101
|
+
);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The set of skill instruction files pi's own native discovery is exposing
|
|
107
|
+
* right now (live mirror detection). */
|
|
108
|
+
function nativeActivePaths(ctx: ExtensionCommandContext): Set<string> {
|
|
109
|
+
const skills = ctx.getSystemPromptOptions()?.skills ?? [];
|
|
110
|
+
return new Set(skills.map((skill) => skill.filePath ?? ""));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function rootState(root: ManagedRoot): string {
|
|
114
|
+
if (root.issues.length) return "invalid";
|
|
115
|
+
return root.config.enabled ? "enabled" : "disabled";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Exposure labels resolve ${...} templates against cwd; a template that
|
|
119
|
+
* cannot resolve yet simply shows zero exposed (it fails closed at scan). */
|
|
120
|
+
function safeRootPath(root: ManagedRoot, workspace: string): string {
|
|
121
|
+
try {
|
|
122
|
+
return resolvedRootPath(root.config, workspace);
|
|
123
|
+
} catch {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** skillFilePath that fails closed (undefined) when the root's path template
|
|
129
|
+
* cannot be resolved — the tree must never crash on an unresolved root. */
|
|
130
|
+
function safeSkillFilePath(root: ManagedRoot, snapshot: SkillSnapshot, ctx: ExtensionCommandContext): string | undefined {
|
|
131
|
+
try {
|
|
132
|
+
return skillFilePath(root.config, snapshot, ctx.cwd);
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The exposed skills of a root, failing closed to an empty list when its
|
|
139
|
+
* path template cannot be resolved yet. */
|
|
140
|
+
function safeExposedSkills(root: ManagedRoot, ctx: ExtensionCommandContext, nativeActive: Set<string>): SkillSnapshot[] {
|
|
141
|
+
try {
|
|
142
|
+
return computeExposedSkills(root.config, ctx.cwd, nativeActive);
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function rootLabel(root: ManagedRoot, ctx: ExtensionCommandContext, nativeActive: Set<string>): string {
|
|
149
|
+
const skills = manifestSkills(root.config);
|
|
150
|
+
// For native roots, "mirrored" is the honest state whenever pi is
|
|
151
|
+
// actively exposing from this location — "disabled" would suggest a
|
|
152
|
+
// control the manager does not have.
|
|
153
|
+
const state = root.config.native && nativeRootHasLive(root, ctx, nativeActive) ? "mirrored" : rootState(root);
|
|
154
|
+
const rootPath = safeRootPath(root, ctx.cwd);
|
|
155
|
+
const exposed = rootPath && root.normalized && !root.issues.length
|
|
156
|
+
? safeExposedSkills(root, ctx, nativeActive).length
|
|
157
|
+
: 0;
|
|
158
|
+
const flags = [
|
|
159
|
+
root.config.store ? "store" : "",
|
|
160
|
+
root.config.native ? "native pi" : "",
|
|
161
|
+
root.config.transport === "single-skill" ? "single-skill" : "",
|
|
162
|
+
root.auto ? "auto-detected" : "",
|
|
163
|
+
].filter(Boolean).join(", ");
|
|
164
|
+
return `${root.config.name} — ${state} · ${exposed}/${skills.length} skills${flags ? ` · ${flags}` : ""}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when pi's live native exposure includes anything under this root's path. */
|
|
168
|
+
function nativeRootHasLive(root: ManagedRoot, ctx: ExtensionCommandContext, nativeActive: Set<string>): boolean {
|
|
169
|
+
const rootPath = safeRootPath(root, ctx.cwd);
|
|
170
|
+
if (!rootPath) return false;
|
|
171
|
+
for (const filePath of nativeActive) {
|
|
172
|
+
if (filePath.startsWith(`${rootPath}/`)) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function pickRoot(ctx: ExtensionCommandContext, config: ManagerConfig, title: string, filter?: (root: ManagedRoot) => boolean): Promise<ManagedRoot | undefined> {
|
|
178
|
+
const roots = filter ? config.roots.filter(filter) : config.roots;
|
|
179
|
+
if (!roots.length) {
|
|
180
|
+
ctx.ui.notify("No matching library roots are configured — add one first", "warning");
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const nativeActive = nativeActivePaths(ctx);
|
|
184
|
+
const labels = roots.map((root) => rootLabel(root, ctx, nativeActive));
|
|
185
|
+
const selected = await ctx.ui.select(title, labels);
|
|
186
|
+
const index = selected ? labels.indexOf(selected) : -1;
|
|
187
|
+
return index >= 0 ? roots[index] : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function rootDetails(root: ManagedRoot, ctx: ExtensionCommandContext): string {
|
|
191
|
+
const config = root.config;
|
|
192
|
+
const skills = manifestSkills(config);
|
|
193
|
+
const scannedAt = typeof config.manifest?.scannedAt === "string" ? config.manifest.scannedAt : "unknown";
|
|
194
|
+
const lines = [
|
|
195
|
+
`**State:** ${config.enabled ? "enabled" : "disabled"}${root.auto ? " · auto-detected" : ""}${root.issues.length ? " (with issues)" : ""}`,
|
|
196
|
+
`**Transport:** ${config.transport}`,
|
|
197
|
+
`**Path:** \`${config.path}\``,
|
|
198
|
+
`**Store root:** ${config.store ? "yes — skills added via /skills-manager accumulate here" : "no"}`,
|
|
199
|
+
`**Native pi location:** ${config.native ? "yes — pi exposes skills here itself; the manager mirrors (red, locked), it cannot revoke" : "no"}`,
|
|
200
|
+
`**Loading:** lazy; the directory is not scanned until an approved skill is invoked`,
|
|
201
|
+
`**Approved manifest:** ${config.manifest ? `${skills.length} skills from ${scannedAt}` : "none"}`,
|
|
202
|
+
`**Exposed by the manager:** ${safeExposedSkills(root, ctx, nativeActivePaths(ctx)).map((skill) => skill.name).join(", ") || "none"}`,
|
|
203
|
+
];
|
|
204
|
+
if (config.native) {
|
|
205
|
+
const rootPath = safeRootPath(root, ctx.cwd);
|
|
206
|
+
const liveCount = rootPath
|
|
207
|
+
? [...nativeActivePaths(ctx)].filter((filePath) => filePath.startsWith(`${rootPath}/`)).length
|
|
208
|
+
: 0;
|
|
209
|
+
lines.push(`**Exposed by pi itself (right now):** ${liveCount} skill(s)${liveCount ? " — outside the manager's control while native discovery is active" : " (native discovery is off or found nothing)"}`);
|
|
210
|
+
}
|
|
211
|
+
lines.push(`**Definitions and manifests live in:** \`${CONFIG_PATH}\``);
|
|
212
|
+
if (root.issues.length) lines.push("", "### Issues", ...root.issues.map((issue) => `- ${issue}`));
|
|
213
|
+
return lines.filter(Boolean).join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── The library tree navigator ────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
type LeafKind = "normal" | "mirror" | "unscanned" | "rejected" | "issues";
|
|
219
|
+
|
|
220
|
+
type Leaf = {
|
|
221
|
+
kind: LeafKind;
|
|
222
|
+
root: ManagedRoot;
|
|
223
|
+
label: string;
|
|
224
|
+
snapshot?: SkillSnapshot;
|
|
225
|
+
reason?: string;
|
|
226
|
+
checked: boolean;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
type Row = {
|
|
230
|
+
id: string;
|
|
231
|
+
depth: 0 | 1 | 2;
|
|
232
|
+
type: "root" | "group" | "leaf";
|
|
233
|
+
label: string;
|
|
234
|
+
leaf?: Leaf;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
type TreeState = {
|
|
238
|
+
folded: Set<string>;
|
|
239
|
+
selected: number;
|
|
240
|
+
/** root name → skill name → checked; toggles not yet applied to the config. */
|
|
241
|
+
changed: Map<string, Map<string, boolean>>;
|
|
242
|
+
flash: string | undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
type TreeAction = { kind: "exit" } | { kind: "inspect"; row: Row };
|
|
246
|
+
|
|
247
|
+
/** Build the flat tree rows: roots sit flat at the top level; groups (the
|
|
248
|
+
* first path segment under a root) nest skills beneath them. Exported for
|
|
249
|
+
* the smoke test. */
|
|
250
|
+
export function buildTreeRows(config: ManagerConfig, ctx: ExtensionCommandContext, nativeActive: Set<string>): Row[] {
|
|
251
|
+
const rows: Row[] = [];
|
|
252
|
+
config.roots.forEach((root, rootIndex) => {
|
|
253
|
+
rows.push({ id: `r:${rootIndex}`, depth: 0, type: "root", label: rootLabel(root, ctx, nativeActive) });
|
|
254
|
+
if (root.issues.length && !manifestSkills(root.config).length) {
|
|
255
|
+
rows.push({
|
|
256
|
+
id: `l:${rootIndex}:issues`, depth: 1, type: "leaf",
|
|
257
|
+
label: `${root.issues.length} issue(s) — press Enter to view`,
|
|
258
|
+
leaf: { kind: "issues", root, label: "issues", checked: false, reason: bulletList(root.issues) },
|
|
259
|
+
});
|
|
260
|
+
// Native roots keep going: their unscanned skills show as red
|
|
261
|
+
// unapproved-but-live leaves — visibility is the whole point.
|
|
262
|
+
if (!root.config.native) return;
|
|
263
|
+
}
|
|
264
|
+
const rootPath = safeRootPath(root, ctx.cwd);
|
|
265
|
+
const leaves: { group: string; leaf: Leaf }[] = [];
|
|
266
|
+
for (const snapshot of manifestSkills(root.config)) {
|
|
267
|
+
const skillPath = safeSkillFilePath(root, snapshot, ctx);
|
|
268
|
+
const mirrored = root.config.native && skillPath !== undefined && nativeActive.has(skillPath);
|
|
269
|
+
leaves.push({
|
|
270
|
+
group: skillGroup(snapshot.dirPath),
|
|
271
|
+
leaf: {
|
|
272
|
+
kind: mirrored ? "mirror" : "normal",
|
|
273
|
+
root,
|
|
274
|
+
label: snapshot.name,
|
|
275
|
+
snapshot,
|
|
276
|
+
checked: mirrored
|
|
277
|
+
|| (root.config.enabled && (root.config.skills.mode === "all" || root.config.skills.include.includes(snapshot.name))),
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
for (const rejected of root.config.manifest?.rejected ?? []) {
|
|
282
|
+
leaves.push({
|
|
283
|
+
group: skillGroup(rejected.dirPath),
|
|
284
|
+
leaf: {
|
|
285
|
+
kind: "rejected",
|
|
286
|
+
root,
|
|
287
|
+
label: rejected.dirPath === "." ? "(root-level file)" : rejected.dirPath,
|
|
288
|
+
checked: false,
|
|
289
|
+
reason: rejected.reason,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (root.config.native && rootPath) {
|
|
294
|
+
// Skills pi is natively exposing from this root that are not in
|
|
295
|
+
// the approved manifest — visibility is the whole point of mirroring.
|
|
296
|
+
const known = new Set(
|
|
297
|
+
manifestSkills(root.config)
|
|
298
|
+
.map((snapshot) => safeSkillFilePath(root, snapshot, ctx))
|
|
299
|
+
.filter((path): path is string => path !== undefined),
|
|
300
|
+
);
|
|
301
|
+
const rejectedDirs = (root.config.manifest?.rejected ?? [])
|
|
302
|
+
.filter((rejected) => rejected.dirPath !== ".")
|
|
303
|
+
.map((rejected) => resolve(rootPath, rejected.dirPath));
|
|
304
|
+
for (const filePath of nativeActive) {
|
|
305
|
+
if (!filePath.startsWith(`${rootPath}/`) || known.has(filePath) || !filePath.toLowerCase().endsWith(".md")) continue;
|
|
306
|
+
const parent = resolve(dirname(filePath));
|
|
307
|
+
if (rejectedDirs.some((dir) => parent === dir || parent.startsWith(`${dir}/`))) continue;
|
|
308
|
+
leaves.push({
|
|
309
|
+
group: skillGroup(relative(rootPath, filePath)),
|
|
310
|
+
leaf: {
|
|
311
|
+
kind: "unscanned",
|
|
312
|
+
root,
|
|
313
|
+
label: relative(rootPath, filePath),
|
|
314
|
+
checked: false,
|
|
315
|
+
reason: "pi is exposing this skill natively, but it is not in the approved manifest — use Scan or refresh to adopt it",
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Emit group rows + leaf rows; "ungrouped" first, then alphabetical.
|
|
321
|
+
const groups = [...new Set(leaves.map((entry) => entry.group))].sort((a, b) =>
|
|
322
|
+
a === "ungrouped" ? -1 : b === "ungrouped" ? 1 : a.localeCompare(b));
|
|
323
|
+
for (const group of groups) {
|
|
324
|
+
rows.push({ id: `g:${rootIndex}:${group}`, depth: 1, type: "group", label: group });
|
|
325
|
+
for (const entry of leaves.filter((item) => item.group === group)) {
|
|
326
|
+
rows.push({
|
|
327
|
+
id: `l:${rootIndex}:${entry.group}:${entry.leaf.label}`,
|
|
328
|
+
depth: 2,
|
|
329
|
+
type: "leaf",
|
|
330
|
+
label: entry.leaf.label,
|
|
331
|
+
leaf: entry.leaf,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
return rows;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function visibleRows(rows: Row[], folded: Set<string>): Row[] {
|
|
340
|
+
const visible: Row[] = [];
|
|
341
|
+
let insideFoldedRoot = false;
|
|
342
|
+
let insideFoldedGroup = false;
|
|
343
|
+
for (const row of rows) {
|
|
344
|
+
if (row.type === "root") {
|
|
345
|
+
insideFoldedRoot = folded.has(row.id);
|
|
346
|
+
insideFoldedGroup = false;
|
|
347
|
+
visible.push(row);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (insideFoldedRoot) continue;
|
|
351
|
+
if (row.type === "group") {
|
|
352
|
+
insideFoldedGroup = folded.has(row.id);
|
|
353
|
+
visible.push(row);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (insideFoldedGroup) continue;
|
|
357
|
+
visible.push(row);
|
|
358
|
+
}
|
|
359
|
+
return visible;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export type { Row, Leaf };
|
|
363
|
+
export { visibleRows };
|
|
364
|
+
|
|
365
|
+
type TreeTheme = { fg: (color: ThemeColor, text: string) => string; inverse: (text: string) => string; bold: (text: string) => string };
|
|
366
|
+
|
|
367
|
+
function renderRow(row: Row, selected: boolean, theme: TreeTheme): string {
|
|
368
|
+
const indent = " ".repeat(row.depth * 2);
|
|
369
|
+
let text: string;
|
|
370
|
+
if (row.type === "root") {
|
|
371
|
+
text = `${indent}${theme.bold(row.label)}`;
|
|
372
|
+
} else if (row.type === "group") {
|
|
373
|
+
text = `${indent}${theme.fg("accent", row.label)}`;
|
|
374
|
+
} else {
|
|
375
|
+
const leaf = row.leaf!;
|
|
376
|
+
const description = leaf.snapshot ? ` — ${leaf.snapshot.description.replace(/\s+/g, " ").slice(0, 100)}` : "";
|
|
377
|
+
const hiddenNote = leaf.snapshot?.hidden ? theme.fg("muted", " ⌁ hidden from model") : "";
|
|
378
|
+
if (leaf.kind === "mirror") {
|
|
379
|
+
text = `${indent}${theme.fg("error", `[⊘] ${leaf.label}`)}${description}${theme.fg("error", " — native pi: exposed by pi's own discovery (cannot be revoked here)")}${hiddenNote}`;
|
|
380
|
+
} else if (leaf.kind === "unscanned") {
|
|
381
|
+
text = `${indent}${theme.fg("error", `[⊘] ${leaf.label}`)} ${theme.fg("error", leaf.reason ?? "")}`;
|
|
382
|
+
} else if (leaf.kind === "rejected") {
|
|
383
|
+
text = `${indent}${theme.fg("warning", `[!] ${leaf.label}`)} ${theme.fg("warning", leaf.reason ?? "")}`;
|
|
384
|
+
} else if (leaf.kind === "issues") {
|
|
385
|
+
text = `${indent}${theme.fg("warning", `[!] ${row.label}`)}`;
|
|
386
|
+
} else {
|
|
387
|
+
const body = `${indent}${leaf.checked ? "[√]" : "[ ]"} ${leaf.label}${description}${hiddenNote}`;
|
|
388
|
+
text = leaf.checked ? body : theme.fg("dim", body);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return selected ? theme.inverse(text) : text;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function treeFooter(config: ManagerConfig, nativeActive: Set<string>, flash: string | undefined, theme: TreeTheme): string[] {
|
|
395
|
+
if (flash) return [theme.fg("warning", flash)];
|
|
396
|
+
const nativeRoots = config.roots.filter((root) => root.config.native);
|
|
397
|
+
const lines = ["↑/↓ move · space toggle exposure / fold · enter inspect / fold · esc save & close"];
|
|
398
|
+
if (nativeRoots.length) {
|
|
399
|
+
lines.push(nativeActive.size > 0
|
|
400
|
+
? theme.fg("error", "red ⊘ leaves: pi exposes these natively; revoke by removing the files or launching pi with --no-skills (library-only mode)")
|
|
401
|
+
: "native discovery: OFF (library-only mode) — native-root skills are fully gated here");
|
|
402
|
+
}
|
|
403
|
+
return lines;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** One round of the tree dialog. Renders fresh on every frame (a new
|
|
407
|
+
* Container per render call), so folds, toggles, and the selection bar
|
|
408
|
+
* update live. Returns the next action for the loop. */
|
|
409
|
+
async function runTreeDialog(
|
|
410
|
+
ctx: ExtensionCommandContext,
|
|
411
|
+
config: ManagerConfig,
|
|
412
|
+
nativeActive: Set<string>,
|
|
413
|
+
state: TreeState,
|
|
414
|
+
): Promise<TreeAction> {
|
|
415
|
+
const rows = buildTreeRows(config, ctx, nativeActive);
|
|
416
|
+
let visible = visibleRows(rows, state.folded);
|
|
417
|
+
let selected = Math.min(state.selected, Math.max(0, visible.length - 1));
|
|
418
|
+
state.flash = undefined;
|
|
419
|
+
|
|
420
|
+
return await ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
421
|
+
return {
|
|
422
|
+
render: (width: number) => {
|
|
423
|
+
const container = new Container();
|
|
424
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
425
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Skills library")), 1, 0));
|
|
426
|
+
visible.forEach((row, index) => container.addChild(new Text(renderRow(row, index === selected, theme), 0, 0)));
|
|
427
|
+
container.addChild(new Text("", 0, 0));
|
|
428
|
+
for (const line of treeFooter(config, nativeActive, state.flash, theme)) {
|
|
429
|
+
container.addChild(new Text(line, 1, 0));
|
|
430
|
+
}
|
|
431
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
432
|
+
return container.render(width);
|
|
433
|
+
},
|
|
434
|
+
invalidate: () => undefined,
|
|
435
|
+
handleInput: (data: string) => {
|
|
436
|
+
if (matchesKey(data, "up")) {
|
|
437
|
+
selected = Math.max(0, selected - 1);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (matchesKey(data, "down")) {
|
|
441
|
+
selected = Math.min(visible.length - 1, selected + 1);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (matchesKey(data, "space")) {
|
|
445
|
+
const row = visible[selected];
|
|
446
|
+
if (row?.type === "root" || row?.type === "group") {
|
|
447
|
+
if (state.folded.has(row.id)) state.folded.delete(row.id);
|
|
448
|
+
else state.folded.add(row.id);
|
|
449
|
+
visible = visibleRows(rows, state.folded);
|
|
450
|
+
selected = Math.min(selected, Math.max(0, visible.length - 1));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (row?.type === "leaf") {
|
|
454
|
+
const leaf = row.leaf!;
|
|
455
|
+
if (leaf.kind === "mirror") {
|
|
456
|
+
state.flash = "Locked: pi exposes this skill natively — remove the file or launch pi with --no-skills to revoke.";
|
|
457
|
+
} else if (leaf.kind === "unscanned") {
|
|
458
|
+
state.flash = "Not in the approved manifest — use Scan or refresh to adopt it.";
|
|
459
|
+
} else if (leaf.kind === "rejected") {
|
|
460
|
+
state.flash = "This skill failed the scan — press Enter for the reason.";
|
|
461
|
+
} else if (leaf.kind === "normal") {
|
|
462
|
+
let perRoot = state.changed.get(leaf.root.config.name);
|
|
463
|
+
if (!perRoot) state.changed.set(leaf.root.config.name, perRoot = new Map());
|
|
464
|
+
const current = perRoot.get(leaf.label) ?? leaf.checked;
|
|
465
|
+
perRoot.set(leaf.label, !current);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (matchesKey(data, "enter")) {
|
|
471
|
+
state.selected = selected;
|
|
472
|
+
const row = visible[selected];
|
|
473
|
+
if (row) done({ kind: "inspect", row });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (matchesKey(data, "escape")) {
|
|
477
|
+
state.selected = selected;
|
|
478
|
+
done({ kind: "exit" });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Apply toggled exposure to the config; returns true when anything changed. */
|
|
487
|
+
function applyTreeChanges(config: ManagerConfig, changed: Map<string, Map<string, boolean>>): boolean {
|
|
488
|
+
let dirty = false;
|
|
489
|
+
for (const [rootName, perRoot] of changed) {
|
|
490
|
+
const root = findRoot(config, rootName);
|
|
491
|
+
if (!root || !root.normalized) continue;
|
|
492
|
+
const current = new Set(root.config.skills.mode === "all" ? manifestSkills(root.config).map((skill) => skill.name) : root.config.skills.include);
|
|
493
|
+
const finalChecked = new Set<string>();
|
|
494
|
+
for (const skill of manifestSkills(root.config)) {
|
|
495
|
+
const toggled = perRoot.get(skill.name);
|
|
496
|
+
if (toggled === true) finalChecked.add(skill.name);
|
|
497
|
+
else if (toggled === false) finalChecked.delete(skill.name);
|
|
498
|
+
else if (root.config.enabled && current.has(skill.name)) finalChecked.add(skill.name);
|
|
499
|
+
}
|
|
500
|
+
if (root.config.enabled && finalChecked.size === current.size && [...finalChecked].every((name) => current.has(name))) continue;
|
|
501
|
+
if (!root.config.enabled && finalChecked.size === 0) continue;
|
|
502
|
+
if (!root.config.manifest && finalChecked.size > 0) continue; // enabling requires a manifest
|
|
503
|
+
dirty = true;
|
|
504
|
+
// Checking any skill enables the root; unchecking every skill disables
|
|
505
|
+
// it (an enabled root with an empty selection registers nothing).
|
|
506
|
+
upsertRoot(config, {
|
|
507
|
+
...root.config,
|
|
508
|
+
enabled: finalChecked.size > 0,
|
|
509
|
+
skills: { mode: "selected", include: [...finalChecked] },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return dirty;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The full tree interaction loop: browse, toggle, inspect, save on exit. */
|
|
516
|
+
async function libraryTree(ctx: ExtensionCommandContext, config: ManagerConfig): Promise<boolean> {
|
|
517
|
+
const state: TreeState = { folded: new Set(), selected: 0, changed: new Map(), flash: undefined };
|
|
518
|
+
while (true) {
|
|
519
|
+
const action = await runTreeDialog(ctx, config, nativeActivePaths(ctx), state);
|
|
520
|
+
if (action.kind === "exit") {
|
|
521
|
+
if (!state.changed.size) return false;
|
|
522
|
+
const dirty = applyTreeChanges(config, state.changed);
|
|
523
|
+
if (!dirty) return false;
|
|
524
|
+
if (!await ctx.ui.confirm("Apply exposure changes?", "Toggled skills are written to the config; pi reloads to re-register them.")) return false;
|
|
525
|
+
await saveManagerConfig(config);
|
|
526
|
+
ctx.ui.notify("Exposure updated; reloading", "info");
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
const row = action.row;
|
|
530
|
+
if (row.type === "root") {
|
|
531
|
+
const root = config.roots[Number(row.id.slice(2))]!;
|
|
532
|
+
await showMarkdown(root.config.name, rootDetails(root, ctx), ctx);
|
|
533
|
+
if (await rootActions(ctx, config, root)) return true; // a save + reload already happened
|
|
534
|
+
} else if (row.type === "group") {
|
|
535
|
+
// Enter folds a group, same as space would.
|
|
536
|
+
if (state.folded.has(row.id)) state.folded.delete(row.id);
|
|
537
|
+
else state.folded.add(row.id);
|
|
538
|
+
} else if (row.type === "leaf") {
|
|
539
|
+
if (await inspectLeaf(ctx, config, row.leaf!)) return true; // a save + reload already happened
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Enter on a leaf: details, view, edit (store only), remove (store only).
|
|
545
|
+
* Returns true when the config changed and pi should reload. */
|
|
546
|
+
async function inspectLeaf(ctx: ExtensionCommandContext, config: ManagerConfig, leaf: Leaf): Promise<boolean> {
|
|
547
|
+
const root = leaf.root;
|
|
548
|
+
if (leaf.kind === "issues" || leaf.kind === "rejected" || leaf.kind === "unscanned") {
|
|
549
|
+
await showMarkdown(leaf.kind === "issues" ? `${root.config.name} — issues` : leaf.label, leaf.reason ?? "", ctx);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
const snapshot = leaf.snapshot!;
|
|
553
|
+
const rootPath = safeRootPath(root, ctx.cwd);
|
|
554
|
+
if (!rootPath) {
|
|
555
|
+
ctx.ui.notify(`The path of ${root.config.name} cannot be resolved (an environment variable it references is not set)`, "error");
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
const path = skillFilePath(root.config, snapshot, ctx.cwd);
|
|
559
|
+
const skillDir = join(rootPath, snapshot.dirPath === "." ? "" : snapshot.dirPath);
|
|
560
|
+
const lines = [
|
|
561
|
+
`**Name:** ${snapshot.name}`,
|
|
562
|
+
`**Description:** ${snapshot.description}`,
|
|
563
|
+
`**Group:** ${skillGroup(snapshot.dirPath)}`,
|
|
564
|
+
`**Root:** ${root.config.name}${root.config.store ? " (store)" : ""}${root.config.native ? " (native pi)" : ""}`,
|
|
565
|
+
`**Directory:** \`${skillDir}\``,
|
|
566
|
+
`**Content hash:** \`${snapshot.contentHash.slice(0, 16)}…\``,
|
|
567
|
+
`**Files:** ${snapshot.fileCount}`,
|
|
568
|
+
`**Executable scripts:** ${snapshot.executableScripts.join(", ") || "none"}${snapshot.executableScripts.length ? " — the model may be instructed to run these" : ""}`,
|
|
569
|
+
`**Hidden from model:** ${snapshot.hidden ? "yes (invocable via skill:<name> only)" : "no"}`,
|
|
570
|
+
`**Exposure:** ${leaf.kind === "mirror" ? "mirrored — pi exposes this natively" : leaf.checked ? "exposed" : "not exposed"}`,
|
|
571
|
+
];
|
|
572
|
+
// Live drift state: hash the bytes on disk against the approval (bounded:
|
|
573
|
+
// a swollen file is reported, not loaded into memory).
|
|
574
|
+
let drift: string;
|
|
575
|
+
try {
|
|
576
|
+
const info = await lstat(path);
|
|
577
|
+
if (info.size > config.settings.maxSkillBytes) {
|
|
578
|
+
drift = `⚠ the file now exceeds settings.maxSkillBytes (${config.settings.maxSkillBytes} bytes) — refresh this root before it can be invoked`;
|
|
579
|
+
} else {
|
|
580
|
+
const bytes = await readFile(path);
|
|
581
|
+
drift = createHash("sha256").update(bytes).digest("hex") === snapshot.contentHash
|
|
582
|
+
? "approved content matches the file on disk"
|
|
583
|
+
: "⚠ content changed since approval — refresh this root before it can be invoked";
|
|
584
|
+
}
|
|
585
|
+
} catch (error) {
|
|
586
|
+
drift = `⚠ the file can no longer be read: ${errText(error)}`;
|
|
587
|
+
}
|
|
588
|
+
lines.push(`**Live check:** ${drift}`);
|
|
589
|
+
await showMarkdown(snapshot.name, lines.join("\n"), ctx);
|
|
590
|
+
|
|
591
|
+
const actions = ["View the full skill file"];
|
|
592
|
+
if (root.config.store) actions.push("Edit the skill file", "Remove this skill");
|
|
593
|
+
actions.push("Back");
|
|
594
|
+
const choice = await ctx.ui.select(`${snapshot.name} — actions`, actions);
|
|
595
|
+
if (!choice || choice === "Back") return false;
|
|
596
|
+
if (choice === "View the full skill file") {
|
|
597
|
+
try {
|
|
598
|
+
const info = await lstat(path);
|
|
599
|
+
if (info.size > config.settings.maxSkillBytes) throw new Error(`over ${config.settings.maxSkillBytes} bytes`);
|
|
600
|
+
const content = sanitizeExternalText(await readFile(path, "utf8"));
|
|
601
|
+
await showMarkdown(`${snapshot.name} — skill file`, `\`\`\`markdown\n${content.slice(0, 60_000)}${content.length > 60_000 ? "\n… (truncated for display)" : ""}\n\`\`\``, ctx);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
ctx.ui.notify(`Could not read the skill file: ${errText(error)}`, "error");
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
if (choice === "Edit the skill file") return await editStoreSkill(ctx, config, root, snapshot);
|
|
608
|
+
if (choice === "Remove this skill") return await removeStoreSkill(ctx, config, root, snapshot);
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Edit a store skill in the TUI editor. Saving through the manager is an
|
|
613
|
+
* approval act: the snapshot is refreshed so the skill does not fail as
|
|
614
|
+
* drifted after its own edit. */
|
|
615
|
+
async function editStoreSkill(ctx: ExtensionCommandContext, config: ManagerConfig, root: ManagedRoot, snapshot: SkillSnapshot): Promise<boolean> {
|
|
616
|
+
const path = skillFilePath(root.config, snapshot, ctx.cwd);
|
|
617
|
+
let current: string;
|
|
618
|
+
try {
|
|
619
|
+
current = await readFile(path, "utf8");
|
|
620
|
+
} catch (error) {
|
|
621
|
+
ctx.ui.notify(`Could not read ${path}: ${errText(error)}`, "error");
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
const edited = await ctx.ui.editor(`${path} (frontmatter name/description required)`, current);
|
|
625
|
+
if (edited === undefined || edited === current) return false;
|
|
626
|
+
await writeFileAtomic(path, edited);
|
|
627
|
+
let refreshed: SkillSnapshot;
|
|
628
|
+
try {
|
|
629
|
+
// Preserve the instruction file name: a root-level .md store skill
|
|
630
|
+
// must not silently fall back to SKILL.md after its own edit.
|
|
631
|
+
refreshed = await snapshotSkillFile(path, snapshot.dirPath, config.settings, snapshot.file ?? "SKILL.md");
|
|
632
|
+
} catch (error) {
|
|
633
|
+
ctx.ui.notify(`Saved, but the skill is now invalid: ${errText(error)}. Fix the frontmatter and re-save.`, "error");
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
const manifest = root.config.manifest!;
|
|
637
|
+
const skills = manifestSkills(root.config).filter((skill) => skill.name !== snapshot.name && skill.name !== refreshed.name);
|
|
638
|
+
skills.push(refreshed);
|
|
639
|
+
const wasExposed = root.config.skills.mode === "all" || root.config.skills.include.includes(snapshot.name);
|
|
640
|
+
const include = [...new Set([
|
|
641
|
+
...root.config.skills.include.filter((name) => name !== snapshot.name && name !== refreshed.name),
|
|
642
|
+
...(wasExposed ? [refreshed.name] : []),
|
|
643
|
+
])];
|
|
644
|
+
upsertRoot(config, {
|
|
645
|
+
...root.config,
|
|
646
|
+
skills: { mode: root.config.skills.mode, include: root.config.skills.mode === "all" ? [] : include },
|
|
647
|
+
manifest: { ...manifest, skills },
|
|
648
|
+
});
|
|
649
|
+
await saveManagerConfig(config);
|
|
650
|
+
ctx.ui.notify(`Saved ${refreshed.name}; reloading`, "info");
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Delete a store skill: its directory, or — for a root-level .md skill,
|
|
655
|
+
* whose "directory" is the store itself — just the instruction file.
|
|
656
|
+
* Refuses anything outside the store either way. */
|
|
657
|
+
async function removeStoreSkill(ctx: ExtensionCommandContext, config: ManagerConfig, root: ManagedRoot, snapshot: SkillSnapshot): Promise<boolean> {
|
|
658
|
+
const rootPath = safeRootPath(root, ctx.cwd);
|
|
659
|
+
const storePath = resolve(STORE_DIR);
|
|
660
|
+
const skillDir = resolve(join(rootPath || storePath, snapshot.dirPath === "." ? "" : snapshot.dirPath));
|
|
661
|
+
const fileOnly = skillDir === storePath; // a root-level .md skill: its file is the skill
|
|
662
|
+
const target = fileOnly ? resolve(skillFilePath(root.config, snapshot, ctx.cwd)) : skillDir;
|
|
663
|
+
if (!target.startsWith(`${storePath}/`)) {
|
|
664
|
+
ctx.ui.notify("Refusing to delete anything outside the store directory", "error");
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
if (!await ctx.ui.confirm(
|
|
668
|
+
`Remove ${snapshot.name}?`,
|
|
669
|
+
`Delete \`${target}\` and remove it from the approved manifest? The skill stops being exposed after reload.`,
|
|
670
|
+
)) return false;
|
|
671
|
+
await rm(target, { recursive: true, force: true }).catch((error) =>
|
|
672
|
+
ctx.ui.notify(`Removed from the manifest, but the deletion failed: ${errText(error)}`, "warning"));
|
|
673
|
+
const manifest = root.config.manifest!;
|
|
674
|
+
upsertRoot(config, {
|
|
675
|
+
...root.config,
|
|
676
|
+
skills: {
|
|
677
|
+
mode: root.config.skills.mode,
|
|
678
|
+
include: root.config.skills.include.filter((name) => name !== snapshot.name),
|
|
679
|
+
},
|
|
680
|
+
manifest: {
|
|
681
|
+
...manifest,
|
|
682
|
+
skills: manifestSkills(root.config).filter((skill) => skill.name !== snapshot.name),
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
await saveManagerConfig(config);
|
|
686
|
+
ctx.ui.notify(`Removed ${snapshot.name}; reloading`, "info");
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ─── Adding skills to the store ────────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
/** Shared tail of every add-skill method: snapshot the written skill into
|
|
693
|
+
* the store root's manifest, offer exposure, save, reload. */
|
|
694
|
+
async function finalizeStoreSkill(
|
|
695
|
+
ctx: ExtensionCommandContext,
|
|
696
|
+
config: ManagerConfig,
|
|
697
|
+
skillFile: string,
|
|
698
|
+
dirPath: string,
|
|
699
|
+
): Promise<boolean> {
|
|
700
|
+
const store = findStoreRoot(config);
|
|
701
|
+
if (!store) {
|
|
702
|
+
ctx.ui.notify("The registrar has no store root; add one first", "error");
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
const snapshot = await snapshotSkillFile(skillFile, dirPath, config.settings);
|
|
706
|
+
const skills = manifestSkills(store.config).filter((skill) => skill.name !== snapshot.name);
|
|
707
|
+
skills.push(snapshot);
|
|
708
|
+
const expose = await ctx.ui.confirm(
|
|
709
|
+
`Add ${snapshot.name} to the library?`,
|
|
710
|
+
`The skill is written into the store and snapshotted into the approved manifest. Expose it to the model now (it loads lazily on first use)?`,
|
|
711
|
+
);
|
|
712
|
+
if (!expose) {
|
|
713
|
+
ctx.ui.notify(`The file remains at ${dirname(skillFile)} but is not in the library; Scan or refresh the store root to adopt it`, "warning");
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
upsertRoot(config, {
|
|
717
|
+
...store.config,
|
|
718
|
+
enabled: true,
|
|
719
|
+
skills: { mode: "selected", include: [...new Set([...store.config.skills.include, snapshot.name])] },
|
|
720
|
+
manifest: {
|
|
721
|
+
version: 1,
|
|
722
|
+
fingerprint: fingerprint(store.config),
|
|
723
|
+
scannedAt: new Date().toISOString(),
|
|
724
|
+
skills,
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
await saveManagerConfig(config);
|
|
728
|
+
ctx.ui.notify(`Saved ${snapshot.name} to the store; reloading`, "info");
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function promptSkillIdentity(ctx: ExtensionCommandContext, fallbackName?: string, fallbackDescription?: string): Promise<{ name: string; description: string } | undefined> {
|
|
733
|
+
const name = (await ctx.ui.input("Skill name (lowercase letters, numbers, hyphens)", fallbackName ?? "my-skill"))?.trim();
|
|
734
|
+
if (!name) return undefined;
|
|
735
|
+
if (!SKILL_NAME_RE.test(name) || name.length > MAX_SKILL_NAME_CHARS) {
|
|
736
|
+
ctx.ui.notify(`Skill names are 1-${MAX_SKILL_NAME_CHARS} lowercase letters, numbers, or single hyphens`, "error");
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
const description = (await ctx.ui.input("What does this skill do and when should it be used?", fallbackDescription ?? ""))?.trim();
|
|
740
|
+
if (!description) return undefined;
|
|
741
|
+
return { name, description };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** A single path segment for a group (subdirectory of the store). */
|
|
745
|
+
async function promptGroup(ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
746
|
+
const group = (await ctx.ui.input("Group (optional — a subdirectory of the store; skills in it display as a group)", ""))?.trim();
|
|
747
|
+
if (!group) return "";
|
|
748
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(group) || group.length > 64) {
|
|
749
|
+
ctx.ui.notify("A group name is a single path segment (letters, numbers, dots, underscores, hyphens)", "error");
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
return group;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function addSkillToStore(ctx: ExtensionCommandContext, config: ManagerConfig): Promise<boolean> {
|
|
756
|
+
if (!findStoreRoot(config)) {
|
|
757
|
+
ctx.ui.notify("The registrar has no store root; restore it in the config file first", "error");
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
await mkdir(STORE_DIR, { recursive: true }).catch(() => undefined);
|
|
761
|
+
const method = await ctx.ui.select("Add a skill to the store", [
|
|
762
|
+
"Write a new SKILL.md in the editor",
|
|
763
|
+
"Import an .md file (copied into the store)",
|
|
764
|
+
"Import a directory (copied wholesale)",
|
|
765
|
+
"Cancel",
|
|
766
|
+
]);
|
|
767
|
+
if (!method || method === "Cancel") return false;
|
|
768
|
+
const group = await promptGroup(ctx);
|
|
769
|
+
if (group === undefined) return false;
|
|
770
|
+
|
|
771
|
+
if (method === "Write a new SKILL.md in the editor") {
|
|
772
|
+
const identity = await promptSkillIdentity(ctx);
|
|
773
|
+
if (!identity) return false;
|
|
774
|
+
const scaffold =
|
|
775
|
+
`---\nname: ${identity.name}\ndescription: ${identity.description.replace(/\n/g, " ")}\n---\n\n` +
|
|
776
|
+
`# ${identity.name}\n\n${identity.description}\n\n## Usage\n\n` +
|
|
777
|
+
`(Write the instructions the model should follow. Reference helper scripts and reference\nfiles with relative paths from the skill directory.)\n`;
|
|
778
|
+
const edited = await ctx.ui.editor("New SKILL.md (frontmatter is prewritten)", scaffold);
|
|
779
|
+
if (edited === undefined) return false;
|
|
780
|
+
const parsed = parseFrontmatter(edited);
|
|
781
|
+
const finalIdentity = parsed.data
|
|
782
|
+
? {
|
|
783
|
+
name: SKILL_NAME_RE.test(parsed.data["name"] ?? "") ? parsed.data["name"]! : identity.name,
|
|
784
|
+
description: (parsed.data["description"] ?? identity.description).trim() || identity.description,
|
|
785
|
+
}
|
|
786
|
+
: identity;
|
|
787
|
+
const dirPath = group ? `${group}/${finalIdentity.name}` : finalIdentity.name;
|
|
788
|
+
const destination = join(STORE_DIR, dirPath, "SKILL.md");
|
|
789
|
+
if (await lstat(destination).catch(() => undefined)) {
|
|
790
|
+
ctx.ui.notify(`A skill already exists at ${destination}`, "error");
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
await mkdir(join(STORE_DIR, dirPath), { recursive: true });
|
|
794
|
+
await writeFileAtomic(destination, edited);
|
|
795
|
+
return await finalizeStoreSkill(ctx, config, destination, dirPath);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (method === "Import an .md file (copied into the store)") {
|
|
799
|
+
const input = await ctx.ui.input("Path to the .md file", "~/Downloads/my-skill.md");
|
|
800
|
+
if (!input) return false;
|
|
801
|
+
const source = expandUserPath(input, ctx.cwd);
|
|
802
|
+
const info = await lstat(source).catch(() => undefined);
|
|
803
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
804
|
+
ctx.ui.notify("The import must be a regular file", "error");
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
if (info.size > config.settings.maxSkillBytes) {
|
|
808
|
+
ctx.ui.notify(`The file exceeds settings.maxSkillBytes (${config.settings.maxSkillBytes} bytes)`, "error");
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
const text = await readFile(source, "utf8");
|
|
812
|
+
const parsed = parseFrontmatter(text);
|
|
813
|
+
const identity = parsed.data?.["name"] && parsed.data?.["description"]
|
|
814
|
+
? { name: parsed.data["name"], description: parsed.data["description"] }
|
|
815
|
+
: await promptSkillIdentity(ctx, parsed.data?.["name"], parsed.data?.["description"]);
|
|
816
|
+
if (!identity) return false;
|
|
817
|
+
const finalText = parsed.data
|
|
818
|
+
? text
|
|
819
|
+
: `---\nname: ${identity.name}\ndescription: ${identity.description.replace(/\n/g, " ")}\n---\n\n${text}`;
|
|
820
|
+
const dirPath = group ? `${group}/${identity.name}` : identity.name;
|
|
821
|
+
const destination = join(STORE_DIR, dirPath, "SKILL.md");
|
|
822
|
+
if (await lstat(destination).catch(() => undefined)) {
|
|
823
|
+
ctx.ui.notify(`A skill already exists at ${destination}`, "error");
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
await mkdir(join(STORE_DIR, dirPath), { recursive: true });
|
|
827
|
+
await writeFileAtomic(destination, finalText);
|
|
828
|
+
ctx.ui.notify(`Copied ${source} into the store (the original is untouched)`, "info");
|
|
829
|
+
return await finalizeStoreSkill(ctx, config, destination, dirPath);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Directory import: wholesale copy; find the SKILL.md if it is not at the root.
|
|
833
|
+
const input = await ctx.ui.input("Path to the skill directory", "~/Downloads/their-skill");
|
|
834
|
+
if (!input) return false;
|
|
835
|
+
const source = expandUserPath(input, ctx.cwd);
|
|
836
|
+
const info = await lstat(source).catch(() => undefined);
|
|
837
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) {
|
|
838
|
+
ctx.ui.notify("The import must be a regular directory", "error");
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
let skillDir = source;
|
|
842
|
+
if (!await lstat(join(source, "SKILL.md")).catch(() => undefined)) {
|
|
843
|
+
const tree = await renderDirTree(source, 4);
|
|
844
|
+
let chosen: string | undefined;
|
|
845
|
+
while (!chosen) {
|
|
846
|
+
await showMarkdown(
|
|
847
|
+
`${source} — no SKILL.md at the root`,
|
|
848
|
+
`This directory has no SKILL.md at its root. Its tree (all depths up to 4):\n\n\`\`\`text\n${tree}\n\`\`\`\n\nWhich one is the skill's SKILL.md? Type its full path.`,
|
|
849
|
+
ctx,
|
|
850
|
+
);
|
|
851
|
+
const answer = await ctx.ui.input("Full path of the SKILL.md", join(source, "…"));
|
|
852
|
+
if (!answer) return false;
|
|
853
|
+
const candidate = expandUserPath(answer, source);
|
|
854
|
+
const rel = relative(source, candidate);
|
|
855
|
+
if (!rel || rel.startsWith("..") || !candidate.toLowerCase().endsWith(".md")
|
|
856
|
+
|| !(await lstat(candidate).catch(() => undefined))?.isFile()) {
|
|
857
|
+
ctx.ui.notify("That is not an .md file inside the printed tree — try again, or leave empty to cancel", "error");
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
chosen = candidate;
|
|
861
|
+
}
|
|
862
|
+
skillDir = dirname(chosen) || source;
|
|
863
|
+
}
|
|
864
|
+
const skillMdPath = join(skillDir, "SKILL.md");
|
|
865
|
+
const skillMdInfo = await lstat(skillMdPath).catch(() => undefined);
|
|
866
|
+
if (!skillMdInfo?.isFile() || skillMdInfo.isSymbolicLink()) {
|
|
867
|
+
ctx.ui.notify(`The chosen SKILL.md must be a regular file: ${skillMdPath}`, "error");
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
if (skillMdInfo.size > config.settings.maxSkillBytes) {
|
|
871
|
+
ctx.ui.notify(`The file exceeds settings.maxSkillBytes (${config.settings.maxSkillBytes} bytes)`, "error");
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
const parsed = parseFrontmatter((await readFile(skillMdPath)).toString("utf8"));
|
|
875
|
+
const identity = parsed.data?.["name"] && parsed.data?.["description"]
|
|
876
|
+
? { name: parsed.data["name"], description: parsed.data["description"] }
|
|
877
|
+
: await promptSkillIdentity(ctx, parsed.data?.["name"], parsed.data?.["description"]);
|
|
878
|
+
if (!identity) return false;
|
|
879
|
+
const dirPath = group ? `${group}/${identity.name}` : identity.name;
|
|
880
|
+
const destination = join(STORE_DIR, dirPath);
|
|
881
|
+
if (await lstat(destination).catch(() => undefined)) {
|
|
882
|
+
ctx.ui.notify(`A skill already exists at ${destination}`, "error");
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
// Copy the skill's own directory (the one holding the chosen SKILL.md),
|
|
886
|
+
// not the whole import source — when the SKILL.md is nested, copying
|
|
887
|
+
// the source would leave the copy without a root-level SKILL.md.
|
|
888
|
+
const result = await copyTreeInto(skillDir, destination, config.settings);
|
|
889
|
+
if (result.skipped.length) {
|
|
890
|
+
ctx.ui.notify(`Copied ${result.copied} file(s); skipped: ${result.skipped.join(", ").slice(0, 200)}`, "warning");
|
|
891
|
+
} else {
|
|
892
|
+
ctx.ui.notify(`Copied ${result.copied} file(s) into the store (the original is untouched)`, "info");
|
|
893
|
+
}
|
|
894
|
+
return await finalizeStoreSkill(ctx, config, join(destination, "SKILL.md"), dirPath);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ─── Adding roots and the scan ceremony ────────────────────────────────────
|
|
898
|
+
|
|
899
|
+
/** The scan ceremony: explicit confirmation, scan, inspection. Returns
|
|
900
|
+
* the scan outcome; exposure and enabling are decided by the caller. */
|
|
901
|
+
async function scanWithCeremony(
|
|
902
|
+
ctx: ExtensionCommandContext,
|
|
903
|
+
config: ManagerConfig,
|
|
904
|
+
rootConfig: RootConfig,
|
|
905
|
+
action: string,
|
|
906
|
+
): Promise<Awaited<ReturnType<typeof scanRoot>> | undefined> {
|
|
907
|
+
const issues = validateRoot(rootConfig, config.settings);
|
|
908
|
+
if (issues.length) {
|
|
909
|
+
await showIssues(ctx, "Library root rejected", issues);
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
let summary: string;
|
|
913
|
+
try {
|
|
914
|
+
summary = scanSummary(rootConfig, ctx.cwd);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
ctx.ui.notify(errText(error), "error");
|
|
917
|
+
return undefined;
|
|
918
|
+
}
|
|
919
|
+
const warnings = [mutablePathWarning(rootConfig.path), nativeDiscoveryWarning(rootConfig.path)].filter(Boolean);
|
|
920
|
+
const nativeNote = rootConfig.native
|
|
921
|
+
? "\n\nNote: this is a native pi location — while pi's native discovery is active, found skills are exposed by pi itself regardless; the manager mirrors them (red, locked in the tree)."
|
|
922
|
+
: "";
|
|
923
|
+
const approved = await ctx.ui.confirm(
|
|
924
|
+
action,
|
|
925
|
+
`${summary}${warnings.length ? `\n\n${warnings.join("\n\n")}` : ""}\n\nSkills are untrusted instructions the model may follow, and their scripts may be executed with your permissions. Scanning only reads the directory.${nativeNote}`,
|
|
926
|
+
);
|
|
927
|
+
if (!approved) return undefined;
|
|
928
|
+
ctx.ui.notify(`Scanning ${rootConfig.name}...`, "info");
|
|
929
|
+
let outcome;
|
|
930
|
+
try {
|
|
931
|
+
outcome = await scanRoot(rootConfig, ctx.cwd, config.settings);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
await showMarkdown("Skill scan failed", `\`\`\`text\n${sanitizeExternalText(errText(error)).slice(0, 16_000)}\n\`\`\``, ctx);
|
|
934
|
+
return undefined;
|
|
935
|
+
}
|
|
936
|
+
await showMarkdown("Skill scan result", [
|
|
937
|
+
`Discovered **${outcome.skills.length}** skill(s); **${outcome.rejected.length}** rejected; **${outcome.issues.length}** warning(s).`,
|
|
938
|
+
"",
|
|
939
|
+
...outcome.skills.map((skill) => {
|
|
940
|
+
const scripts = skill.executableScripts.length ? `\n - ⚠ executable scripts: ${skill.executableScripts.join(", ")}` : "";
|
|
941
|
+
const hidden = skill.hidden ? " · hidden from model" : "";
|
|
942
|
+
return `- **${skill.name}** (${skillGroup(skill.dirPath)}) — ${skill.description.replace(/\s+/g, " ").slice(0, 300)} · ${skill.fileCount} file(s)${hidden}${scripts}`;
|
|
943
|
+
}),
|
|
944
|
+
...(outcome.rejected.length ? ["", "### Rejected", ...outcome.rejected.map((rejected) => `- ⚠ \`${rejected.dirPath}\` — ${rejected.reason}`)] : []),
|
|
945
|
+
...(outcome.issues.length ? ["", "### Warnings", ...outcome.issues.map((issue) => `- ${issue}`)] : []),
|
|
946
|
+
].join("\n"), ctx);
|
|
947
|
+
if (!outcome.skills.length) {
|
|
948
|
+
ctx.ui.notify("No valid skills were found in this root", "warning");
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
return outcome;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function addRoot(ctx: ExtensionCommandContext, config: ManagerConfig): Promise<boolean> {
|
|
955
|
+
const method = await ctx.ui.select("Add a library root to the registrar", [
|
|
956
|
+
"Guided skills directory (a folder of skill folders)",
|
|
957
|
+
"Guided single skill (one directory with a SKILL.md)",
|
|
958
|
+
"Cancel",
|
|
959
|
+
]);
|
|
960
|
+
if (!method || method === "Cancel") return false;
|
|
961
|
+
const name = (await ctx.ui.input("Library root name (lowercase-name)", "my-skills"))?.trim();
|
|
962
|
+
if (!name) return false;
|
|
963
|
+
const path = (await ctx.ui.input("Path to the directory", "~/.claude/skills"))?.trim();
|
|
964
|
+
if (!path) return false;
|
|
965
|
+
const transport = method === "Guided single skill (one directory with a SKILL.md)" ? "single-skill" : "skills-dir";
|
|
966
|
+
const native = isNativePath(path);
|
|
967
|
+
const base: RootConfig = {
|
|
968
|
+
name, enabled: false, transport, path,
|
|
969
|
+
native: native ? true : undefined,
|
|
970
|
+
connection: "lazy",
|
|
971
|
+
skills: { mode: "selected", include: [] },
|
|
972
|
+
};
|
|
973
|
+
if (native) {
|
|
974
|
+
ctx.ui.notify("Recognized as a native pi location — exposure will be mirrored, not gated", "info");
|
|
975
|
+
} else {
|
|
976
|
+
const warning = nativeDiscoveryWarning(path);
|
|
977
|
+
if (warning) ctx.ui.notify(warning, "warning");
|
|
978
|
+
}
|
|
979
|
+
if (findRoot(config, name)) {
|
|
980
|
+
ctx.ui.notify(`A library root named ${name} already exists; refresh or remove it instead`, "error");
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
const outcome = await scanWithCeremony(ctx, config, base, `Scan ${path} for skills?`);
|
|
984
|
+
if (!outcome) return false;
|
|
985
|
+
const selection = await chooseExposure(ctx, { version: 1, fingerprint: "", scannedAt: outcome.scannedAt, skills: outcome.skills });
|
|
986
|
+
if (!selection) return false;
|
|
987
|
+
upsertRoot(config, {
|
|
988
|
+
...base,
|
|
989
|
+
enabled: true,
|
|
990
|
+
skills: selection,
|
|
991
|
+
manifest: {
|
|
992
|
+
version: 1,
|
|
993
|
+
fingerprint: fingerprint(base),
|
|
994
|
+
scannedAt: outcome.scannedAt,
|
|
995
|
+
skills: outcome.skills,
|
|
996
|
+
rejected: outcome.rejected.length ? outcome.rejected : undefined,
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
await saveManagerConfig(config);
|
|
1000
|
+
ctx.ui.notify(`Saved root ${name}; reloading`, "info");
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Ask which of a freshly approved manifest's skills are exposed. */
|
|
1005
|
+
async function chooseExposure(ctx: ExtensionCommandContext, manifest: SkillManifest, initial: Set<string> = new Set()): Promise<{ mode: "all" | "selected"; include: string[] } | undefined> {
|
|
1006
|
+
if (!manifest.skills.length) return { mode: "selected", include: [] };
|
|
1007
|
+
const choice = await ctx.ui.select("Skill exposure", [
|
|
1008
|
+
"Choose individual skills (recommended)",
|
|
1009
|
+
`Expose all ${manifest.skills.length} skills`,
|
|
1010
|
+
"Keep all skills disabled",
|
|
1011
|
+
"Cancel",
|
|
1012
|
+
]);
|
|
1013
|
+
if (!choice || choice === "Cancel") return undefined;
|
|
1014
|
+
if (choice.startsWith("Expose all")) return { mode: "all", include: [] };
|
|
1015
|
+
if (choice === "Keep all skills disabled") return { mode: "selected", include: [] };
|
|
1016
|
+
const enabled = new Set(initial);
|
|
1017
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
1018
|
+
const container = new Container();
|
|
1019
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Choose skills to expose")), 1, 1));
|
|
1020
|
+
const items: SettingItem[] = manifest.skills.map((skill) => ({
|
|
1021
|
+
id: skill.name,
|
|
1022
|
+
label: `${skill.name} (${skillGroup(skill.dirPath)})`,
|
|
1023
|
+
currentValue: enabled.has(skill.name) ? "on" : "off",
|
|
1024
|
+
values: ["off", "on"],
|
|
1025
|
+
}));
|
|
1026
|
+
const list = new SettingsList(
|
|
1027
|
+
items,
|
|
1028
|
+
Math.min(items.length + 2, 18),
|
|
1029
|
+
getSettingsListTheme(),
|
|
1030
|
+
(id, value) => {
|
|
1031
|
+
if (value === "on") enabled.add(id);
|
|
1032
|
+
else enabled.delete(id);
|
|
1033
|
+
},
|
|
1034
|
+
() => done(undefined),
|
|
1035
|
+
{ enableSearch: true },
|
|
1036
|
+
);
|
|
1037
|
+
container.addChild(list);
|
|
1038
|
+
container.addChild(new Text(theme.fg("dim", "Toggle with Enter/Space; Esc closes and saves the selection"), 1, 0));
|
|
1039
|
+
return {
|
|
1040
|
+
render: (width: number) => container.render(width),
|
|
1041
|
+
invalidate: () => container.invalidate(),
|
|
1042
|
+
handleInput: (data: string) => list.handleInput?.(data),
|
|
1043
|
+
};
|
|
1044
|
+
});
|
|
1045
|
+
return { mode: "selected", include: manifest.skills.map((skill) => skill.name).filter((name) => enabled.has(name)) };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Enable or disable one root (bulk pause/resume that preserves the
|
|
1049
|
+
* per-skill selection). Reached from the root's detail view. */
|
|
1050
|
+
async function toggleRootEntry(ctx: ExtensionCommandContext, config: ManagerConfig, root: ManagedRoot): Promise<boolean> {
|
|
1051
|
+
if (root.issues.length) {
|
|
1052
|
+
ctx.ui.notify(`Fix the issues for ${root.config.name} first (see the tree)`, "error");
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
const enabled = !root.config.enabled;
|
|
1056
|
+
if (enabled && !root.config.manifest) {
|
|
1057
|
+
ctx.ui.notify("Scan the root and approve a skill manifest before enabling it", "error");
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
if (!await ctx.ui.confirm(
|
|
1061
|
+
`${enabled ? "Enable" : "Disable"} ${root.config.name}`,
|
|
1062
|
+
root.config.native && enabled
|
|
1063
|
+
? "Native roots mirror pi's own exposure while native discovery is active; the selection applies in library-only mode (--no-skills)."
|
|
1064
|
+
: enabled
|
|
1065
|
+
? "Its selected skills will be registered after reload; the directory is still scanned lazily."
|
|
1066
|
+
: "Its skills will no longer be registered after reload.",
|
|
1067
|
+
)) return false;
|
|
1068
|
+
upsertRoot(config, { ...root.config, enabled });
|
|
1069
|
+
await saveManagerConfig(config);
|
|
1070
|
+
ctx.ui.notify(`${enabled ? "Enabled" : "Disabled"} ${root.config.name}; reloading`, "info");
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Scan or refresh one root: the approval ceremony, then exposure choice.
|
|
1075
|
+
* For auto-detected native roots this is the adoption path. */
|
|
1076
|
+
async function refreshRootEntry(ctx: ExtensionCommandContext, config: ManagerConfig, root: ManagedRoot): Promise<boolean> {
|
|
1077
|
+
const outcome = await scanWithCeremony(ctx, config, root.config, `Re-scan ${root.config.name}?`);
|
|
1078
|
+
if (!outcome) return false;
|
|
1079
|
+
// Drop selected skills that no longer exist; never silently keep names
|
|
1080
|
+
// absent from the freshly approved manifest.
|
|
1081
|
+
const available = new Set(outcome.skills.map((skill) => skill.name));
|
|
1082
|
+
const initial = new Set(root.config.skills.mode === "all"
|
|
1083
|
+
? outcome.skills.map((skill) => skill.name)
|
|
1084
|
+
: root.config.skills.include.filter((name) => available.has(name)));
|
|
1085
|
+
const selection = await chooseExposure(ctx, { version: 1, fingerprint: "", scannedAt: outcome.scannedAt, skills: outcome.skills }, initial);
|
|
1086
|
+
if (!selection) return false;
|
|
1087
|
+
// A disabled root that just got skills selected is offered the toggle.
|
|
1088
|
+
let enabled = root.config.enabled;
|
|
1089
|
+
if (!enabled && (selection.mode === "all" || selection.include.length)) {
|
|
1090
|
+
enabled = await ctx.ui.confirm(
|
|
1091
|
+
`Enable ${root.config.name} now?`,
|
|
1092
|
+
"Its selected skills will be registered after reload; the directory is still scanned lazily.",
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
upsertRoot(config, {
|
|
1096
|
+
...root.config,
|
|
1097
|
+
enabled,
|
|
1098
|
+
skills: selection,
|
|
1099
|
+
manifest: {
|
|
1100
|
+
version: 1,
|
|
1101
|
+
fingerprint: fingerprint(root.config),
|
|
1102
|
+
scannedAt: outcome.scannedAt,
|
|
1103
|
+
skills: outcome.skills,
|
|
1104
|
+
rejected: outcome.rejected.length ? outcome.rejected : undefined,
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
await saveManagerConfig(config);
|
|
1108
|
+
ctx.ui.notify(`Refreshed ${root.config.name}; reloading`, "info");
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Remove one root's entry (unregister only; files are never touched). */
|
|
1113
|
+
async function removeRootEntryFlow(ctx: ExtensionCommandContext, config: ManagerConfig, root: ManagedRoot): Promise<boolean> {
|
|
1114
|
+
if (root.config.store) {
|
|
1115
|
+
ctx.ui.notify("The store root is built in; remove its skills individually instead", "error");
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
if (root.auto) {
|
|
1119
|
+
ctx.ui.notify(
|
|
1120
|
+
"This root was auto-detected as a native pi location — pi exposes its skills regardless, so the library shows it for visibility. Scan or refresh it to adopt it properly.",
|
|
1121
|
+
"warning",
|
|
1122
|
+
);
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
if (!await ctx.ui.confirm(
|
|
1126
|
+
`Remove ${root.config.name}?`,
|
|
1127
|
+
`Delete its definition and approved manifest from ${CONFIG_PATH}? The directory on disk is never touched.`,
|
|
1128
|
+
)) return false;
|
|
1129
|
+
removeRootEntry(config, root.config.name);
|
|
1130
|
+
await saveManagerConfig(config);
|
|
1131
|
+
ctx.ui.notify(`Removed ${root.config.name}; reloading`, "info");
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Enter on a root row: overview plus the root's actions. Returns true when
|
|
1136
|
+
* the config changed and pi should reload. */
|
|
1137
|
+
async function rootActions(ctx: ExtensionCommandContext, config: ManagerConfig, root: ManagedRoot): Promise<boolean> {
|
|
1138
|
+
const actions = [];
|
|
1139
|
+
if (root.config.enabled || root.config.manifest) actions.push(`${root.config.enabled ? "Disable" : "Enable"} this root (keeps the skill selection)`);
|
|
1140
|
+
actions.push("Scan or refresh this root");
|
|
1141
|
+
if (!root.config.store && !root.auto) actions.push("Remove this root");
|
|
1142
|
+
actions.push("Back");
|
|
1143
|
+
const choice = await ctx.ui.select(`${root.config.name} — actions`, actions);
|
|
1144
|
+
if (!choice || choice === "Back") return false;
|
|
1145
|
+
if (choice.startsWith("Scan or refresh")) return await refreshRootEntry(ctx, config, root);
|
|
1146
|
+
if (choice.startsWith("Remove this root")) return await removeRootEntryFlow(ctx, config, root);
|
|
1147
|
+
if (choice.startsWith("Disable") || choice.startsWith("Enable")) return await toggleRootEntry(ctx, config, root);
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function refreshRoot(ctx: ExtensionCommandContext, config: ManagerConfig): Promise<boolean> {
|
|
1152
|
+
const root = await pickRoot(ctx, config, "Scan or refresh a library root");
|
|
1153
|
+
if (!root) return false;
|
|
1154
|
+
return await refreshRootEntry(ctx, config, root);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function removeRootOrSkill(ctx: ExtensionCommandContext, config: ManagerConfig): Promise<boolean> {
|
|
1158
|
+
const kind = await ctx.ui.select("Remove…", ["A skill from the store", "A library root", "Cancel"]);
|
|
1159
|
+
if (!kind || kind === "Cancel") return false;
|
|
1160
|
+
if (kind === "A library root") {
|
|
1161
|
+
const root = await pickRoot(ctx, config, "Remove which library root?", (item) => !item.config.store && !item.auto);
|
|
1162
|
+
if (!root) return false;
|
|
1163
|
+
return await removeRootEntryFlow(ctx, config, root);
|
|
1164
|
+
}
|
|
1165
|
+
const root = await pickRoot(ctx, config, "Remove a skill from which root?", (item) => Boolean(item.config.store && item.normalized));
|
|
1166
|
+
if (!root) return false;
|
|
1167
|
+
const skills = manifestSkills(root.config);
|
|
1168
|
+
if (!skills.length) {
|
|
1169
|
+
ctx.ui.notify("This root has no approved skills", "warning");
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
const labels = skills.map((skill) => `${skill.name} (${skillGroup(skill.dirPath)})`);
|
|
1173
|
+
const selected = await ctx.ui.select("Remove which skill?", labels);
|
|
1174
|
+
const index = selected ? labels.indexOf(selected) : -1;
|
|
1175
|
+
if (index < 0) return false;
|
|
1176
|
+
return await removeStoreSkill(ctx, config, root, skills[index]!);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ─── Extension settings ─────────────────────────────────────────────────────
|
|
1180
|
+
|
|
1181
|
+
async function extensionSettings(ctx: ExtensionCommandContext, config: ManagerConfig): Promise<boolean> {
|
|
1182
|
+
while (true) {
|
|
1183
|
+
const action = await ctx.ui.select("Extension settings", [
|
|
1184
|
+
"View current settings",
|
|
1185
|
+
"Edit the config file",
|
|
1186
|
+
"Reset settings to defaults",
|
|
1187
|
+
"Back",
|
|
1188
|
+
]);
|
|
1189
|
+
if (!action || action === "Back") return false;
|
|
1190
|
+
if (action === "View current settings") {
|
|
1191
|
+
const lines = [
|
|
1192
|
+
`**Config file:** \`${CONFIG_PATH}\``,
|
|
1193
|
+
`**Skill store:** \`${STORE_DIR}\``,
|
|
1194
|
+
`**Library roots:** ${config.roots.length}`,
|
|
1195
|
+
"",
|
|
1196
|
+
"### Settings",
|
|
1197
|
+
`**maxResultBytes:** ${config.settings.maxResultBytes} (default ${DEFAULT_SETTINGS.maxResultBytes})`,
|
|
1198
|
+
`**maxResultLines:** ${config.settings.maxResultLines} (default ${DEFAULT_SETTINGS.maxResultLines})`,
|
|
1199
|
+
`**maxSkills:** ${config.settings.maxSkills} (default ${DEFAULT_SETTINGS.maxSkills})`,
|
|
1200
|
+
`**toolNameLimit:** ${config.settings.toolNameLimit} (default ${DEFAULT_SETTINGS.toolNameLimit})`,
|
|
1201
|
+
`**maxSkillBytes:** ${config.settings.maxSkillBytes} (default ${DEFAULT_SETTINGS.maxSkillBytes})`,
|
|
1202
|
+
`**maxScanFiles:** ${config.settings.maxScanFiles} (default ${DEFAULT_SETTINGS.maxScanFiles})`,
|
|
1203
|
+
];
|
|
1204
|
+
if (config.settingsIssues.length) {
|
|
1205
|
+
lines.push("", "### Settings issues", ...config.settingsIssues.map((issue) => `- ${issue}`));
|
|
1206
|
+
}
|
|
1207
|
+
await showMarkdown("simple-skills-manager settings", lines.join("\n"), ctx);
|
|
1208
|
+
} else if (action === "Edit the config file") {
|
|
1209
|
+
let text: string;
|
|
1210
|
+
try {
|
|
1211
|
+
text = await readFile(CONFIG_PATH, "utf8");
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
ctx.ui.notify(`Could not read ${CONFIG_PATH}: ${errText(error)}`, "error");
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
const edited = await ctx.ui.editor(`${CONFIG_FILE} (JSONC comments allowed; saving strips them)`, text);
|
|
1217
|
+
if (edited === undefined || edited === text) continue;
|
|
1218
|
+
try {
|
|
1219
|
+
// Validate before writing: refuse to persist an unparseable file.
|
|
1220
|
+
JSON.parse(stripJsonComments(edited));
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
ctx.ui.notify(`Not saved — invalid JSON: ${errText(error)}`, "error");
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
await writeConfigText(edited);
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
ctx.ui.notify(`Not saved: ${errText(error)}`, "error");
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
await loadManagerConfig();
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
ctx.ui.notify(`Saved, but the config has issues: ${errText(error)}`, "warning");
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
ctx.ui.notify("Config saved; reloading to apply", "info");
|
|
1238
|
+
return true;
|
|
1239
|
+
} else if (action === "Reset settings to defaults") {
|
|
1240
|
+
if (!await ctx.ui.confirm(
|
|
1241
|
+
"Reset settings to defaults?",
|
|
1242
|
+
"All six tunables return to their built-in defaults. Library roots and manifests are kept.",
|
|
1243
|
+
)) continue;
|
|
1244
|
+
config.settings = { ...DEFAULT_SETTINGS };
|
|
1245
|
+
config.settingsIssues = [];
|
|
1246
|
+
await saveManagerConfig(config);
|
|
1247
|
+
ctx.ui.notify("Settings reset; reloading to apply", "info");
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// ─── Menu loop ─────────────────────────────────────────────────────────────
|
|
1254
|
+
|
|
1255
|
+
/** The /skills-manager command loop. Re-loads the config from disk every
|
|
1256
|
+
* iteration so hand edits and other processes are picked up between
|
|
1257
|
+
* actions; returns after any action that changed the config file (the
|
|
1258
|
+
* caller reloads pi to re-register skills). */
|
|
1259
|
+
export async function runSkillsManagerMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
1260
|
+
while (true) {
|
|
1261
|
+
const config = await loadConfigForMenu(ctx);
|
|
1262
|
+
if (!config) return;
|
|
1263
|
+
if (config.created) {
|
|
1264
|
+
ctx.ui.notify(`Created a starter config at ${CONFIG_PATH}`, "info");
|
|
1265
|
+
}
|
|
1266
|
+
const action = await ctx.ui.select("Skills library", [
|
|
1267
|
+
"Open the library tree (browse and toggle exposure)",
|
|
1268
|
+
"Add a skill to the store",
|
|
1269
|
+
"Add or import a library root",
|
|
1270
|
+
"Scan or refresh a root",
|
|
1271
|
+
"Remove a skill or a root",
|
|
1272
|
+
"Extension settings",
|
|
1273
|
+
"Close",
|
|
1274
|
+
]);
|
|
1275
|
+
if (!action || action === "Close") return;
|
|
1276
|
+
try {
|
|
1277
|
+
let changed = false;
|
|
1278
|
+
if (action === "Open the library tree (browse and toggle exposure)") changed = await libraryTree(ctx, config);
|
|
1279
|
+
else if (action === "Add a skill to the store") changed = await addSkillToStore(ctx, config);
|
|
1280
|
+
else if (action === "Add or import a library root") changed = await addRoot(ctx, config);
|
|
1281
|
+
else if (action === "Scan or refresh a root") changed = await refreshRoot(ctx, config);
|
|
1282
|
+
else if (action === "Remove a skill or a root") changed = await removeRootOrSkill(ctx, config);
|
|
1283
|
+
else if (action === "Extension settings") changed = await extensionSettings(ctx, config);
|
|
1284
|
+
if (changed) {
|
|
1285
|
+
await ctx.reload();
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
ctx.ui.notify(errText(error), "error");
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|