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* (...@@ -147,16 +147,18 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
147 if (!resolvedDirectory) {147 if (!resolvedDirectory) {
148 return;148 return;
149 }149 }
150 if (150 if (visitedDirectories.has(resolvedDirectory)) {
151 visitedDirectories.has(resolvedDirectory) ||
152 (resolvedDirectory !== rootDirectory &&
153 !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`))
154 ) {
155 return;151 return;
156 }152 }
157 visitedDirectories.add(resolvedDirectory);153 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");
160 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);162 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);
161 if (skillInfo?.type === "File") {163 if (skillInfo?.type === "File") {
162 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };164 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };
...@@ -167,7 +169,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (...@@ -167,7 +169,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
167 frontmatter = parseSkillFrontmatter(contents);169 frontmatter = parseSkillFrontmatter(contents);
168 }170 }
169 }171 }
170 const name = path.basename(resolvedDirectory).trim();172 const name = path.basename(directory).trim();
171 if (frontmatter?.cliVisible && name) {173 if (frontmatter?.cliVisible && name) {
172 skills.push({174 skills.push({
173 name,175 name,
...@@ -184,7 +186,10 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (...@@ -184,7 +186,10 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
184 }186 }
185 }187 }
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);
188 if (!entries) {193 if (!entries) {
189 return;194 return;
190 }195 }
...@@ -194,7 +199,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (...@@ -194,7 +199,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (
194 return;199 return;
195 }200 }
196 input.budget.remainingEntries -= 1;201 input.budget.remainingEntries -= 1;
197 const child = path.join(resolvedDirectory, entry);202 const child = path.join(directory, entry);
198 const info = yield* orUndefined(fileSystem.stat(child), input.budget);203 const info = yield* orUndefined(fileSystem.stat(child), input.budget);
199 if (info?.type !== "Directory") continue;204 if (info?.type !== "Directory") continue;
200 if (depth >= MAX_SKILL_DEPTH) {205 if (depth >= MAX_SKILL_DEPTH) {
apps/server/src/provider/Layers/CursorProvider.test.ts+50
...@@ -395,6 +395,56 @@ describe("Cursor skills", () => {...@@ -395,6 +395,56 @@ describe("Cursor skills", () => {
395 }),395 }),
396 ));396 ));
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
398 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {448 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {
399 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);449 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);
400 expect(hasCursorSkillMention("please $review this")).toBe(true);450 expect(hasCursorSkillMention("please $review this")).toBe(true);