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/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "simple-skills-manager",
3
+ "version": "1.0.1",
4
+ "description": "A pi extension for exposing reviewed skills to the model: a single JSONC config file holds every library root (the registrar) and approved skill manifest, /skills-manager manages everything interactively in a tree navigator, skills are hash-bound and fail closed on drift, and native pi skill locations are adopted as honest mirrors.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "skills",
10
+ "agent-skills"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "files": ["*"],
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "bash tests/smoke.sh"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "typescript": "^5.6.0"
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*",
26
+ "typebox": "*"
27
+ },
28
+ "pi": {
29
+ "extensions": ["./index.ts"]
30
+ }
31
+ }
package/scan.ts ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * The scanner — the filesystem equivalent of an MCP protocol client. It is
3
+ * the ONLY code that reads skill content from disk, behind a small,
4
+ * auditable, fail-closed surface (see docs/discovery.md):
5
+ *
6
+ * - one bounded recursive walk per root (settings.maxScanFiles entries,
7
+ * depth capped at 8, symlinks skipped with an issue — never followed);
8
+ * - one bounded read per SKILL.md (settings.maxSkillBytes, hashed with
9
+ * SHA-256 the moment it is read);
10
+ * - a minimal frontmatter parser (key: value lines only);
11
+ * - a file inventory per skill, flagging executable scripts (shebang
12
+ * files the model may be instructed to run);
13
+ * - root-level *.md files with valid skill frontmatter are recognized as
14
+ * skills, mirroring pi's native discovery of ~/.pi/agent/skills;
15
+ * - rejected skills are reported (not silently dropped) so the TUI can
16
+ * show exactly where the manager's strictness disagrees with pi's
17
+ * leniency.
18
+ *
19
+ * Also home to the copy helpers the import flows use, with the same bounds.
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
24
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
25
+ import {
26
+ MAX_SKILL_DESC_CHARS,
27
+ MAX_SKILL_NAME_CHARS,
28
+ SKILL_NAME_RE,
29
+ resolvedRootPath,
30
+ type ManagerSettings,
31
+ type RejectedSkill,
32
+ type RootConfig,
33
+ type SkillSnapshot,
34
+ } from "./config.ts";
35
+
36
+ export const MAX_SCAN_DEPTH = 8;
37
+ export const MAX_EXECUTABLE_SHOWN = 32;
38
+ export const MAX_COPY_FILE_BYTES = 10 * 1024 * 1024;
39
+
40
+ /** Read the first `count` bytes of a file without loading it whole — the
41
+ * shebang check must not read entire files (skill directories can contain
42
+ * large assets). Unreadable files yield undefined, like a failed read. */
43
+ async function readFirstBytes(path: string, count: number): Promise<Buffer | undefined> {
44
+ const handle = await open(path, "r").catch(() => undefined);
45
+ if (!handle) return undefined;
46
+ try {
47
+ const buffer = Buffer.alloc(count);
48
+ const { bytesRead } = await handle.read(buffer, 0, count, 0);
49
+ return bytesRead > 0 ? buffer.subarray(0, bytesRead) : undefined;
50
+ } finally {
51
+ await handle.close();
52
+ }
53
+ }
54
+
55
+ export type ScanOutcome = {
56
+ skills: SkillSnapshot[];
57
+ rejected: RejectedSkill[];
58
+ /** Non-fatal warnings (e.g. skipped symlinks). */
59
+ issues: string[];
60
+ scannedAt: string;
61
+ };
62
+
63
+ // ─── Frontmatter (the deliberately small protocol subset) ───────────────────
64
+
65
+ /** Parse a minimal YAML frontmatter block: `---` fences around `key: value`
66
+ * lines. Quoted values, `#` comments, and unknown keys are tolerated;
67
+ * block scalars, nested structures, and multi-line values are not — they
68
+ * fail closed with a readable error. */
69
+ export function parseFrontmatter(text: string): { data: Record<string, string> | null; error?: string } {
70
+ const src = text.replace(/^\uFEFF/, "");
71
+ if (!src.startsWith("---")) return { data: null, error: "the file does not start with a YAML frontmatter block (a '---' line)." };
72
+ const lines = src.split("\n");
73
+ if (lines[0]!.trim() !== "---") return { data: null, error: "the frontmatter opening fence must be a '---' line." };
74
+ const data: Record<string, string> = {};
75
+ let i = 1;
76
+ while (i < lines.length && lines[i]!.trim() !== "---") {
77
+ const trimmed = lines[i]!.trim();
78
+ i++;
79
+ if (!trimmed || trimmed.startsWith("#")) continue;
80
+ const sep = trimmed.indexOf(":");
81
+ if (sep <= 0) return { data: null, error: `frontmatter line is not 'key: value': ${trimmed.slice(0, 80)}` };
82
+ const key = trimmed.slice(0, sep).trim();
83
+ let value = trimmed.slice(sep + 1).trim();
84
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) return { data: null, error: `invalid frontmatter key: ${key}` };
85
+ if (!value) return { data: null, error: `frontmatter key '${key}' has an empty value (block or multi-line values are not supported).` };
86
+ if (value === "|" || value === ">" || value.endsWith("|") || value.endsWith(">")) {
87
+ return { data: null, error: `frontmatter key '${key}' uses a block scalar (| or >); block or multi-line values are not supported.` };
88
+ }
89
+ if (value.startsWith('"') || value.startsWith("'")) {
90
+ const quote = value[0]!;
91
+ const end = value.indexOf(quote, 1);
92
+ if (end < 1) return { data: null, error: `frontmatter key '${key}' has an unterminated quoted value.` };
93
+ value = value.slice(1, end); // a trailing comment after the quote is ignored
94
+ }
95
+ data[key] = value;
96
+ }
97
+ if (i >= lines.length) return { data: null, error: "the frontmatter block is not closed by a '---' line." };
98
+ return { data };
99
+ }
100
+
101
+ // ─── Scanning ──────────────────────────────────────────────────────────────
102
+
103
+ /** Scan one library root. Throws on fatal conditions (root missing or a
104
+ * symlink, scan budget exceeded, more skills than settings.maxSkills);
105
+ * returns rejected skills and symlink warnings otherwise. */
106
+ export async function scanRoot(
107
+ config: RootConfig,
108
+ workspace: string,
109
+ settings: ManagerSettings,
110
+ signal?: AbortSignal,
111
+ ): Promise<ScanOutcome> {
112
+ const rootPath = resolvedRootPath(config, workspace);
113
+ const issues: string[] = [];
114
+ const rejected: RejectedSkill[] = [];
115
+ const skills: SkillSnapshot[] = [];
116
+ const seen = new Set<string>();
117
+ let budget = settings.maxScanFiles;
118
+ let overBudget = false;
119
+
120
+ const info = await lstat(rootPath).catch(() => undefined);
121
+ if (!info) throw new Error(`The root path does not exist: ${rootPath}`);
122
+ if (info.isSymbolicLink()) throw new Error(`The root path must be a regular directory, not a symbolic link: ${rootPath}`);
123
+ if (!info.isDirectory()) throw new Error(`The root path must be a directory: ${rootPath}`);
124
+
125
+ const spend = (): boolean => {
126
+ if (budget-- <= 0) {
127
+ overBudget = true;
128
+ return false;
129
+ }
130
+ return true;
131
+ };
132
+
133
+ /** Read and hash one skill instruction file; reject (never throw) on
134
+ * per-skill problems so one bad skill never hides the others. */
135
+ const readSkill = async (dirRel: string, fileName: string, fileCount: number, executableScripts: string[]): Promise<void> => {
136
+ const skillPath = join(rootPath, dirRel === "." ? "" : dirRel, fileName);
137
+ const skillInfo = await lstat(skillPath).catch(() => undefined);
138
+ if (!skillInfo) {
139
+ rejected.push({ dirPath: dirRel, reason: `instruction file '${fileName}' not found` });
140
+ return;
141
+ }
142
+ if (skillInfo.isSymbolicLink() || !skillInfo.isFile()) {
143
+ rejected.push({ dirPath: dirRel, reason: `instruction file '${fileName}' must be a regular file, not a symbolic link` });
144
+ return;
145
+ }
146
+ if (skillInfo.size > settings.maxSkillBytes) {
147
+ rejected.push({ dirPath: dirRel, reason: `instruction file '${fileName}' exceeds settings.maxSkillBytes (${settings.maxSkillBytes} bytes)` });
148
+ return;
149
+ }
150
+ if (signal?.aborted) throw new Error("Skill scan cancelled.");
151
+ const bytes = await readFile(skillPath);
152
+ const parsed = parseFrontmatter(bytes.toString("utf8"));
153
+ if (!parsed.data) {
154
+ rejected.push({ dirPath: dirRel, reason: `invalid frontmatter: ${parsed.error}` });
155
+ return;
156
+ }
157
+ const name = parsed.data["name"] ?? "";
158
+ const description = parsed.data["description"] ?? "";
159
+ if (!SKILL_NAME_RE.test(name) || !name || name.length > MAX_SKILL_NAME_CHARS) {
160
+ rejected.push({ dirPath: dirRel, reason: `invalid skill name '${name.slice(0, 80) || "(missing)"}' (1-64 chars, lowercase letters, numbers, single hyphens)` });
161
+ return;
162
+ }
163
+ if (!description.trim() || description.length > MAX_SKILL_DESC_CHARS) {
164
+ rejected.push({ dirPath: dirRel, reason: `skill '${name}' must have a non-empty description of at most ${MAX_SKILL_DESC_CHARS} characters` });
165
+ return;
166
+ }
167
+ if (seen.has(name)) {
168
+ rejected.push({ dirPath: dirRel, reason: `duplicate skill name: ${name} (first occurrence wins)` });
169
+ return;
170
+ }
171
+ seen.add(name);
172
+ skills.push({
173
+ name,
174
+ description: description.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").slice(0, MAX_SKILL_DESC_CHARS),
175
+ dirPath: dirRel,
176
+ file: fileName === "SKILL.md" ? undefined : fileName,
177
+ contentHash: createHash("sha256").update(bytes).digest("hex"),
178
+ fileCount,
179
+ executableScripts,
180
+ hidden: parsed.data["disable-model-invocation"] === "true" ? true : undefined,
181
+ });
182
+ };
183
+
184
+ /** Inventory one skill directory: count files, flag shebang executables. */
185
+ const inventory = async (dirRel: string): Promise<{ fileCount: number; executableScripts: string[] }> => {
186
+ const files: { absolute: string; relative: string }[] = [];
187
+ await collectFiles(join(rootPath, dirRel === "." ? "" : dirRel), dirRel === "." ? "" : dirRel, files);
188
+ let executable = 0;
189
+ const executableScripts: string[] = [];
190
+ for (const file of files) {
191
+ if (executableScripts.length >= MAX_EXECUTABLE_SHOWN) break;
192
+ const first = await readFirstBytes(file.absolute, 2);
193
+ if (first && first[0] === 0x23 && first[1] === 0x21) {
194
+ executable++;
195
+ executableScripts.push(file.relative);
196
+ }
197
+ }
198
+ if (executable > executableScripts.length) executableScripts.push(`… (+${executable - executableScripts.length} more)`);
199
+ return { fileCount: files.length, executableScripts };
200
+ };
201
+
202
+ const collectFiles = async (absolute: string, rel: string, files: { absolute: string; relative: string }[]): Promise<void> => {
203
+ const entries = await readdir(absolute, { withFileTypes: true }).catch(() => []);
204
+ for (const entry of entries) {
205
+ if (!spend()) return;
206
+ if (entry.isSymbolicLink()) continue; // fail closed: symlinks are skipped
207
+ const childAbs = join(absolute, entry.name);
208
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
209
+ if (entry.isDirectory()) await collectFiles(childAbs, childRel, files);
210
+ else if (entry.isFile()) files.push({ absolute: childAbs, relative: childRel });
211
+ }
212
+ };
213
+
214
+ if (config.transport === "single-skill") {
215
+ const { fileCount, executableScripts } = await inventory(".");
216
+ await readSkill(".", "SKILL.md", fileCount, executableScripts);
217
+ } else {
218
+ // Root-level *.md files with skill frontmatter are skills (pi native
219
+ // discovery does the same in its own locations). The shared root-level
220
+ // inventory is computed once, lazily: it walks the whole root and
221
+ // spends scan budget, so it must not run once per candidate file.
222
+ const entries = await readdir(rootPath, { withFileTypes: true });
223
+ let rootLevel: { fileCount: number; executableScripts: string[] } | undefined;
224
+ for (const entry of entries) {
225
+ if (!spend()) break;
226
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.toLowerCase().endsWith(".md")) continue;
227
+ const entryInfo = await lstat(join(rootPath, entry.name)).catch(() => undefined);
228
+ if (!entryInfo) continue; // vanished mid-scan
229
+ if (entryInfo.size > settings.maxSkillBytes) {
230
+ rejected.push({ dirPath: ".", reason: `root-level file '${entry.name}' exceeds settings.maxSkillBytes` });
231
+ continue;
232
+ }
233
+ const bytes = await readFile(join(rootPath, entry.name));
234
+ const parsed = parseFrontmatter(bytes.toString("utf8"));
235
+ if (!parsed.data) continue; // not a skill file; ignored silently, like pi
236
+ rootLevel ??= await inventory(".");
237
+ await readSkill(".", entry.name, rootLevel.fileCount, rootLevel.executableScripts);
238
+ }
239
+ // Directories containing SKILL.md are skills; group directories are recursed.
240
+ const walk = async (dirRel: string, depth: number): Promise<void> => {
241
+ if (depth > MAX_SCAN_DEPTH) {
242
+ issues.push(`Scan stopped descending below depth ${MAX_SCAN_DEPTH} at ${dirRel || "."}.`);
243
+ return;
244
+ }
245
+ const absolute = join(rootPath, dirRel);
246
+ const entries = await readdir(absolute, { withFileTypes: true }).catch(() => []);
247
+ for (const entry of entries) {
248
+ if (!spend()) return;
249
+ if (entry.isSymbolicLink()) {
250
+ issues.push(`Symlink skipped (fail closed): ${dirRel ? `${dirRel}/` : ""}${entry.name}`);
251
+ continue;
252
+ }
253
+ if (!entry.isDirectory()) continue;
254
+ const childRel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
255
+ const skillPath = join(rootPath, childRel, "SKILL.md");
256
+ const skillInfo = await lstat(skillPath).catch(() => undefined);
257
+ if (skillInfo?.isFile()) {
258
+ const { fileCount, executableScripts } = await inventory(childRel);
259
+ await readSkill(childRel, "SKILL.md", fileCount, executableScripts);
260
+ } else {
261
+ await walk(childRel, depth + 1);
262
+ }
263
+ }
264
+ };
265
+ await walk("", 0);
266
+ }
267
+
268
+ if (overBudget) throw new Error(`The root contains more than ${settings.maxScanFiles} filesystem entries; refusing to scan further.`);
269
+ if (skills.length > settings.maxSkills) {
270
+ throw new Error(`The root contains more than ${settings.maxSkills} skills (found ${skills.length}); adjust settings.maxSkills or split the root.`);
271
+ }
272
+ skills.sort((a, b) => a.name.localeCompare(b.name));
273
+ return { skills, rejected, issues, scannedAt: new Date().toISOString() };
274
+ }
275
+
276
+ // ─── Copy helpers (import flows) ────────────────────────────────────────────
277
+
278
+ /** Snapshot a single skill's instruction file without a full root scan:
279
+ * used by the TUI's add/edit flows, where the user just authored or
280
+ * approved the content by acting through the manager. Pass `file` for
281
+ * root-level .md skills so the snapshot keeps the instruction file's
282
+ * name instead of defaulting to SKILL.md. */
283
+ export async function snapshotSkillFile(
284
+ skillFilePath: string,
285
+ dirPath: string,
286
+ settings: ManagerSettings,
287
+ file?: string,
288
+ ): Promise<SkillSnapshot> {
289
+ const info = await lstat(skillFilePath).catch(() => undefined);
290
+ if (!info) throw new Error(`Skill file not found: ${skillFilePath}`);
291
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Skill file must be a regular file, not a symbolic link: ${skillFilePath}`);
292
+ if (info.size > settings.maxSkillBytes) {
293
+ throw new Error(`Skill file exceeds settings.maxSkillBytes (${settings.maxSkillBytes} bytes): ${skillFilePath}`);
294
+ }
295
+ const bytes = await readFile(skillFilePath);
296
+ const parsed = parseFrontmatter(bytes.toString("utf8"));
297
+ if (!parsed.data) throw new Error(`Invalid skill file (${skillFilePath}): ${parsed.error}`);
298
+ const name = parsed.data["name"] ?? "";
299
+ const description = parsed.data["description"] ?? "";
300
+ if (!SKILL_NAME_RE.test(name) || !name || name.length > MAX_SKILL_NAME_CHARS) {
301
+ throw new Error(`Invalid skill name '${name.slice(0, 80) || "(missing)"}' (1-64 chars, lowercase letters, numbers, single hyphens).`);
302
+ }
303
+ if (!description.trim() || description.length > MAX_SKILL_DESC_CHARS) {
304
+ throw new Error(`Skill '${name}' must have a non-empty description of at most ${MAX_SKILL_DESC_CHARS} characters.`);
305
+ }
306
+ return {
307
+ name,
308
+ description: description.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").slice(0, MAX_SKILL_DESC_CHARS),
309
+ dirPath,
310
+ file: file && file !== "SKILL.md" ? file : undefined,
311
+ contentHash: createHash("sha256").update(bytes).digest("hex"),
312
+ fileCount: 1,
313
+ executableScripts: [],
314
+ hidden: parsed.data["disable-model-invocation"] === "true" ? true : undefined,
315
+ };
316
+ }
317
+
318
+ /** Copy a directory tree wholesale (import flows). Symlinks are skipped and
319
+ * reported; per-file size and total file-count bounds are enforced. The
320
+ * destination must not exist yet. */
321
+ export async function copyTreeInto(
322
+ source: string,
323
+ destination: string,
324
+ settings: ManagerSettings,
325
+ ): Promise<{ copied: number; skipped: string[] }> {
326
+ const info = await lstat(source).catch(() => undefined);
327
+ if (!info) throw new Error(`The source does not exist: ${source}`);
328
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`The source must be a regular directory, not a symbolic link: ${source}`);
329
+ const destInfo = await lstat(destination).catch(() => undefined);
330
+ if (destInfo) throw new Error(`The destination already exists: ${destination}`);
331
+ const skipped: string[] = [];
332
+ let copied = 0;
333
+ let budget = settings.maxScanFiles;
334
+ const walk = async (absolute: string, rel: string): Promise<void> => {
335
+ if (rel) await mkdir(join(destination, rel), { recursive: true });
336
+ const entries = await readdir(absolute, { withFileTypes: true });
337
+ for (const entry of entries) {
338
+ if (entry.isSymbolicLink()) {
339
+ skipped.push(`symlink skipped: ${rel ? `${rel}/` : ""}${entry.name}`);
340
+ continue;
341
+ }
342
+ // Every filesystem entry (file or directory) spends budget, the
343
+ // same accounting a scan uses.
344
+ if (budget-- <= 0) throw new Error(`The source contains more than ${settings.maxScanFiles} filesystem entries; refusing to copy further.`);
345
+ const childAbs = join(absolute, entry.name);
346
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
347
+ if (entry.isDirectory()) await walk(childAbs, childRel);
348
+ else if (entry.isFile()) {
349
+ const childInfo = await lstat(childAbs);
350
+ if (childInfo.size > MAX_COPY_FILE_BYTES) {
351
+ skipped.push(`file skipped (over ${MAX_COPY_FILE_BYTES} bytes): ${childRel}`);
352
+ continue;
353
+ }
354
+ await mkdir(dirname(join(destination, childRel)), { recursive: true });
355
+ await writeFile(join(destination, childRel), await readFile(childAbs), { mode: 0o644 });
356
+ copied++;
357
+ }
358
+ }
359
+ };
360
+ await walk(source, "");
361
+ return { copied, skipped };
362
+ }
363
+
364
+ /** Render a directory tree for display (the import flow prints it and asks
365
+ * which SKILL.md is the skill). Bounded to maxDepth levels and 500 entries. */
366
+ export async function renderDirTree(source: string, maxDepth = 4): Promise<string> {
367
+ const lines: string[] = [source.endsWith("/") ? source.slice(0, -1) : source];
368
+ let budget = 500;
369
+ const walk = async (absolute: string, rel: string, depth: number, prefix: string): Promise<void> => {
370
+ if (depth >= maxDepth || budget <= 0) return;
371
+ const entries = await readdir(absolute, { withFileTypes: true }).catch(() => []);
372
+ const visible = entries.filter((entry) => !entry.isSymbolicLink());
373
+ for (let index = 0; index < visible.length && budget > 0; index++) {
374
+ const entry = visible[index]!;
375
+ budget--;
376
+ const last = index === visible.length - 1;
377
+ const connector = last ? "└── " : "├── ";
378
+ const marker = entry.isFile() && entry.name.toLowerCase() === "skill.md" ? " ◄ SKILL.md" : entry.isFile() && entry.name.toLowerCase().endsWith(".md") ? " (.md)" : "";
379
+ lines.push(`${prefix}${connector}${entry.name}${marker}`);
380
+ if (entry.isDirectory()) {
381
+ await walk(join(absolute, entry.name), rel ? `${rel}/${entry.name}` : entry.name, depth + 1, `${prefix}${last ? " " : "│ "}`);
382
+ }
383
+ }
384
+ if (budget <= 0) lines.push(`${prefix}… (tree truncated)`);
385
+ };
386
+ await walk(source, "", 0, "");
387
+ return lines.join("\n");
388
+ }
389
+
390
+ /** The relative path of `target` under `base`, or undefined when it is not
391
+ * inside (..-escaping or absolute elsewhere). Used to validate the import
392
+ * flow's hand-typed SKILL.md answer. */
393
+ export function relativeInside(base: string, target: string): string | undefined {
394
+ const rel = relative(base, target);
395
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
396
+ return rel;
397
+ }
@@ -0,0 +1,114 @@
1
+ {
2
+ // Template for ~/.pi/agent/simple-skills-manager.json — the extension creates
3
+ // a commented starter automatically on first run; this file is the fully
4
+ // documented reference. Copy it over the starter if you prefer:
5
+ // cp ~/.pi/agent/extensions/simple-skills-manager/simple-skills-manager.example.json \
6
+ // ~/.pi/agent/simple-skills-manager.json
7
+ //
8
+ // JSONC comments are allowed when you edit by hand; saving from
9
+ // /skills-manager rewrites the file and strips them. Full field reference:
10
+ // docs/config.md.
11
+
12
+ "version": 1,
13
+
14
+ // ── Extension settings (all optional; defaults shown) ────────────────────
15
+ "settings": {
16
+ // Per-invocation skill text output cap, in bytes (default 50 KiB).
17
+ "maxResultBytes": 51200,
18
+ // Per-invocation skill text output line cap (default 2,000 lines).
19
+ "maxResultLines": 2000,
20
+ // Maximum skills one root's approved manifest may hold (default 200).
21
+ "maxSkills": 200,
22
+ // Maximum length of registered skill_<root>__<skill> tool names (default 64).
23
+ "toolNameLimit": 64,
24
+ // Maximum size of one skill instruction file (default 128 KiB).
25
+ "maxSkillBytes": 131072,
26
+ // Maximum filesystem entries one scan may visit (default 4096).
27
+ "maxScanFiles": 4096
28
+ },
29
+
30
+ // ── The registrar: every library root sits flat here ──────────────────────
31
+ // Each non-store root needs one "Scan or refresh" pass (in /skills-manager)
32
+ // to record its approved skill manifest before it can be enabled.
33
+ "registrar": [
34
+ // The built-in store — seeded automatically on first run. Skills added
35
+ // through /skills-manager accumulate here; group skills by nesting their
36
+ // directories in subdirectories of the store (groups are the first path
37
+ // segment and display as groups in the tree navigator).
38
+ {
39
+ "name": "store",
40
+ "store": true,
41
+ "enabled": true,
42
+ "transport": "skills-dir",
43
+ "path": "~/.pi/agent/managed-skills",
44
+ "connection": "lazy",
45
+ "skills": {
46
+ "mode": "selected",
47
+ "include": ["pdf-tools"]
48
+ },
49
+
50
+ // The approved manifest, written by /skills-manager's scans and by the
51
+ // add/edit flows. Bound to the root identity (name, transport, path)
52
+ // via a SHA-256 fingerprint, and every skill is bound to the exact
53
+ // content that was inspected via a SHA-256 contentHash. Change the
54
+ // path and the manifest is invalidated until you refresh; change a
55
+ // SKILL.md outside the manager and invocations fail closed until you
56
+ // refresh. Never write this section by hand.
57
+ "manifest": {
58
+ "version": 1,
59
+ "fingerprint": "sha256-of-root-identity",
60
+ "scannedAt": "2026-09-13T00:00:00.000Z",
61
+ "skills": [
62
+ {
63
+ "name": "pdf-tools",
64
+ "description": "Extracts text and tables from PDF files, fills PDF forms, and merges PDFs. Use when working with PDF documents.",
65
+ "dirPath": "docs/pdf-tools",
66
+ "contentHash": "sha256-of-the-skill-md-bytes",
67
+ "fileCount": 5,
68
+ "executableScripts": ["docs/pdf-tools/scripts/process.sh"],
69
+ "hidden": false
70
+ }
71
+ ]
72
+ }
73
+ },
74
+
75
+ // A native pi location — auto-detected. Native locations that exist on
76
+ // disk appear in the registrar automatically on the next load: disabled,
77
+ // unapproved, flagged "Auto-detected". The entry below shows what one
78
+ // looks like after its "Scan or refresh" adoption. The manager mirrors
79
+ // pi's own exposure (red, locked in the tree) instead of pretending to
80
+ // gate it; in library-only mode (pi --no-skills) the normal gate applies.
81
+ {
82
+ "name": "agents-skills",
83
+ "native": true,
84
+ "enabled": true,
85
+ "transport": "skills-dir",
86
+ "path": "~/.agents/skills",
87
+ "connection": "lazy",
88
+ "skills": { "mode": "selected", "include": [] },
89
+ "manifest": { "version": 1, "fingerprint": "…", "scannedAt": "…", "skills": [] }
90
+ },
91
+
92
+ // An external skills directory — read-only to the manager (browse,
93
+ // inspect, expose; never edited or deleted). Registering it here gives
94
+ // its skills the drift-checked /skill:<name> command and tool surfaces.
95
+ {
96
+ "name": "claude-import",
97
+ "enabled": false,
98
+ "transport": "skills-dir",
99
+ "path": "~/.claude/skills",
100
+ "connection": "lazy",
101
+ "skills": { "mode": "selected", "include": [] }
102
+ },
103
+
104
+ // A single skill (one directory with one SKILL.md).
105
+ {
106
+ "name": "one-off",
107
+ "enabled": false,
108
+ "transport": "single-skill",
109
+ "path": "~/skills/their-skill",
110
+ "connection": "lazy",
111
+ "skills": { "mode": "selected", "include": [] }
112
+ }
113
+ ]
114
+ }
@@ -0,0 +1,5 @@
1
+ ---
2
+ name: malformed-skill
3
+ ---
4
+
5
+ Missing description; must be rejected.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: block-scalar
3
+ description: |
4
+ A multi-line block value that the minimal parser must reject.
5
+ ---
6
+
7
+ Body.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: solo-skill
3
+ description: A single-skill fixture root with one SKILL.md and a reference file.
4
+ ---
5
+
6
+ # Solo skill
7
+
8
+ See references/notes.md.
@@ -0,0 +1 @@
1
+ Reference notes for solo-skill.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: dupe
3
+ description: First duplicate-named fixture skill. The first occurrence wins.
4
+ ---
5
+
6
+ First.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: dupe
3
+ description: Second duplicate-named fixture skill. This one must be rejected.
4
+ ---
5
+
6
+ Second.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: doc-skill
3
+ description: A fixture skill nested in a group directory. Use when testing group display.
4
+ ---
5
+
6
+ # Doc skill
7
+
8
+ Grouped under "grouped".
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: scripted-skill
3
+ description: A fixture skill that ships an executable script. Use when testing script flagging.
4
+ ---
5
+
6
+ # Scripted skill
7
+
8
+ Run ./scripts/run.sh to do the thing.
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ echo "fixture script"
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: hidden-skill
3
+ description: A fixture skill hidden from the model via disable-model-invocation.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Hidden skill
8
+
9
+ Command-only.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: plain-skill
3
+ description: A plain fixture skill with no extras. Use when testing skill scanning.
4
+ ---
5
+
6
+ # Plain skill
7
+
8
+ Follow these instructions when the plain-skill tool is invoked.