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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * simple-skills-manager smoke suite (phase 1). Runs inside `pi -e` with
3
+ * PI_SKILL_FIXTURES pointing at tests/fixtures and PI_SKILL_RESULT naming
4
+ * the output JSON. No external model, no untrusted network calls — local
5
+ * fixtures and temp dirs only.
6
+ *
7
+ * Covers: both transports end-to-end (discovery, grouping, script flagging,
8
+ * hidden skills, rejections), the frontmatter subset, validation
9
+ * invariants, config round-trips, the production executor path (lazy scan,
10
+ * success, reuse, fail-closed drift), native mirror computation, and the
11
+ * regression suites (name-length invariants, malformed-manifest safety,
12
+ * scan-budget enforcement, pathological sources).
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join, resolve } from "node:path";
19
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import {
21
+ buildManagedSkillExecutor,
22
+ createScanCache,
23
+ piSkillToolName,
24
+ } from "../index.ts";
25
+ import {
26
+ computeExposedSkills,
27
+ DEFAULT_SETTINGS,
28
+ fingerprint,
29
+ loadManagerConfig,
30
+ manifestSkills,
31
+ removeRootEntry,
32
+ saveManagerConfig,
33
+ selectedSkills,
34
+ skillGroup,
35
+ skillFilePath,
36
+ upsertRoot,
37
+ validateManifest,
38
+ validateRoot,
39
+ type ManagerSettings,
40
+ type RootConfig,
41
+ type SkillSnapshot,
42
+ } from "../config.ts";
43
+ import { parseFrontmatter, scanRoot, snapshotSkillFile, copyTreeInto } from "../scan.ts";
44
+ import { buildTreeRows, visibleRows } from "../menu.ts";
45
+
46
+ const FIXTURES = process.env.PI_SKILL_FIXTURES!;
47
+ const RESULT = process.env.PI_SKILL_RESULT!;
48
+
49
+ function assert(condition: unknown, message: string): void {
50
+ if (!condition) throw new Error(`assertion failed: ${message}`);
51
+ }
52
+
53
+ async function tempDir(prefix: string): Promise<string> {
54
+ return await mkdtemp(join(tmpdir(), prefix));
55
+ }
56
+
57
+ function hashFile(bytes: Buffer): string {
58
+ return createHash("sha256").update(bytes).digest("hex");
59
+ }
60
+
61
+ // ─── 1. Scans ───────────────────────────────────────────────────────────────
62
+
63
+ async function scanSuite(settings: ManagerSettings): Promise<Record<string, unknown>> {
64
+ // skills-dir: nested groups, scripts, hidden, duplicates.
65
+ const dirRoot: RootConfig = {
66
+ name: "fixture-dir", enabled: false, transport: "skills-dir",
67
+ path: join(FIXTURES, "skills-dir"), connection: "lazy",
68
+ skills: { mode: "selected", include: [] },
69
+ };
70
+ const outcome = await scanRoot(dirRoot, process.cwd(), settings);
71
+ const names = outcome.skills.map((skill) => skill.name);
72
+ assert(JSON.stringify(names) === JSON.stringify(["doc-skill", "dupe", "hidden-skill", "plain-skill", "scripted-skill"]), `skills-dir names wrong: ${names}`);
73
+ const byName = new Map(outcome.skills.map((skill) => [skill.name, skill]));
74
+ assert(byName.get("doc-skill")!.dirPath === "grouped/doc", "doc-skill dirPath wrong");
75
+ assert(skillGroup(byName.get("doc-skill")!.dirPath) === "grouped", "grouped group wrong");
76
+ assert(skillGroup(byName.get("plain-skill")!.dirPath) === "ungrouped", "ungrouped group wrong");
77
+ const scripted = byName.get("scripted-skill")!;
78
+ assert(scripted.executableScripts.some((script) => script === "grouped/scripted/scripts/run.sh"), `script flagging wrong: ${scripted.executableScripts}`);
79
+ assert(scripted.fileCount >= 2, "scripted fileCount wrong");
80
+ assert(byName.get("hidden-skill")!.hidden === true, "hidden flag not detected");
81
+ assert(byName.get("plain-skill")!.hidden !== true, "plain skill wrongly hidden");
82
+ // contentHash matches the bytes on disk.
83
+ const plainBytes = await readFile(join(FIXTURES, "skills-dir/plain/SKILL.md"));
84
+ assert(byName.get("plain-skill")!.contentHash === hashFile(plainBytes), "contentHash does not match file bytes");
85
+ // Duplicates: first wins, second rejected with a readable reason.
86
+ assert(outcome.rejected.length === 1 && outcome.rejected[0]!.reason.includes("duplicate"), `duplicate handling wrong: ${JSON.stringify(outcome.rejected)}`);
87
+
88
+ // single-skill: one skill, inventory includes the reference file.
89
+ const soloRoot: RootConfig = {
90
+ name: "fixture-solo", enabled: false, transport: "single-skill",
91
+ path: join(FIXTURES, "single-skill"), connection: "lazy",
92
+ skills: { mode: "selected", include: [] },
93
+ };
94
+ const solo = await scanRoot(soloRoot, process.cwd(), settings);
95
+ assert(solo.skills.length === 1 && solo.skills[0]!.name === "solo-skill", "single-skill scan wrong");
96
+ assert(solo.skills[0]!.dirPath === ".", "single-skill dirPath wrong");
97
+ assert(solo.skills[0]!.fileCount >= 2, "single-skill inventory wrong");
98
+
99
+ // bad root: strict rejections (pi would load these leniently or warn).
100
+ const badRoot: RootConfig = {
101
+ name: "fixture-bad", enabled: false, transport: "skills-dir",
102
+ path: join(FIXTURES, "bad"), connection: "lazy",
103
+ skills: { mode: "selected", include: [] },
104
+ };
105
+ const bad = await scanRoot(badRoot, process.cwd(), settings);
106
+ assert(bad.skills.length === 0, `bad root produced skills: ${bad.skills.map((s) => s.name)}`);
107
+ assert(bad.rejected.length === 2, `bad root rejections wrong: ${JSON.stringify(bad.rejected)}`);
108
+ assert(bad.rejected.some((entry) => entry.reason.includes("description")), "malformed rejection reason wrong");
109
+ assert(bad.rejected.some((entry) => entry.reason.includes("frontmatter")), "unparseable rejection reason wrong");
110
+
111
+ // A symlinked root fails closed.
112
+ const linkDir = await tempDir("skill-scan-link-");
113
+ const target = join(FIXTURES, "single-skill");
114
+ const link = join(linkDir, "link");
115
+ await symlink(target, link);
116
+ let symlinkRejected = false;
117
+ try {
118
+ await scanRoot({ ...soloRoot, path: link }, process.cwd(), settings);
119
+ } catch (error) {
120
+ symlinkRejected = String(error).includes("symbolic link");
121
+ }
122
+ assert(symlinkRejected, "symlinked root was scanned");
123
+
124
+ // snapshotSkillFile preserves a root-level .md skill's instruction file
125
+ // name (an edit must not silently fall back to SKILL.md).
126
+ const soloMdPath = join(FIXTURES, "single-skill", "SKILL.md");
127
+ const soloSnap = await snapshotSkillFile(soloMdPath, ".", settings, "notes.md");
128
+ assert(soloSnap.file === "notes.md", `snapshotSkillFile lost the file name: ${soloSnap.file}`);
129
+ assert((await snapshotSkillFile(soloMdPath, ".", settings)).file !== "notes.md", "snapshotSkillFile must default the file to undefined");
130
+
131
+ // copyTreeInto's budget counts every filesystem entry, not just
132
+ // directories — a flat directory of many files must fail closed.
133
+ const copyDir = await tempDir("skill-smoke-copy-");
134
+ const flatSource = join(copyDir, "src");
135
+ await mkdir(flatSource, { recursive: true });
136
+ for (let i = 0; i < 12; i++) await writeFile(join(flatSource, `f${i}.txt`), "x");
137
+ let copyBudgetThrew = false;
138
+ try {
139
+ await copyTreeInto(flatSource, join(copyDir, "dst"), { ...settings, maxScanFiles: 10 });
140
+ } catch (error) {
141
+ copyBudgetThrew = String(error).includes("more than 10");
142
+ }
143
+ assert(copyBudgetThrew, "copyTreeInto did not enforce the entry budget against files");
144
+
145
+ return { skillsDir: true, singleSkill: true, strictRejections: true, symlinkRoots: true, snapshotFile: true, copyBudget: true };
146
+ }
147
+
148
+ // ─── 2. Frontmatter subset ─────────────────────────────────────────────────
149
+
150
+ function frontmatterSuite(): boolean {
151
+ const good = parseFrontmatter('---\nname: x\ndescription: "Quoted: with colon" # trailing\n# comment\nunknown: ignored\n---\nbody');
152
+ assert(good.data?.["name"] === "x" && good.data?.["description"] === "Quoted: with colon", `good frontmatter wrong: ${JSON.stringify(good)}`);
153
+ const block = parseFrontmatter("---\nname: x\ndescription: |\n block\n---\n");
154
+ assert(block.data === null && block.error?.includes("block scalar"), "block scalar must fail");
155
+ const unclosed = parseFrontmatter("---\nname: x\n");
156
+ assert(unclosed.data === null && unclosed.error?.includes("closed"), "unclosed fence must fail");
157
+ const none = parseFrontmatter("# no frontmatter\n");
158
+ assert(none.data === null, "missing frontmatter must fail");
159
+ return true;
160
+ }
161
+
162
+ // ─── 3. Validation invariants ───────────────────────────────────────────────
163
+
164
+ function validationSuite(): boolean {
165
+ const base: RootConfig = {
166
+ name: "ok-root", enabled: false, transport: "skills-dir",
167
+ path: "/tmp/definitely-not-used", connection: "lazy",
168
+ skills: { mode: "selected", include: [] },
169
+ };
170
+ const unsafe: RootConfig[] = [
171
+ { ...base, name: "Bad_Name" },
172
+ { ...base, native: true }, // native flag on a non-native path
173
+ { ...base, store: true }, // store flag on a non-store path
174
+ { ...base, skills: { mode: "all", include: ["x"] } },
175
+ { ...base, skills: { mode: "selected", include: ["x", "x"] } },
176
+ { ...base, path: "bad\u0000path" },
177
+ { ...base, name: "a".repeat(49) },
178
+ ];
179
+ for (const config of unsafe) {
180
+ const issues = validateRoot(config);
181
+ assert(issues.length > 0, `unsafe config accepted: ${JSON.stringify({ name: config.name, issues })}`);
182
+ }
183
+ // Manifest binding: valid for its own identity, invalid for a different path.
184
+ const root: RootConfig = { ...base, path: "/tmp/alpha" };
185
+ const manifest = { version: 1 as const, fingerprint: fingerprint(root), scannedAt: "t", skills: [snapshotStub()] };
186
+ assert(validateManifest(manifest, root).manifest, "manifest rejected for its own root");
187
+ assert(!validateManifest(manifest, { ...root, path: "/tmp/beta" }).manifest, "manifest accepted for a different root");
188
+ // A selected skill absent from the manifest is an issue.
189
+ assert(!validateManifest(manifest, { ...root, skills: { mode: "selected", include: ["absent"] } }).manifest, "absent selected skill accepted");
190
+ // Bad content hashes, names, and dir paths are rejected.
191
+ const badSnapshots = [
192
+ { ...snapshotStub(), contentHash: "nothex" },
193
+ { ...snapshotStub(), name: "Bad Name" },
194
+ { ...snapshotStub(), dirPath: "../escape" },
195
+ { ...snapshotStub(), dirPath: "/absolute" },
196
+ { ...snapshotStub(), description: "" },
197
+ ];
198
+ for (const snapshot of badSnapshots) {
199
+ const checked = validateManifest({ ...manifest, skills: [snapshot] }, root);
200
+ assert(!checked.manifest, `malformed snapshot accepted: ${JSON.stringify(snapshot).slice(0, 120)}`);
201
+ }
202
+ return true;
203
+ }
204
+
205
+ function snapshotStub(): SkillSnapshot {
206
+ return {
207
+ name: "stub-skill",
208
+ description: "Stub skill for manifest tests.",
209
+ dirPath: "stub-skill",
210
+ contentHash: "a".repeat(64),
211
+ fileCount: 1,
212
+ executableScripts: [],
213
+ };
214
+ }
215
+
216
+ // ─── 4. Config round-trip ──────────────────────────────────────────────────
217
+
218
+ async function configRoundTrip(): Promise<Record<string, unknown>> {
219
+ const dir = await tempDir("skill-smoke-cfg-");
220
+ const path = join(dir, "config.json");
221
+
222
+ // First load auto-creates the starter, seeded with the store root.
223
+ // (Auto-detected native roots may also appear — machine-dependent; every
224
+ // assertion below is tolerant of them.)
225
+ const first = await loadManagerConfig(path);
226
+ assert(first.created, "first-run starter was not created");
227
+ assert(first.roots.filter((root) => !root.auto).length === 1, `starter must seed exactly the store root, got ${first.roots.length}`);
228
+ const storeEntry = first.roots.find((root) => root.config.store);
229
+ assert(storeEntry && storeEntry.config.store === true && storeEntry.config.name === "store", "starter store root wrong");
230
+ assert(!storeEntry!.config.enabled, "starter store root must start disabled");
231
+ assert(storeEntry!.issues.some((issue) => issue.includes("manifest")), "unscanned store root must carry an issue");
232
+
233
+ // Upsert a valid external root with a manifest, change a setting, save,
234
+ // reload — must round-trip.
235
+ const root: RootConfig = {
236
+ name: "roundtrip", enabled: false, transport: "skills-dir",
237
+ path: "/tmp/roundtrip", connection: "lazy",
238
+ skills: { mode: "selected", include: [] },
239
+ };
240
+ root.manifest = { version: 1, fingerprint: fingerprint(root), scannedAt: "t", skills: [snapshotStub()] };
241
+ first.settings.maxSkills = 37;
242
+ upsertRoot(first, root);
243
+ await saveManagerConfig(first, path);
244
+ const reloaded = await loadManagerConfig(path);
245
+ assert(!reloaded.created, "reload flagged as first run");
246
+ assert(reloaded.settings.maxSkills === 37, "settings did not round-trip");
247
+ const entry = reloaded.roots.find((item) => item.config.name === "roundtrip")!;
248
+ assert(entry && entry.normalized && !entry.issues.length, `root entry did not round-trip cleanly: ${entry?.issues.join("; ")}`);
249
+
250
+ // Remove, save, reload — entry gone, store root unaffected.
251
+ assert(removeRootEntry(reloaded, "roundtrip"), "removeRootEntry found nothing");
252
+ await saveManagerConfig(reloaded, path);
253
+ const afterRemove = await loadManagerConfig(path);
254
+ assert(!afterRemove.roots.some((item) => item.config.name === "roundtrip"), "removed entry came back");
255
+ assert(afterRemove.roots.some((item) => item.config.store), "store root lost across a save");
256
+
257
+ // Invalid settings fall back to defaults with issues; not fatal.
258
+ const settingsPath = join(dir, "settings.json");
259
+ await writeFile(settingsPath, JSON.stringify({ settings: { maxSkills: 999_999 }, registrar: [] }));
260
+ const withIssues = await loadManagerConfig(settingsPath);
261
+ assert(withIssues.settingsIssues.length > 0 && withIssues.settings.maxSkills === DEFAULT_SETTINGS.maxSkills, "invalid settings did not fall back");
262
+
263
+ // Unparseable file fails loudly.
264
+ const badPath = join(dir, "bad.json");
265
+ await writeFile(badPath, "{not json");
266
+ let threw = false;
267
+ try { await loadManagerConfig(badPath); } catch { threw = true; }
268
+ assert(threw, "unparseable config did not throw");
269
+
270
+ return { roundTrip: true, storeSeed: true, settingsFallback: true, loudFailure: true };
271
+ }
272
+
273
+ // ─── 5. Malformed-manifest safety ──────────────────────────────────────────
274
+
275
+ async function malformedManifestSafety(): Promise<boolean> {
276
+ const dir = await tempDir("skill-smoke-bad-");
277
+ const path = join(dir, "malformed.json");
278
+ await writeFile(path, JSON.stringify({
279
+ version: 1,
280
+ registrar: [{
281
+ name: "bad", enabled: true, transport: "skills-dir", path: "/tmp/x",
282
+ skills: { mode: "all", include: [] },
283
+ manifest: { version: 1, fingerprint: "x", skills: "not-an-array" },
284
+ }],
285
+ }));
286
+ const loaded = await loadManagerConfig(path);
287
+ const root = loaded.roots[0]!;
288
+ assert(root.issues.length > 0, "malformed manifest produced no issues");
289
+ assert(manifestSkills(root.config).length === 0, "manifestSkills did not degrade safely");
290
+ assert(selectedSkills(root.config).length === 0, "selectedSkills did not degrade safely");
291
+ // The raw entry must survive a save round-trip (no silent destruction).
292
+ await saveManagerConfig(loaded, path);
293
+ const reloaded = await loadManagerConfig(path);
294
+ assert(reloaded.roots[0]!.issues.length > 0, "malformed entry lost its issues across a save");
295
+ return true;
296
+ }
297
+
298
+ // ─── 6. Executor suite (the production invocation path) ────────────────────
299
+
300
+ async function executorSuite(settings: ManagerSettings): Promise<Record<string, unknown>> {
301
+ const dir = await tempDir("skill-smoke-exec-");
302
+
303
+ // Lazy proof A: building an executor never touches the disk.
304
+ const absentRoot: RootConfig = {
305
+ name: "absent", enabled: true, transport: "skills-dir",
306
+ path: join(dir, "does-not-exist"), connection: "lazy",
307
+ skills: { mode: "selected", include: [] },
308
+ };
309
+ const absentExecutor = buildManagedSkillExecutor(
310
+ absentRoot, snapshotStub(), settings,
311
+ createScanCache(settings).getScan, createScanCache(settings).evictScan,
312
+ );
313
+ assert(absentExecutor.name.startsWith("skill_absent__"), "executor tool name wrong");
314
+ // Lazy proof B: the failure happens at invocation (scan time), proving
315
+ // nothing was scanned at build time.
316
+ let lazyRejected = false;
317
+ try {
318
+ await absentExecutor.execute("t", {}, undefined, undefined, fakeCtx());
319
+ } catch (error) {
320
+ lazyRejected = String(error).includes("does not exist");
321
+ }
322
+ assert(lazyRejected, "invocation against a missing root did not fail with a readable scan error");
323
+
324
+ // Real invocation: copy the fixture store, approve one skill, call.
325
+ const store = join(dir, "store");
326
+ await cp(join(FIXTURES, "skills-dir"), store, { recursive: true });
327
+ const root: RootConfig = {
328
+ name: "store", enabled: true, transport: "skills-dir",
329
+ path: store, connection: "lazy",
330
+ skills: { mode: "selected", include: ["plain-skill"] },
331
+ };
332
+ const snapshot = await snapshotSkillFile(join(store, "plain", "SKILL.md"), "plain", settings);
333
+ const cache = createScanCache(settings);
334
+ const executor = buildManagedSkillExecutor(root, snapshot, settings, cache.getScan, cache.evictScan);
335
+ const result = await executor.execute("t1", {}, undefined, undefined, fakeCtx());
336
+ const text = JSON.stringify(result.content);
337
+ const callOk = text.includes("Skill directory:") && text.includes("Follow these instructions");
338
+ assert(callOk, "executor result did not carry the skill content");
339
+ assert((result.details as { skill: string }).skill === "plain-skill", "executor details wrong");
340
+ // Second call succeeds through the cached scan.
341
+ const again = await executor.execute("t2", {}, undefined, undefined, fakeCtx());
342
+ assert(JSON.stringify(again.content).includes("Follow these instructions"), "second invocation (scan reuse) failed");
343
+
344
+ // Drift: change the SKILL.md outside the manager — fail closed.
345
+ await writeFile(join(store, "plain", "SKILL.md"), "---\nname: plain-skill\ndescription: Tampered.\n---\nTampered body.\n");
346
+ let driftRejected = false;
347
+ try {
348
+ await executor.execute("t3", {}, undefined, undefined, fakeCtx());
349
+ } catch (error) {
350
+ driftRejected = String(error).includes("drift");
351
+ }
352
+ assert(driftRejected, "content drift was not rejected");
353
+ await cache.shutdown();
354
+ return { lazyRejected, callOk, reuseOk: true, driftRejected };
355
+ }
356
+
357
+ function fakeCtx(): import("@earendil-works/pi-coding-agent").ExtensionContext {
358
+ return { cwd: process.cwd(), ui: { notify: () => undefined } } as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
359
+ }
360
+
361
+ // ─── 7. Name-length invariants ──────────────────────────────────────────────
362
+
363
+ function nameInvariants(): boolean {
364
+ const pinned = piSkillToolName("a".repeat(48), "b".repeat(100), 64);
365
+ assert(pinned === "skill_aaaaaaaaaaaaaa_97daac__bbbbbbbbbbbbbbbbbbbbbbbbbb_d6cbb053", `pinned default-limit name changed: ${pinned}`);
366
+ for (const limit of [16, 17, 24, 31, 64, 100, 128]) {
367
+ for (const [root, skill] of [
368
+ ["a".repeat(48), "b".repeat(100)],
369
+ ["very-long-root-name", "some_skill_name"],
370
+ ["abc", "search_documents"],
371
+ ["s", "t"],
372
+ ] as const) {
373
+ const name = piSkillToolName(root, skill, limit);
374
+ assert(name.startsWith("skill_") && name.length <= limit, `piSkillToolName violated limit ${limit}: ${name} (len ${name.length})`);
375
+ }
376
+ }
377
+ return true;
378
+ }
379
+
380
+ // ─── 8. Scan budget / maxSkills enforcement ─────────────────────────────────
381
+
382
+ async function cacheMaxSkillsHonored(): Promise<boolean> {
383
+ const settings: ManagerSettings = { ...DEFAULT_SETTINGS, maxSkills: 1 };
384
+ const root: RootConfig = {
385
+ name: "fixture", enabled: false, transport: "skills-dir",
386
+ path: join(FIXTURES, "skills-dir"), connection: "lazy",
387
+ skills: { mode: "selected", include: [] },
388
+ };
389
+ const cache = createScanCache(settings);
390
+ let caught = "";
391
+ try {
392
+ await cache.getScan(root, process.cwd());
393
+ } catch (error) {
394
+ caught = String(error);
395
+ } finally {
396
+ await cache.shutdown();
397
+ }
398
+ assert(caught.includes("more than 1"), `cache maxSkills failure wrong: ${caught}`);
399
+ return true;
400
+ }
401
+
402
+ // ─── 9. Native mirror computation ───────────────────────────────────────────
403
+
404
+ async function nativeMirrorSuite(settings: ManagerSettings): Promise<boolean> {
405
+ const dir = await tempDir("skill-smoke-native-");
406
+ const store = join(dir, "native");
407
+ await cp(join(FIXTURES, "skills-dir"), store, { recursive: true });
408
+ const nativeRoot: RootConfig = {
409
+ name: "native-pi", enabled: true, native: true, transport: "skills-dir",
410
+ path: store, connection: "lazy",
411
+ skills: { mode: "selected", include: [] }, // nothing selected on purpose
412
+ };
413
+ const snapshot = await snapshotSkillFile(join(store, "plain", "SKILL.md"), "plain", settings);
414
+ nativeRoot.manifest = { version: 1, fingerprint: fingerprint(nativeRoot), scannedAt: "t", skills: [snapshot] };
415
+ // Mirror mode: pi is natively exposing the skill → exposed regardless of selection.
416
+ const live = new Set([skillFilePath(nativeRoot, snapshot, process.cwd())]);
417
+ const mirrored = computeExposedSkills(nativeRoot, process.cwd(), live);
418
+ assert(mirrored.length === 1 && mirrored[0]!.name === "plain-skill", "mirror exposure missing");
419
+ // Library-only mode (nothing natively active) → standard gate applies.
420
+ const gated = computeExposedSkills(nativeRoot, process.cwd(), new Set());
421
+ assert(gated.length === 0, "unselected skill exposed without native mirror");
422
+ // A disabled root never exposes, even mirrored.
423
+ const disabled = computeExposedSkills({ ...nativeRoot, enabled: false }, process.cwd(), live);
424
+ assert(disabled.length === 0, "disabled root exposed");
425
+ return true;
426
+ }
427
+
428
+ // ─── 10. Tree navigator construction ───────────────────────────────────────
429
+
430
+ /** The tree the TUI renders: roots flat, groups nested, leaves carrying
431
+ * the right kinds (normal / mirrored / unscanned / rejected), folds
432
+ * hiding exactly their subtree. */
433
+ async function treeSuite(settings: ManagerSettings): Promise<boolean> {
434
+ const dir = await tempDir("skill-smoke-tree-");
435
+ const store = join(dir, "store");
436
+ await cp(join(FIXTURES, "skills-dir"), store, { recursive: true });
437
+ const nativeStore = join(dir, "native");
438
+ await cp(join(FIXTURES, "single-skill"), nativeStore, { recursive: true });
439
+
440
+ const storeRoot: RootConfig = {
441
+ name: "store", store: true, enabled: true, transport: "skills-dir",
442
+ path: store, connection: "lazy", skills: { mode: "selected", include: ["plain-skill"] },
443
+ };
444
+ const plain = await snapshotSkillFile(join(store, "plain", "SKILL.md"), "plain", settings);
445
+ storeRoot.manifest = { version: 1, fingerprint: fingerprint(storeRoot), scannedAt: "t", skills: [plain] };
446
+
447
+ const nativeRoot: RootConfig = {
448
+ name: "native-pi", native: true, enabled: true, transport: "skills-dir",
449
+ path: nativeStore, connection: "lazy", skills: { mode: "selected", include: [] },
450
+ };
451
+ const solo = await snapshotSkillFile(join(nativeStore, "SKILL.md"), ".", settings);
452
+ nativeRoot.manifest = {
453
+ version: 1, fingerprint: fingerprint(nativeRoot), scannedAt: "t",
454
+ skills: [solo],
455
+ rejected: [{ dirPath: "ghosted", reason: "rejected by the strict scanner" }],
456
+ };
457
+
458
+ // A root whose ${...} path template cannot resolve (unset env var) must
459
+ // fail closed to zero exposure instead of crashing the tree builder.
460
+ const templateRoot: RootConfig = {
461
+ name: "template", enabled: true, transport: "skills-dir",
462
+ path: "${SSM_SMOKE_UNSET_VAR}/skills", connection: "lazy",
463
+ skills: { mode: "all", include: [] },
464
+ };
465
+ const templateSnap = await snapshotSkillFile(join(store, "plain", "SKILL.md"), "template-skill", settings);
466
+ templateRoot.manifest = { version: 1, fingerprint: fingerprint(templateRoot), scannedAt: "t", skills: [templateSnap] };
467
+
468
+ const config = await loadManagerConfig(join(dir, "unused-config.json"));
469
+ config.roots = [
470
+ { config: storeRoot, issues: [], normalized: true, raw: {} },
471
+ { config: nativeRoot, issues: [], normalized: true, raw: {} },
472
+ { config: templateRoot, issues: [], normalized: true, raw: {} },
473
+ ];
474
+
475
+ const ctx = {
476
+ cwd: process.cwd(),
477
+ getSystemPromptOptions: () => ({
478
+ skills: [{ filePath: join(nativeStore, "SKILL.md") }, { filePath: join(nativeStore, "unscanned/SKILL.md") }],
479
+ }),
480
+ } as unknown as import("@earendil-works/pi-coding-agent").ExtensionCommandContext;
481
+
482
+ // Must not throw despite the unresolvable template root.
483
+ const rows = buildTreeRows(config, ctx, nativeActivePathsFor(ctx));
484
+ // Roots sit flat at depth 0.
485
+ const roots = rows.filter((row) => row.type === "root");
486
+ assert(roots.length === 3 && roots.every((row) => row.depth === 0), "roots must sit flat");
487
+ // The template root fails closed: its live exposure is 0, while its
488
+ // skill's checkbox still reflects the configured mode:"all" selection
489
+ // (the checkbox edits config state; the label shows live exposure).
490
+ const templateRow = roots.find((row) => row.label.startsWith("template —"));
491
+ assert(templateRow?.label.includes("0/1 skills"), `template root must fail closed to 0 exposed: ${templateRow?.label}`);
492
+ const templateLeaf = rows.find((row) => row.type === "leaf" && row.leaf!.root.config.name === "template");
493
+ assert(templateLeaf?.leaf?.kind === "normal" && templateLeaf.leaf.checked === true, "template-root checkbox must reflect the configured selection");
494
+ // The store root has one group (ungrouped) with the exposed skill.
495
+ const plainLeaf = rows.find((row) => row.type === "leaf" && row.label === "plain-skill");
496
+ assert(plainLeaf?.leaf?.kind === "normal" && plainLeaf.leaf.checked === true, "plain-skill leaf wrong");
497
+ // The native root mirrors the live skill, flags the unscanned one, and
498
+ // lists the rejection — visibility, not gating.
499
+ const kinds = new Map(rows.filter((row) => row.type === "leaf").map((row) => [row.label, row.leaf!.kind]));
500
+ assert(kinds.get("solo-skill") === "mirror", `solo-skill must be a mirror, got ${kinds.get("solo-skill")}`);
501
+ assert(kinds.get("unscanned/SKILL.md") === "unscanned", "unscanned skill must be flagged");
502
+ assert(kinds.get("ghosted") === "rejected", "rejected skill must be listed");
503
+ const mirrorLeaf = rows.find((row) => row.leaf?.kind === "mirror")!.leaf!;
504
+ assert(mirrorLeaf.checked === true, "mirror leaves render as locked-exposed");
505
+ // Folds: folding the first root hides its groups and leaves, not others.
506
+ const folded = visibleRows(rows, new Set([roots[0]!.id]));
507
+ assert(folded.filter((row) => row.type === "leaf").every((row) => row.leaf!.root.config.name !== "store"), "fold must hide the root's subtree");
508
+ assert(folded.some((row) => row.leaf?.label === "solo-skill"), "fold must not hide other roots");
509
+ return true;
510
+ }
511
+
512
+ function nativeActivePathsFor(ctx: import("@earendil-works/pi-coding-agent").ExtensionCommandContext): Set<string> {
513
+ const skills = ctx.getSystemPromptOptions()?.skills ?? [];
514
+ return new Set(skills.map((skill) => skill.filePath ?? ""));
515
+ }
516
+
517
+ // ─── 11. Auto-adoption of native pi locations ───────────────────────────────
518
+
519
+ /** A native pi directory that exists on disk appears in the registrar
520
+ * automatically: disabled, unapproved, flagged auto — and never duplicated,
521
+ * even after a save blesses it into the file. Runs LAST and cleans up after
522
+ * itself (it creates the agent-dir native location for this process). */
523
+ async function nativeAutoSuite(): Promise<boolean> {
524
+ const nativeDir = join(getAgentDir(), "skills");
525
+ const skillDir = join(nativeDir, "live-skill");
526
+ await mkdir(skillDir, { recursive: true });
527
+ try {
528
+ await writeFile(join(skillDir, "SKILL.md"),
529
+ "---\nname: live-skill\ndescription: A native skill found by auto-adoption.\n---\n\nLive.\n");
530
+ const dir = await tempDir("skill-smoke-auto-");
531
+ const path = join(dir, "config.json");
532
+ const loaded = await loadManagerConfig(path);
533
+ const auto = loaded.roots.find((root) => root.config.name === "pi-skills");
534
+ assert(auto && auto.auto === true, "auto-detected native root missing");
535
+ assert(auto!.config.native === true && auto!.config.enabled === false, "auto root must be a disabled native root");
536
+ assert(auto!.issues.some((issue) => issue.includes("Auto-detected")), "auto root must explain itself");
537
+ assert(!auto!.config.manifest, "auto root must not be pre-approved");
538
+ // Saving blesses the auto root into the file; a reload must not seed a duplicate.
539
+ await saveManagerConfig(loaded, path);
540
+ const reloaded = await loadManagerConfig(path);
541
+ const byPath = reloaded.roots.filter((root) => resolve(root.config.path) === resolve(nativeDir));
542
+ assert(byPath.length === 1, `auto root duplicated across a save: ${byPath.map((root) => root.config.name).join(", ")}`);
543
+ // Removing the directory removes the auto root again.
544
+ await rm(nativeDir, { recursive: true, force: true });
545
+ const withoutDir = await loadManagerConfig(join(dir, "config2.json"));
546
+ assert(!withoutDir.roots.some((root) => root.config.name === "pi-skills"), "auto root persisted after its directory vanished");
547
+ } finally {
548
+ await rm(nativeDir, { recursive: true, force: true }).catch(() => undefined);
549
+ }
550
+ return true;
551
+ }
552
+
553
+ // ─── Entry ─────────────────────────────────────────────────────────────────
554
+
555
+ export default function smokeExtension(pi: ExtensionAPI): void {
556
+ pi.on("session_start", async () => {
557
+ const settings = { ...DEFAULT_SETTINGS };
558
+ try {
559
+ const scans = await scanSuite(settings);
560
+ const roundtrip = await configRoundTrip();
561
+ const executor = await executorSuite(settings);
562
+ const regressions = {
563
+ frontmatter: frontmatterSuite(),
564
+ validation: validationSuite(),
565
+ nameInvariants: nameInvariants(),
566
+ malformedManifestSafety: await malformedManifestSafety(),
567
+ cacheMaxSkillsHonored: await cacheMaxSkillsHonored(),
568
+ nativeMirror: await nativeMirrorSuite(settings),
569
+ treeBuilder: await treeSuite(settings),
570
+ };
571
+ const autoAdoption = await nativeAutoSuite();
572
+ await writeFile(RESULT, JSON.stringify({ ok: true, scans, roundtrip, executor, regressions, autoAdoption }));
573
+ } catch (error) {
574
+ await writeFile(RESULT, JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
575
+ }
576
+ });
577
+ }