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)),