authorgravatar for ibxbit@gmail.comEzra <ibxbit@gmail.com> 2026-09-05 02:27:27+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 16:27:27-07:00
log087cfb8ae262f344f7e409f5a8c0eda6f0ef12f9
tree8a97f962fb8fb57ed08faf917052d3297a94be83
parent5a433244d0b827176df8b76a05959cd1a7a98ce2
signature Signed by PGP key B5690EEEBB952194

fix(cursor): discover symlinked skills as package boundaries (#9420)


2 files changed, 64 insertions(+), 9 deletions(-)

apps/server/src/provider/Drivers/CursorSkills.ts+14-9
......@@ -147,16 +147,18 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
147147 if (!resolvedDirectory) {
148148 return;
149149 }
150 if (
151 visitedDirectories.has(resolvedDirectory) ||
152 (resolvedDirectory !== rootDirectory &&
153 !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`))
154 ) {
150 if (visitedDirectories.has(resolvedDirectory)) {
155151 return;
156152 }
157153 visitedDirectories.add(resolvedDirectory);
154 // A symlink whose target lives outside the root is a skill package
155 // boundary: read its own SKILL.md so linked skill libraries show up, but
156 // never walk the target tree.
157 const insideRoot =
158 resolvedDirectory === rootDirectory ||
159 resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`);
158160
159 const skillPath = path.join(resolvedDirectory, "SKILL.md");
161 const skillPath = path.join(directory, "SKILL.md");
160162 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);
161163 if (skillInfo?.type === "File") {
162164 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };
......@@ -167,7 +169,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
167169 frontmatter = parseSkillFrontmatter(contents);
168170 }
169171 }
170 const name = path.basename(resolvedDirectory).trim();
172 const name = path.basename(directory).trim();
171173 if (frontmatter?.cliVisible && name) {
172174 skills.push({
173175 name,
......@@ -184,7 +186,10 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
184186 }
185187 }
186188
187 const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget);
189 if (!insideRoot) {
190 return;
191 }
192 const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget);
188193 if (!entries) {
189194 return;
190195 }
......@@ -194,7 +199,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
194199 return;
195200 }
196201 input.budget.remainingEntries -= 1;
197 const child = path.join(resolvedDirectory, entry);
202 const child = path.join(directory, entry);
198203 const info = yield* orUndefined(fileSystem.stat(child), input.budget);
199204 if (info?.type !== "Directory") continue;
200205 if (depth >= MAX_SKILL_DEPTH) {
apps/server/src/provider/Layers/CursorProvider.test.ts+50
......@@ -395,6 +395,56 @@ describe("Cursor skills", () => {
395395 }),
396396 ));
397397
398 it("treats a symlinked skill outside the root as a package boundary", async () =>
399 await runNode(
400 Effect.gen(function* () {
401 const fileSystem = yield* FileSystem.FileSystem;
402 const path = yield* Path.Path;
403 const userHome = yield* fileSystem.makeTempDirectory({
404 directory: NodeOS.tmpdir(),
405 prefix: "cursor-skills-home-",
406 });
407 const workspace = yield* fileSystem.makeTempDirectory({
408 directory: NodeOS.tmpdir(),
409 prefix: "cursor-skills-workspace-",
410 });
411 const library = yield* fileSystem.makeTempDirectory({
412 directory: NodeOS.tmpdir(),
413 prefix: "cursor-skills-library-",
414 });
415 const writeSkill = Effect.fn("writeCursorSkill")(function* (
416 directory: string,
417 contents: string,
418 ) {
419 yield* fileSystem.makeDirectory(directory, { recursive: true });
420 yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents);
421 });
422
423 // A skill package managed in a config repo and installed by symlink.
424 // Its own SKILL.md must be discovered under the link name, but nothing
425 // below the target may be walked.
426 yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n");
427 yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n");
428 const root = path.join(workspace, ".cursor", "skills");
429 yield* fileSystem.makeDirectory(root, { recursive: true });
430 yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review"));
431
432 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
433 expect(skills).toEqual([
434 {
435 name: "review",
436 description: "shared",
437 path: path.join(root, "review", "SKILL.md"),
438 scope: "project",
439 enabled: true,
440 },
441 ]);
442 expect(
443 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
444 ).toBe("Success");
445 }),
446 ));
447
398448 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {
399449 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);
400450 expect(hasCursorSkillMention("please $review this")).toBe(true);