1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9
10const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) {
11 const fileSystem = yield* FileSystem.FileSystem;
12 const path = yield* Path.Path;
13 yield* fileSystem.makeDirectory(directory, { recursive: true });
14 const skillPath = path.join(directory, "SKILL.md");
15 yield* fileSystem.writeFileString(skillPath, contents);
16 return skillPath;
17});
18
19const makeWorkspace = Effect.fn("makeWorkspace")(function* () {
20 const fileSystem = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;
22 const temporaryDirectory = yield* fileSystem.makeTempDirectoryScoped({
23 prefix: "t3-antigravity-skills-",
24 });
25 return {
26 cwd: path.join(temporaryDirectory, "workspace"),
27 userHome: path.join(temporaryDirectory, "home"),
28 };
29});
30
31it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
32 it.effect("does not read user skills from a nested project or from ~/.agents", () =>
33 Effect.gen(function* () {
34 const path = yield* Path.Path;
35 const input = yield* makeWorkspace();
36 const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") };
37 const skillPath = yield* writeSkill(
38 path.join(input.userHome, ".gemini", "config", "skills", "review"),
39 "---\nname: review\ndescription: Review changes.\n---\n",
40 );
41 yield* writeSkill(
42 path.join(input.userHome, ".agents", "skills", "ignored"),
43 "---\nname: ignored\n---\n",
44 );
45
46 assert.deepEqual(yield* discoverAntigravitySkills(nested), [
47 {
48 name: "review",
49 description: "Review changes.",
50 path: skillPath,
51 scope: "user",
52 enabled: true,
53 },
54 ]);
55 // A project rooted at the home directory sees ~/.agents/skills as its own.
56 assert.deepEqual(
57 (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [
58 skill.name,
59 skill.scope,
60 ]),
61 [
62 ["ignored", "project"],
63 ["review", "user"],
64 ],
65 );
66 }),
67 );
68
69 it.effect("reads skill names, descriptions and paths from the current native roots", () =>
70 Effect.gen(function* () {
71 const path = yield* Path.Path;
72 const input = yield* makeWorkspace();
73 const roots = [
74 { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" },
75 { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" },
76 {
77 directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"),
78 scope: "user",
79 },
80 { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" },
81 ];
82 const expected = [];
83 for (const [index, root] of roots.entries()) {
84 const name = `review-${index}`;
85 const description = `Review changes in root ${index}.`;
86 const skillPath = yield* writeSkill(
87 path.join(root.directory, name),
88 `---\nname: ${name}\ndescription: ${description}\n---\n# Review\n`,
89 );
90 expected.push({ name, description, path: skillPath, scope: root.scope, enabled: true });
91 }
92
93 assert.deepEqual(yield* discoverAntigravitySkills(input), expected);
94 }),
95 );
96
97 it.effect("returns no skills when the native roots are missing", () =>
98 Effect.gen(function* () {
99 const input = yield* makeWorkspace();
100 assert.deepEqual(yield* discoverAntigravitySkills(input), []);
101 }),
102 );
103
104 it.effect("discovers skills from the legacy workspace root", () =>
105 Effect.gen(function* () {
106 const path = yield* Path.Path;
107 const input = yield* makeWorkspace();
108 const skillPath = yield* writeSkill(
109 path.join(input.cwd, ".agent", "skills", "review"),
110 "---\nname: review\ndescription: Review changes.\n---\n",
111 );
112
113 assert.deepEqual(yield* discoverAntigravitySkills(input), [
114 {
115 name: "review",
116 description: "Review changes.",
117 path: skillPath,
118 scope: "project",
119 enabled: true,
120 },
121 ]);
122 }),
123 );
124
125 it.effect("uses native root order for duplicate names", () =>
126 Effect.gen(function* () {
127 const fileSystem = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const input = yield* makeWorkspace();
130 const roots = [
131 path.join(input.userHome, ".gemini", "config", "skills"),
132 path.join(input.cwd, ".gemini", "skills"),
133 path.join(input.userHome, ".gemini", "antigravity-cli", "skills"),
134 path.join(input.cwd, ".agents", "skills"),
135 path.join(input.cwd, ".agent", "skills"),
136 ];
137 for (const [index, root] of roots.entries()) {
138 yield* writeSkill(
139 path.join(root, `copy-${index}`),
140 `---\nname: review\ndescription: Copy ${index}.\n---\n`,
141 );
142 }
143
144 for (const [index, root] of roots.entries()) {
145 const skills = yield* discoverAntigravitySkills(input);
146 assert.equal(skills.length, 1);
147 assert.equal(skills[0]?.path, path.join(root, `copy-${index}`, "SKILL.md"));
148 yield* fileSystem.remove(root, { recursive: true });
149 }
150 }),
151 );
152
153 it.effect("loads a root skill without scanning its children", () =>
154 Effect.gen(function* () {
155 const fileSystem = yield* FileSystem.FileSystem;
156 const path = yield* Path.Path;
157 const input = yield* makeWorkspace();
158 const root = path.join(input.cwd, ".agents", "skills");
159 const skillPath = yield* writeSkill(root, "---\nname: root-skill\n---\n");
160 yield* writeSkill(path.join(root, "child"), "---\nname: child-skill\n---\n");
161
162 assert.deepEqual(yield* discoverAntigravitySkills(input), [
163 { name: "root-skill", path: skillPath, scope: "project", enabled: true },
164 ]);
165
166 yield* fileSystem.writeFileString(skillPath, "---\nname: [invalid\n---\n");
167 assert.deepEqual(yield* discoverAntigravitySkills(input), []);
168 }),
169 );
170
171 it.effect("uses the filename when valid frontmatter has no name", () =>
172 Effect.gen(function* () {
173 const fileSystem = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175 const input = yield* makeWorkspace();
176 const skillPath = yield* writeSkill(
177 path.join(input.cwd, ".agents", "skills", "not-the-name"),
178 "---\n---\n# Skill body\n",
179 );
180
181 assert.deepEqual(yield* discoverAntigravitySkills(input), [
182 { name: "SKILL", path: skillPath, scope: "project", enabled: true },
183 ]);
184
185 const lowerCasePath = path.join(path.dirname(skillPath), "skill.md");
186 yield* fileSystem.rename(skillPath, lowerCasePath);
187 assert.deepEqual(yield* discoverAntigravitySkills(input), [
188 { name: "skill", path: lowerCasePath, scope: "project", enabled: true },
189 ]);
190
191 yield* fileSystem.rename(lowerCasePath, path.join(path.dirname(skillPath), "SKILL.MD"));
192 assert.deepEqual(yield* discoverAntigravitySkills(input), []);
193 }),
194 );
195
196 it.effect("accepts native metadata delimiters after leading text", () =>
197 Effect.gen(function* () {
198 const path = yield* Path.Path;
199 const input = yield* makeWorkspace();
200 const skillPath = yield* writeSkill(
201 path.join(input.cwd, ".agents", "skills", "review"),
202 "Leading text.---\nname: review\ndescription: null\n---Skill body.",
203 );
204
205 assert.deepEqual(yield* discoverAntigravitySkills(input), [
206 { name: "review", path: skillPath, scope: "project", enabled: true },
207 ]);
208 }),
209 );
210
211 it.effect("ignores invalid files and deeper directories but keeps native hidden skills", () =>
212 Effect.gen(function* () {
213 const fileSystem = yield* FileSystem.FileSystem;
214 const path = yield* Path.Path;
215 const input = yield* makeWorkspace();
216 const root = path.join(input.cwd, ".agents", "skills");
217 const invalidSkills = [
218 ["plain", "# Missing frontmatter\n"],
219 ["broken", "---\nname: [unclosed\n---\n"],
220 ["scalar", "---\n42\n---\n"],
221 ["wrong-type", "---\nname: invalid\ndescription: {}\n---\n"],
222 ["blank-name", '---\nname: " "\n---\n'],
223 ] as const;
224 for (const [name, contents] of invalidSkills) {
225 yield* writeSkill(path.join(root, name), contents);
226 }
227 yield* writeSkill(path.join(root, "nested", "deep"), "---\nname: too-deep\n---\n");
228 yield* writeSkill(
229 path.join(input.cwd, ".claude", "skills", "wrong-provider"),
230 "---\nname: wrong-provider\n---\n",
231 );
232 yield* fileSystem.makeDirectory(path.join(root, ".not-a-skill"));
233 yield* fileSystem.writeFileString(path.join(root, "README.md"), "Not a skill.");
234 const skillPath = yield* writeSkill(
235 path.join(root, ".native-hidden-skill"),
236 "---\nname: native-name\ndescription: >\n Review the code\n and run tests.\n---\n",
237 );
238
239 assert.deepEqual(yield* discoverAntigravitySkills(input), [
240 {
241 name: "native-name",
242 description: "Review the code and run tests.",
243 path: skillPath,
244 scope: "project",
245 enabled: true,
246 },
247 ]);
248 }),
249 );
250
251 it.effect("uses native URI order within a root and skips an invalid higher root", () =>
252 Effect.gen(function* () {
253 const fileSystem = yield* FileSystem.FileSystem;
254 const path = yield* Path.Path;
255 const input = yield* makeWorkspace();
256 const root = path.join(input.cwd, ".agents", "skills");
257 yield* writeSkill(
258 path.join(input.userHome, ".gemini", "config", "skills", "review"),
259 "---\nname: [invalid\n---\n",
260 );
261 const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"];
262 for (const name of nativeOrder) {
263 yield* writeSkill(path.join(root, name), "---\nname: review\n---\n");
264 }
265
266 for (const name of nativeOrder) {
267 assert.deepEqual(yield* discoverAntigravitySkills(input), [
268 {
269 name: "review",
270 path: path.join(root, name, "SKILL.md"),
271 scope: "project",
272 enabled: true,
273 },
274 ]);
275 yield* fileSystem.remove(path.join(root, name), { recursive: true });
276 }
277 }),
278 );
279
280 it.effect.skipIf(!symlinksSupported)(
281 "follows directory symlinks used to install shared skills",
282 () =>
283 Effect.gen(function* () {
284 const fileSystem = yield* FileSystem.FileSystem;
285 const path = yield* Path.Path;
286 const input = yield* makeWorkspace();
287 const sourceDirectory = path.join(input.userHome, "shared-review");
288 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");
289 const root = path.join(input.cwd, ".agents", "skills");
290 const linkedDirectory = path.join(root, "review");
291 yield* fileSystem.makeDirectory(root, { recursive: true });
292 yield* fileSystem.symlink(sourceDirectory, linkedDirectory);
293
294 assert.deepEqual(yield* discoverAntigravitySkills(input), [
295 {
296 name: "review",
297 path: path.join(linkedDirectory, "SKILL.md"),
298 scope: "project",
299 enabled: true,
300 },
301 ]);
302 }),
303 );
304
305 it.effect("rejects an oversized skill instead of returning an incomplete catalog", () =>
306 Effect.gen(function* () {
307 const path = yield* Path.Path;
308 const input = yield* makeWorkspace();
309 const skillPath = yield* writeSkill(
310 path.join(input.cwd, ".agents", "skills", "oversized"),
311 `---\nname: oversized\ndescription: Read a large skill.\n---\n${"x".repeat(1_000_000)}`,
312 );
313
314 const result = yield* discoverAntigravitySkills(input).pipe(Effect.result);
315 assert.equal(result._tag, "Failure");
316 if (result._tag === "Failure") {
317 assert.equal(result.failure.reason, "scan-budget-exhausted");
318 assert.equal(result.failure.path, skillPath);
319 }
320 }),
321 );
322
323 it.effect("bounds the total read size across skills", () =>
324 Effect.gen(function* () {
325 const path = yield* Path.Path;
326 const input = yield* makeWorkspace();
327 const root = path.join(input.cwd, ".agents", "skills");
328 for (let index = 0; index < 9; index += 1) {
329 yield* writeSkill(
330 path.join(root, `large-${index}`),
331 `---\nname: large-${index}\n---\n${"x".repeat(900_000)}`,
332 );
333 }
334
335 const result = yield* discoverAntigravitySkills(input).pipe(Effect.result);
336 assert.equal(result._tag, "Failure");
337 if (result._tag === "Failure") {
338 assert.equal(result.failure.reason, "scan-budget-exhausted");
339 assert.equal(result.failure.path, path.join(root, "large-8", "SKILL.md"));
340 }
341 }),
342 );
343});
344
345it("resolves the home the agent expands ~ against", () => {
346 assert.equal(
347 resolveAntigravityUserHome(
348 "linux",
349 { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" },
350 "/home/fallback",
351 ),
352 "/home/user",
353 );
354 assert.equal(
355 resolveAntigravityUserHome(
356 "win32",
357 { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" },
358 "/home/fallback",
359 ),
360 "C:\\Users\\user",
361 );
362 assert.equal(
363 resolveAntigravityUserHome(
364 "win32",
365 { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" },
366 "/home/fallback",
367 ),
368 "D:\\Users\\alice",
369 );
370 assert.equal(
371 resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }, "/home/fallback"),
372 "/Users/a b ",
373 );
374 assert.equal(
375 resolveAntigravityUserHome("darwin", { HOME: "" }, "/home/fallback"),
376 "/home/fallback",
377 );
378});