skill-locator.test.ts
4102 bytes
1import { describe, it, beforeEach, afterEach } from "node:test";
2import assert from "node:assert/strict";
3import type { FileSystem } from "../ports/fs.ts";
4import { DefaultSkillLocator } from "./skill-locator.ts";
5
6const ORIGINAL_HOME = process.env.HOME;
7const ORIGINAL_PI_CODING_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
8
9describe("DefaultSkillLocator", () => {
10 beforeEach(() => {
11 process.env.HOME = "/home/tester";
12 process.env.PI_CODING_AGENT_DIR = "/home/tester/.pi/agent";
13 });
14
15 afterEach(() => {
16 restoreEnv("HOME", ORIGINAL_HOME);
17 restoreEnv("PI_CODING_AGENT_DIR", ORIGINAL_PI_CODING_AGENT_DIR);
18 });
19
20 it("finds global, user, and project skills with Pi root markdown discovery rules", async () => {
21 const fs = new MemoryTreeFileSystem([
22 "/home/tester/.pi/agent/skills/user-root.md",
23 "/home/tester/.agents/skills/ignored-global-root.md",
24 "/home/tester/.agents/skills/global-skill/SKILL.md",
25 "/repo/.pi/skills/project-root.md",
26 "/repo/.agents/skills/ignored-project-legacy-root.md",
27 "/repo/.agents/skills/project-legacy-skill/SKILL.md",
28 ]);
29 const locator = new DefaultSkillLocator(fs);
30
31 const files = await locator.findSkillFiles("/repo");
32 const byPath = new Map(files.map((file) => [file.filePath, file]));
33
34 assert.equal(byPath.get("/home/tester/.pi/agent/skills/user-root.md")?.source.kind, "user");
35 assert.equal(byPath.get("/home/tester/.agents/skills/global-skill/SKILL.md")?.source.kind, "global");
36 assert.equal(byPath.get("/repo/.pi/skills/project-root.md")?.source.kind, "project");
37 assert.equal(byPath.get("/repo/.agents/skills/project-legacy-skill/SKILL.md")?.source.kind, "project-legacy");
38 assert.equal(byPath.has("/home/tester/.agents/skills/ignored-global-root.md"), false);
39 assert.equal(byPath.has("/repo/.agents/skills/ignored-project-legacy-root.md"), false);
40 });
41});
42
43function restoreEnv(name: string, value: string | undefined): void {
44 if (value === undefined) {
45 delete process.env[name];
46 } else {
47 process.env[name] = value;
48 }
49}
50
51class MemoryTreeFileSystem implements FileSystem {
52 private readonly files = new Set<string>();
53 private readonly dirs = new Set<string>(["/"]);
54
55 constructor(paths: string[]) {
56 for (const path of paths) this.addFile(path);
57 }
58
59 async readFile(path: string): Promise<string> {
60 if (!this.files.has(path)) throw new Error(`missing file: ${path}`);
61 return "---\nname: test\ndescription: Test skill.\n---\n";
62 }
63
64 async writeFileAtomic(): Promise<void> {}
65
66 async access(path: string): Promise<boolean> {
67 return this.files.has(path) || this.dirs.has(path);
68 }
69
70 async readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
71 if (!this.dirs.has(path)) throw new Error(`missing dir: ${path}`);
72 const prefix = path === "/" ? "/" : `${path}/`;
73 const names = new Set<string>();
74 for (const dir of this.dirs) {
75 if (dir === path || !dir.startsWith(prefix)) continue;
76 const rest = dir.slice(prefix.length);
77 const [name] = rest.split("/");
78 if (name) names.add(name);
79 }
80 for (const file of this.files) {
81 if (!file.startsWith(prefix)) continue;
82 const rest = file.slice(prefix.length);
83 const [name] = rest.split("/");
84 if (name) names.add(name);
85 }
86 return [...names].sort().map((name) => {
87 const fullPath = path === "/" ? `/${name}` : `${path}/${name}`;
88 return {
89 name,
90 isDirectory: this.dirs.has(fullPath),
91 isFile: this.files.has(fullPath),
92 isSymbolicLink: false,
93 };
94 });
95 }
96
97 async stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
98 return { isDirectory: this.dirs.has(path), isFile: this.files.has(path), mode: 0o644 };
99 }
100
101 private addFile(path: string): void {
102 this.files.add(path);
103 const parts = path.split("/").filter(Boolean);
104 let current = "";
105 for (const part of parts.slice(0, -1)) {
106 current += `/${part}`;
107 this.dirs.add(current);
108 }
109 }
110}