1import * as NodeOS from "node:os";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "vite-plus/test";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import type * as Scope from "effect/Scope";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9
10import {
11 discoverCursorSkills,
12 hasCursorSkillMention,
13 probeCursorSkills,
14 rewriteCursorSkillMentions,
15} from "./skills.ts";
16
17const runNode = <A, E>(
18 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
19) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);
20
21describe("Cursor skills", () => {
22 it("discovers recursive project skills with project precedence", async () =>
23 await runNode(
24 Effect.gen(function* () {
25 const fileSystem = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const userHome = yield* fileSystem
28 .makeTempDirectoryScoped({
29 directory: NodeOS.tmpdir(),
30 prefix: "cursor-skills-home-",
31 })
32 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
33 const workspace = yield* fileSystem
34 .makeTempDirectoryScoped({
35 directory: NodeOS.tmpdir(),
36 prefix: "cursor-skills-workspace-",
37 })
38 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
39 const writeSkill = Effect.fn("writeCursorSkill")(function* (
40 root: string,
41 name: string,
42 contents: string,
43 ) {
44 const skillDirectory = path.join(root, name);
45 yield* fileSystem.makeDirectory(skillDirectory, { recursive: true });
46 yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents);
47 });
48
49 yield* writeSkill(
50 path.join(userHome, ".cursor", "skills"),
51 "review",
52 "---\ndescription: user review\n---\n",
53 );
54 yield* writeSkill(
55 path.join(workspace, ".agents", "skills", "nested"),
56 "review",
57 "---\nname: Review changes\ndescription: project review\n---\n",
58 );
59 yield* writeSkill(
60 path.join(workspace, ".cursor", "skills"),
61 "internal",
62 "---\nuser-invocable: false\n---\n",
63 );
64 yield* writeSkill(
65 path.join(workspace, ".cursor", "skills"),
66 "oversized",
67 "x".repeat(1_000_001),
68 );
69 yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true });
70 yield* fileSystem.writeFileString(
71 path.join(userHome, ".codex", "skills"),
72 "not a directory",
73 );
74
75 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
76 expect(skills).toEqual([
77 {
78 name: "internal",
79 path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"),
80 scope: "project",
81 enabled: true,
82 userInvocable: false,
83 },
84 {
85 name: "oversized",
86 path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"),
87 scope: "project",
88 enabled: true,
89 },
90 {
91 name: "review",
92 displayName: "Review changes",
93 description: "project review",
94 path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"),
95 scope: "project",
96 enabled: true,
97 },
98 ]);
99 expect(
100 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
101 ).toBe("Failure");
102 }),
103 ));
104
105 it("skips a skill tree deeper than the limit instead of failing discovery", async () =>
106 await runNode(
107 Effect.gen(function* () {
108 const fileSystem = yield* FileSystem.FileSystem;
109 const path = yield* Path.Path;
110 const tempDirectory = (prefix: string) =>
111 fileSystem
112 .makeTempDirectoryScoped({ directory: NodeOS.tmpdir(), prefix })
113 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
114 const userHome = yield* tempDirectory("cursor-skills-home-");
115 const workspace = yield* tempDirectory("cursor-skills-workspace-");
116 const writeSkill = Effect.fn("writeCursorSkill")(function* (directory: string) {
117 yield* fileSystem.makeDirectory(directory, { recursive: true });
118 yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), "---\n---\n");
119 });
120 const userRoot = path.join(userHome, ".claude", "skills");
121 // The root is depth 0, so `deep-skill` sits at the depth limit and `below` past it.
122 const atLimit = path.join(
123 userRoot,
124 "deep",
125 "2",
126 "3",
127 "4",
128 "5",
129 "6",
130 "7",
131 "8",
132 "9",
133 "deep-skill",
134 );
135
136 yield* writeSkill(path.join(workspace, ".agents", "skills", "review"));
137 yield* writeSkill(atLimit);
138 yield* writeSkill(path.join(atLimit, "below"));
139 yield* writeSkill(path.join(userRoot, "later"));
140
141 const expected = [
142 {
143 name: "deep-skill",
144 path: path.join(atLimit, "SKILL.md"),
145 scope: "user",
146 enabled: true,
147 },
148 {
149 name: "later",
150 path: path.join(userRoot, "later", "SKILL.md"),
151 scope: "user",
152 enabled: true,
153 },
154 {
155 name: "review",
156 path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"),
157 scope: "project",
158 enabled: true,
159 },
160 ];
161 expect(yield* discoverCursorSkills(workspace, { HOME: userHome })).toEqual(expected);
162 expect(yield* probeCursorSkills(workspace, { HOME: userHome })).toEqual(expected);
163 }),
164 ));
165
166 it("still fails discovery when a root holds more entries than the scan allows", async () =>
167 await runNode(
168 Effect.gen(function* () {
169 const fileSystem = yield* FileSystem.FileSystem;
170 const path = yield* Path.Path;
171 const userHome = yield* fileSystem.makeTempDirectoryScoped({
172 directory: NodeOS.tmpdir(),
173 prefix: "cursor-skills-home-",
174 });
175 const root = path.join(userHome, ".cursor", "skills");
176 yield* fileSystem.makeDirectory(root, { recursive: true });
177 yield* Effect.forEach(
178 Array.from({ length: 10_001 }, (_, index) => path.join(root, `entry-${index}`)),
179 (file) => fileSystem.writeFileString(file, ""),
180 { concurrency: 64, discard: true },
181 );
182
183 const error = yield* Effect.flip(probeCursorSkills(undefined, { HOME: userHome }));
184 expect(error.message).toContain("scan-budget-exhausted");
185 }),
186 ));
187
188 it.skipIf(!symlinksSupported)(
189 "treats a symlinked skill outside the root as a package boundary",
190 async () =>
191 await runNode(
192 Effect.gen(function* () {
193 const fileSystem = yield* FileSystem.FileSystem;
194 const path = yield* Path.Path;
195 const userHome = yield* fileSystem.makeTempDirectoryScoped({
196 directory: NodeOS.tmpdir(),
197 prefix: "cursor-skills-home-",
198 });
199 const workspace = yield* fileSystem
200 .makeTempDirectoryScoped({
201 directory: NodeOS.tmpdir(),
202 prefix: "cursor-skills-workspace-",
203 })
204 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
205 const library = yield* fileSystem.makeTempDirectoryScoped({
206 directory: NodeOS.tmpdir(),
207 prefix: "cursor-skills-library-",
208 });
209 const writeSkill = Effect.fn("writeCursorSkill")(function* (
210 directory: string,
211 contents: string,
212 ) {
213 yield* fileSystem.makeDirectory(directory, { recursive: true });
214 yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents);
215 });
216
217 // A skill package managed in a config repo and installed by symlink.
218 // Its own SKILL.md must be discovered under the link name, but nothing
219 // below the target may be walked.
220 yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n");
221 yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n");
222 const root = path.join(workspace, ".cursor", "skills");
223 yield* fileSystem.makeDirectory(root, { recursive: true });
224 yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review"));
225
226 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
227 expect(skills).toEqual([
228 {
229 name: "review",
230 description: "shared",
231 path: path.join(root, "review", "SKILL.md"),
232 scope: "project",
233 enabled: true,
234 },
235 ]);
236 expect(
237 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
238 ).toBe("Success");
239 }),
240 ),
241 );
242
243 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {
244 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);
245 expect(hasCursorSkillMention("please $review this")).toBe(true);
246 expect(
247 rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])),
248 ).toBe("use $review, keep $HOME and 5$review");
249 expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe(
250 "please /review this",
251 );
252 });
253 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
254 const names = new Set(["2spec", "20k", "100M", "1e6"]);
255 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
256 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
257 expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here");
258 expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here");
259 for (const text of [
260 "pay $20 tomorrow",
261 "budget $20k here",
262 "cost $100M total",
263 "limit $1e6 here",
264 ]) {
265 expect(hasCursorSkillMention(text)).toBe(false);
266 expect(rewriteCursorSkillMentions(text, names)).toBe(text);
267 }
268 });
269});