diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts index 382f9c6dc54e..640515629815 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.test.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -1,9 +1,11 @@ +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "vite-plus/test"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -18,6 +20,8 @@ const runNode = ( effect: Effect.Effect, ) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + describe("Cursor skills", () => { it("discovers recursive project skills with project precedence", async () => await runNode( @@ -183,4 +187,482 @@ describe("Cursor skills", () => { expect(rewriteCursorSkillMentions(text, names)).toBe(text); } }); + + it("discovers local and completed cache plugin skills without overriding folder skills", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-skills-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const workspace = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-skills-workspace-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const writeSkill = Effect.fn("writeCursorPluginSkill")(function* ( + root: string, + name: string, + contents: string, + ) { + const skillDirectory = path.join(root, name); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); + }); + const markComplete = Effect.fn("markCursorPluginCacheComplete")(function* ( + directory: string, + ) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, ".cache-complete"), ""); + }); + + yield* writeSkill( + path.join(workspace, ".cursor", "skills"), + "review", + "---\ndescription: project review\n---\n", + ); + + const localPlugin = path.join(userHome, ".cursor", "plugins", "local", "team"); + yield* writeSkill( + path.join(localPlugin, "skills"), + "review", + "---\ndescription: plugin review\n---\n", + ); + yield* writeSkill( + path.join(localPlugin, "skills"), + "local-only", + "---\nname: Local only\ndescription: from local\nuser-invocable: false\n---\n", + ); + yield* writeSkill( + path.join(localPlugin, "skills", "local-only", "nested"), + "hidden", + "---\ndescription: hidden\n---\n", + ); + yield* writeSkill( + path.join(userHome, ".cursor", "plugins", "local", ".secret", "skills"), + "secret", + "---\ndescription: secret\n---\n", + ); + + const figma = path.join( + userHome, + ".cursor", + "plugins", + "cache", + "cursor-public", + "figma", + "abc", + ); + yield* markComplete(figma); + yield* fileSystem.makeDirectory(path.join(figma, ".cursor-plugin"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(figma, ".cursor-plugin", "plugin.json"), + encodeJson({ skills: "./skills/" }), + ); + yield* writeSkill( + path.join(figma, "skills"), + "figma-use", + "---\ndescription: figma\n---\n", + ); + yield* writeSkill( + path.join(figma, "workflow-skills"), + "figma-workflow", + "---\ndescription: workflow\n---\n", + ); + yield* fileSystem.writeFileString( + path.join(figma, "SKILL.md"), + "---\nname: figma-root\ndescription: root should stay out\n---\n", + ); + + const fileSkill = path.join( + userHome, + ".cursor", + "plugins", + "cache", + "cursor-public", + "listed", + "sha", + ); + yield* markComplete(fileSkill); + yield* fileSystem.makeDirectory(path.join(fileSkill, ".cursor-plugin"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(fileSkill, ".cursor-plugin", "plugin.json"), + encodeJson({ skills: "./skills/named/SKILL.md" }), + ); + yield* writeSkill( + path.join(fileSkill, "skills"), + "named", + "---\ndescription: named file\n---\n", + ); + yield* writeSkill( + path.join(fileSkill, "skills"), + "other", + "---\ndescription: not listed\n---\n", + ); + + const plain = path.join( + userHome, + ".cursor", + "plugins", + "cache", + "cursor-public", + "plain", + "def", + ); + yield* markComplete(plain); + yield* writeSkill( + path.join(plain, "skills"), + "plain-skill", + "---\ndescription: plain\n---\n", + ); + yield* fileSystem.writeFileString( + path.join(plain, "SKILL.md"), + "---\nname: plain-root\ndescription: root skill\n---\n", + ); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "def", + displayName: "plain-root", + description: "root skill", + path: path.join(plain, "SKILL.md"), + scope: "user", + enabled: true, + }, + { + name: "figma-use", + description: "figma", + path: path.join(figma, "skills", "figma-use", "SKILL.md"), + scope: "user", + enabled: true, + }, + { + name: "local-only", + displayName: "Local only", + description: "from local", + path: path.join(localPlugin, "skills", "local-only", "SKILL.md"), + scope: "user", + enabled: true, + userInvocable: false, + }, + { + name: "named", + description: "named file", + path: path.join(fileSkill, "skills", "named", "SKILL.md"), + scope: "user", + enabled: true, + }, + { + name: "plain-skill", + description: "plain", + path: path.join(plain, "skills", "plain-skill", "SKILL.md"), + scope: "user", + enabled: true, + }, + { + name: "review", + description: "project review", + path: path.join(workspace, ".cursor", "skills", "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + const invocable = new Set( + skills.filter((skill) => skill.userInvocable !== false).map((skill) => skill.name), + ); + expect(rewriteCursorSkillMentions("use $figma-use and $local-only", invocable)).toBe( + "use /figma-use and $local-only", + ); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Success"); + }), + )); + + it("uses one completed cache version and manifest skill directories", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-cache-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const writeSkill = Effect.fn("writeCachedCursorPluginSkill")(function* ( + root: string, + name: string, + ) { + const skillDirectory = path.join(root, name); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(skillDirectory, "SKILL.md"), + `---\ndescription: ${name}\n---\n`, + ); + }); + const markComplete = Effect.fn("markCursorPluginCacheComplete")(function* ( + directory: string, + ) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, ".cache-complete"), ""); + }); + + const cache = path.join(userHome, ".cursor", "plugins", "cache", "mkt"); + const oldVersion = path.join(cache, "multi", "old"); + const newVersion = path.join(cache, "multi", "new"); + yield* writeSkill(path.join(oldVersion, "skills"), "stale"); + yield* writeSkill(path.join(newVersion, "skills"), "fresh"); + yield* markComplete(oldVersion); + yield* markComplete(newVersion); + + const onlyVersion = path.join(cache, "only", "sha"); + yield* writeSkill(path.join(onlyVersion, "skills"), "only-skill"); + yield* markComplete(onlyVersion); + const staleIncomplete = path.join(cache, "only", "older"); + yield* writeSkill(path.join(staleIncomplete, "skills"), "ignored"); + + const escapeRoot = path.join(cache, "escape", "sha"); + yield* markComplete(escapeRoot); + yield* fileSystem.makeDirectory(path.join(escapeRoot, ".cursor-plugin"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(escapeRoot, ".cursor-plugin", "plugin.json"), + encodeJson({ + skills: ["../../../../outside-secret/secret", "skills/"], + }), + ); + yield* writeSkill(path.join(userHome, ".cursor", "plugins", "outside-secret"), "secret"); + yield* writeSkill(path.join(escapeRoot, "skills"), "kept"); + + const skills = yield* discoverCursorSkills(undefined, { HOME: userHome }); + expect(skills.map((skill) => skill.name).toSorted()).toEqual(["kept", "only-skill"]); + }), + )); + + it("skips an oversized plugin manifest and still reads skills/", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-manifest-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const plugin = path.join(userHome, ".cursor", "plugins", "local", "huge"); + const skillDirectory = path.join(plugin, "skills", "kept"); + yield* fileSystem.makeDirectory(path.join(plugin, ".cursor-plugin"), { recursive: true }); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(plugin, ".cursor-plugin", "plugin.json"), + "x".repeat(1_000_001), + ); + yield* fileSystem.writeFileString( + path.join(skillDirectory, "SKILL.md"), + "---\ndescription: kept\n---\n", + ); + + const skills = yield* discoverCursorSkills(undefined, { HOME: userHome }); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + expect( + (yield* probeCursorSkills(undefined, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Success"); + }), + )); + + it.skipIf(!symlinksSupported)( + "skips a plugin skill file that points outside the plugin", + async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-link-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-link-outside-", + }); + yield* fileSystem.writeFileString( + path.join(outside, "SKILL.md"), + "---\ndescription: escaped\n---\n", + ); + const plugin = path.join(userHome, ".cursor", "plugins", "local", "team"); + const skillDirectory = path.join(plugin, "skills", "safe"); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(skillDirectory, "SKILL.md"), + "---\ndescription: safe\n---\n", + ); + const escapedDirectory = path.join(plugin, "skills", "escaped"); + yield* fileSystem.makeDirectory(escapedDirectory, { recursive: true }); + yield* fileSystem.symlink( + path.join(outside, "SKILL.md"), + path.join(escapedDirectory, "SKILL.md"), + ); + yield* fileSystem.symlink( + outside, + path.join(userHome, ".cursor", "plugins", "local", "linked-out"), + ); + + const skills = yield* discoverCursorSkills(undefined, { HOME: userHome }); + expect(skills.map((skill) => skill.name)).toEqual(["safe"]); + }), + ), + ); + + it.skipIf(!symlinksSupported)( + "skips a completed cache version that points outside the cache", + async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-cache-link-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-cache-link-outside-", + }); + const outsideSkill = path.join(outside, "skills", "escaped"); + yield* fileSystem.makeDirectory(outsideSkill, { recursive: true }); + yield* fileSystem.writeFileString(path.join(outside, ".cache-complete"), ""); + yield* fileSystem.writeFileString( + path.join(outsideSkill, "SKILL.md"), + "---\ndescription: escaped\n---\n", + ); + const versionLink = path.join( + userHome, + ".cursor", + "plugins", + "cache", + "mkt", + "linked", + "sha", + ); + yield* fileSystem.makeDirectory(path.dirname(versionLink), { recursive: true }); + yield* fileSystem.symlink(outside, versionLink); + + const skills = yield* discoverCursorSkills(undefined, { HOME: userHome }); + expect(skills.map((skill) => skill.name)).toEqual([]); + }), + ), + ); + + it("discovers local plugin skills before the cache listing can exhaust the scan", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-budget-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const skillDirectory = path.join( + userHome, + ".cursor", + "plugins", + "local", + "team", + "skills", + "local-kept", + ); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(skillDirectory, "SKILL.md"), + "---\ndescription: local\n---\n", + ); + const earlySkill = path.join( + userHome, + ".cursor", + "plugins", + "cache", + "mkt", + "a-early", + "sha", + "skills", + "cache-kept", + ); + yield* fileSystem.makeDirectory(earlySkill, { recursive: true }); + yield* fileSystem.writeFileString(path.join(earlySkill, "..", "..", ".cache-complete"), ""); + yield* fileSystem.writeFileString( + path.join(earlySkill, "SKILL.md"), + "---\ndescription: cache\n---\n", + ); + const versions = path.join(userHome, ".cursor", "plugins", "cache", "mkt", "bulk"); + yield* fileSystem.makeDirectory(versions, { recursive: true }); + yield* Effect.sync(() => { + for (let index = 0; index < 10_000; index += 1) { + NodeFS.mkdirSync(path.join(versions, `v${index}`)); + } + }); + + const skills = yield* discoverCursorSkills(undefined, { HOME: userHome }); + expect(skills.map((skill) => skill.name)).toEqual(["cache-kept", "local-kept"]); + expect( + (yield* probeCursorSkills(undefined, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Failure"); + }), + )); + + it("reads an accepted local plugin before later entries exhaust the scan", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-plugin-local-budget-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const skillDirectory = path.join( + userHome, + ".cursor", + "plugins", + "local", + "a-kept", + "skills", + "early", + ); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(skillDirectory, "SKILL.md"), + "---\ndescription: early\n---\n", + ); + const localRoot = path.join(userHome, ".cursor", "plugins", "local"); + yield* Effect.sync(() => { + for (let index = 0; index < 10_000; index += 1) { + NodeFS.mkdirSync(path.join(localRoot, `z${index}`)); + } + }); + + const skills = yield* discoverCursorSkills(undefined, { HOME: userHome }); + expect(skills.map((skill) => skill.name)).toEqual(["early"]); + expect( + (yield* probeCursorSkills(undefined, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Failure"); + }), + )); }); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index c8cd561dfea0..4c80b3178602 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -4,7 +4,8 @@ * Cursor discovers Agent Skills recursively from user and project roots but * its ACP command catalog only appears after opening a real session. Scanning * the same roots avoids starting an agent and its MCP servers just to populate - * a composer menu. + * a composer menu. Enabled plugins keep skills in `plugins/local` and in the + * one completed cache version the SDK marked, not in every historical SHA. * * @module provider/Drivers/CursorSkills */ @@ -127,6 +128,81 @@ function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undef }; } +const PLUGIN_MANIFEST_PATHS = [ + ".cursor-plugin/plugin.json", + ".claude-plugin/plugin.json", + "plugin.json", +] as const; + +function isPathInside(parent: string, candidate: string, path: Path.Path): boolean { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function safePluginRelativePath(value: string): string | undefined { + const normalized = value.replaceAll("\\", "/").trim(); + if ( + normalized.length === 0 || + normalized.includes("..") || + normalized.includes("://") || + normalized.startsWith("/") || + /^[A-Za-z]:/.test(normalized) + ) { + return undefined; + } + const stripped = normalized.replace(/^\.\//, "").replace(/\/+$/, ""); + return stripped.length === 0 ? undefined : stripped; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const readCursorSkill = Effect.fn("readCursorSkill")(function* (input: { + readonly directory: string; + readonly scope: "user" | "project"; + readonly budget: CursorSkillScanBudget; + readonly containmentRoot?: string; +}): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillPath = path.join(input.directory, "SKILL.md"); + const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); + if (skillInfo?.type !== "File") return undefined; + if (input.containmentRoot !== undefined) { + const [resolvedSkill, resolvedRoot] = yield* Effect.all([ + orUndefined(fileSystem.realPath(skillPath), input.budget), + orUndefined(fileSystem.realPath(input.containmentRoot), input.budget), + ]); + if (!resolvedSkill || !resolvedRoot || !isPathInside(resolvedRoot, resolvedSkill, path)) { + return undefined; + } + } + + let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; + if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { + const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); + if (contents !== undefined) { + input.budget.remainingBytes -= skillInfo.size; + frontmatter = parseSkillFrontmatter(contents); + } + } + const name = path.basename(input.directory).trim(); + if (!frontmatter?.cliVisible || !name) return undefined; + return { + name, + path: skillPath, + scope: input.scope, + enabled: true, + ...(frontmatter.displayName && frontmatter.displayName !== name + ? { displayName: frontmatter.displayName } + : {}), + ...(frontmatter.description ? { description: frontmatter.description } : {}), + ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), + }; +}); + const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: { readonly directory: string; readonly scope: "user" | "project"; @@ -143,7 +219,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( const visit = Effect.fn("visitCursorSkillDirectory")(function* ( directory: string, depth: number, - ): Effect.fn.Return { + ): Effect.fn.Return { if (input.budget.exhausted) return; const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); if (!resolvedDirectory) { @@ -160,33 +236,12 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( resolvedDirectory === rootDirectory || resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); - const skillPath = path.join(directory, "SKILL.md"); - const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); - if (skillInfo?.type === "File") { - let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; - if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { - const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); - if (contents !== undefined) { - input.budget.remainingBytes -= skillInfo.size; - frontmatter = parseSkillFrontmatter(contents); - } - } - const name = path.basename(directory).trim(); - if (frontmatter?.cliVisible && name) { - skills.push({ - name, - path: skillPath, - scope: input.scope, - enabled: true, - ...(frontmatter.displayName && frontmatter.displayName !== name - ? { displayName: frontmatter.displayName } - : {}), - ...(frontmatter.description ? { description: frontmatter.description } : {}), - ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), - ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), - }); - } - } + const skill = yield* readCursorSkill({ + directory, + scope: input.scope, + budget: input.budget, + }); + if (skill) skills.push(skill); if (!insideRoot) { return; @@ -216,6 +271,291 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( return skills; }); +const decodePluginJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +const takeScanEntry = (budget: CursorSkillScanBudget): boolean => { + if (budget.exhausted || budget.remainingEntries === 0) { + budget.exhausted = true; + return false; + } + budget.remainingEntries -= 1; + return true; +}; + +const readJsonObject = Effect.fn("readCursorPluginJson")(function* ( + file: string, + budget: CursorSkillScanBudget, +): Effect.fn.Return | undefined, never, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* orUndefined(fileSystem.stat(file), budget); + if (info?.type !== "File" || info.size > MAX_SKILL_BYTES || info.size > budget.remainingBytes) { + return undefined; + } + const contents = yield* orUndefined(fileSystem.readFileString(file), budget); + if (contents === undefined) return undefined; + budget.remainingBytes -= info.size; + const parsed = yield* decodePluginJson(contents).pipe(Effect.orElseSucceed(() => undefined)); + return isRecord(parsed) ? parsed : undefined; +}); + +const discoverPluginSkillDirectory = Effect.fn("discoverCursorPluginSkillDirectory")( + function* (input: { + readonly directory: string; + readonly pluginRoot: string; + readonly scope: "user" | "project"; + readonly budget: CursorSkillScanBudget; + }): Effect.fn.Return< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path + > { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skills: ServerProviderSkill[] = []; + if (input.budget.exhausted) return skills; + const pluginRoot = yield* orUndefined(fileSystem.realPath(input.pluginRoot), input.budget); + const entries = yield* orUndefined(fileSystem.readDirectory(input.directory), input.budget); + if (!pluginRoot || !entries) return skills; + + for (const entry of [...entries].sort()) { + if (input.budget.exhausted) return skills; + if (input.budget.remainingEntries === 0) { + input.budget.exhausted = true; + return skills; + } + input.budget.remainingEntries -= 1; + if (entry.startsWith(".")) continue; + const child = path.join(input.directory, entry); + const info = yield* orUndefined(fileSystem.stat(child), input.budget); + if (info?.type !== "Directory") continue; + const resolvedChild = yield* orUndefined(fileSystem.realPath(child), input.budget); + if (!resolvedChild || !isPathInside(pluginRoot, resolvedChild, path)) continue; + const skill = yield* readCursorSkill({ + directory: child, + scope: input.scope, + budget: input.budget, + containmentRoot: input.pluginRoot, + }); + if (skill) skills.push(skill); + } + return skills; + }, +); + +const resolvePluginRelativePath = Effect.fn("resolveCursorPluginRelativePath")(function* ( + pluginRoot: string, + relativePath: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safePath = safePluginRelativePath(relativePath); + if (!safePath) return undefined; + const candidate = path.resolve(pluginRoot, safePath); + if (!isPathInside(path.resolve(pluginRoot), candidate, path)) return undefined; + const [resolvedRoot, resolvedCandidate] = yield* Effect.all([ + orUndefined(fileSystem.realPath(pluginRoot)), + orUndefined(fileSystem.realPath(candidate)), + ]); + if (!resolvedRoot || !resolvedCandidate || !isPathInside(resolvedRoot, resolvedCandidate, path)) { + return undefined; + } + return candidate; +}); + +const readPluginManifest = Effect.fn("readCursorPluginManifest")(function* ( + pluginRoot: string, + budget: CursorSkillScanBudget, +): Effect.fn.Return | undefined, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + for (const relativePath of PLUGIN_MANIFEST_PATHS) { + if (budget.exhausted) return undefined; + const manifest = yield* readJsonObject(path.join(pluginRoot, relativePath), budget); + if (manifest) return manifest; + } + return undefined; +}); + +const discoverSkillsInPlugin = Effect.fn("discoverCursorSkillsInPlugin")(function* (input: { + readonly directory: string; + readonly scope: "user" | "project"; + readonly budget: CursorSkillScanBudget; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (input.budget.exhausted) return []; + const info = yield* orUndefined(fileSystem.stat(input.directory), input.budget); + if (info?.type !== "Directory") return []; + + const manifest = yield* readPluginManifest(input.directory, input.budget); + const skills: ServerProviderSkill[] = []; + const readContainedSkill = (directory: string) => + readCursorSkill({ + directory, + scope: input.scope, + budget: input.budget, + containmentRoot: input.directory, + }); + const readDeclaredSkills = Effect.fn("readDeclaredCursorPluginSkills")(function* ( + relativePath: string, + asDirectory: boolean, + ) { + const resolved = yield* resolvePluginRelativePath(input.directory, relativePath); + if (!resolved) return; + const info = yield* orUndefined(fileSystem.stat(resolved), input.budget); + if (info?.type === "File" && path.basename(resolved) === "SKILL.md") { + const skill = yield* readContainedSkill(path.dirname(resolved)); + if (skill) skills.push(skill); + return; + } + if (info?.type !== "Directory") return; + if (!asDirectory) { + const skillFile = path.join(resolved, "SKILL.md"); + const skillInfo = yield* orUndefined(fileSystem.stat(skillFile), input.budget); + if (skillInfo?.type === "File") { + const skill = yield* readContainedSkill(resolved); + if (skill) skills.push(skill); + return; + } + } + skills.push( + ...(yield* discoverPluginSkillDirectory({ + directory: resolved, + pluginRoot: input.directory, + scope: input.scope, + budget: input.budget, + })), + ); + }); + + if (manifest && "skills" in manifest) { + const declared = manifest.skills; + if (typeof declared === "string") { + if (!takeScanEntry(input.budget)) return skills; + yield* readDeclaredSkills(declared, true); + } else if (Array.isArray(declared)) { + for (const entry of declared) { + if (!takeScanEntry(input.budget)) break; + if (typeof entry !== "string") continue; + yield* readDeclaredSkills(entry, false); + } + } + return skills; + } + + const skillsDirectory = path.join(input.directory, "skills"); + const skillsInfo = yield* orUndefined(fileSystem.stat(skillsDirectory), input.budget); + if (skillsInfo?.type === "Directory") { + skills.push( + ...(yield* discoverPluginSkillDirectory({ + directory: skillsDirectory, + pluginRoot: input.directory, + scope: input.scope, + budget: input.budget, + })), + ); + } + const rootSkill = yield* readContainedSkill(input.directory); + if (rootSkill) skills.push(rootSkill); + return skills; +}); + +const listDirectoryNames = Effect.fn("listCursorPluginDirectoryNames")(function* ( + directory: string, + budget: CursorSkillScanBudget, +): Effect.fn.Return | undefined, never, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + const entries = yield* orUndefined(fileSystem.readDirectory(directory), budget); + return entries === undefined ? undefined : [...entries].sort(); +}); + +const CACHE_COMPLETE_MARKER = ".cache-complete"; + +const cursorLocalPluginInstalls = Effect.fn("cursorLocalPluginInstalls")(function* (input: { + readonly userHome: string; + readonly budget: CursorSkillScanBudget; + readonly inspect: ( + directory: string, + ) => Effect.Effect; +}): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const localRoot = path.join(input.userHome, ".cursor", "plugins", "local"); + const resolvedLocalRoot = yield* orUndefined(fileSystem.realPath(localRoot), input.budget); + const localEntries = yield* listDirectoryNames(localRoot, input.budget); + if (!resolvedLocalRoot || !localEntries) return; + for (const entry of localEntries) { + if (!takeScanEntry(input.budget)) return; + if (entry.startsWith(".")) continue; + const child = path.join(localRoot, entry); + const info = yield* orUndefined(fileSystem.stat(child), input.budget); + if (info?.type !== "Directory") continue; + const resolvedChild = yield* orUndefined(fileSystem.realPath(child), input.budget); + if (!resolvedChild || !isPathInside(resolvedLocalRoot, resolvedChild, path)) continue; + yield* input.inspect(child); + if (input.budget.exhausted) return; + } +}); + +const addCompletedCachePlugins = Effect.fn("addCompletedCursorCachePlugins")(function* (input: { + readonly cacheRoot: string; + readonly budget: CursorSkillScanBudget; + readonly inspect: ( + directory: string, + ) => Effect.Effect; +}): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolvedCacheRoot = yield* orUndefined(fileSystem.realPath(input.cacheRoot), input.budget); + if (!resolvedCacheRoot) return; + // The SDK marks a finished cache install with .cache-complete and leaves old + // SHAs on disk. Several completed versions are ambiguous, so only one is used. + const marketplaces = yield* listDirectoryNames(input.cacheRoot, input.budget); + if (!marketplaces) return; + for (const marketplace of marketplaces) { + if (!takeScanEntry(input.budget)) return; + if (marketplace.startsWith(".")) continue; + const marketplaceDirectory = path.join(input.cacheRoot, marketplace); + const marketplaceInfo = yield* orUndefined(fileSystem.stat(marketplaceDirectory), input.budget); + if (marketplaceInfo?.type !== "Directory") continue; + const plugins = yield* listDirectoryNames(marketplaceDirectory, input.budget); + if (!plugins) continue; + for (const plugin of plugins) { + if (!takeScanEntry(input.budget)) return; + if (plugin.startsWith(".")) continue; + const pluginDirectory = path.join(marketplaceDirectory, plugin); + const pluginInfo = yield* orUndefined(fileSystem.stat(pluginDirectory), input.budget); + if (pluginInfo?.type !== "Directory") continue; + const versions = yield* listDirectoryNames(pluginDirectory, input.budget); + if (!versions) continue; + const completed: string[] = []; + for (const version of versions) { + if (!takeScanEntry(input.budget)) return; + if (version.startsWith(".")) continue; + const versionDirectory = path.join(pluginDirectory, version); + const versionInfo = yield* orUndefined(fileSystem.stat(versionDirectory), input.budget); + if (versionInfo?.type !== "Directory") continue; + const marker = yield* orUndefined( + fileSystem.stat(path.join(versionDirectory, CACHE_COMPLETE_MARKER)), + input.budget, + ); + if (marker?.type !== "File") continue; + const resolvedVersion = yield* orUndefined( + fileSystem.realPath(versionDirectory), + input.budget, + ); + if (resolvedVersion && isPathInside(resolvedCacheRoot, resolvedVersion, path)) { + completed.push(versionDirectory); + } + } + const [onlyCompleted] = completed; + if (completed.length === 1 && onlyCompleted) { + yield* input.inspect(onlyCompleted); + if (input.budget.exhausted) return; + } + } + } +}); + const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( cwd?: string, environment: NodeJS.ProcessEnv = process.env, @@ -237,12 +577,28 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( exhausted: false, incomplete: false, }; - for (const root of roots) { - if (budget.exhausted) break; - const skills = yield* discoverSkillsInRoot({ ...root, budget }); + const remember = (skills: ReadonlyArray) => { for (const skill of skills) { if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); } + }; + for (const root of roots) { + if (budget.exhausted) break; + remember(yield* discoverSkillsInRoot({ ...root, budget })); + } + const inspectInstall = (directory: string) => + Effect.gen(function* () { + remember(yield* discoverSkillsInPlugin({ directory, scope: "user", budget })); + }); + if (!budget.exhausted) { + yield* cursorLocalPluginInstalls({ userHome, budget, inspect: inspectInstall }); + } + if (!budget.exhausted) { + yield* addCompletedCachePlugins({ + cacheRoot: path.join(userHome, ".cursor", "plugins", "cache"), + budget, + inspect: inspectInstall, + }); } return { skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)),