-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(cursor): discover filesystem skills #7764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1aa466c
2d8b64c
f059af3
caf40fd
37be043
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import { assert, it } from "@effect/vitest"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Path from "effect/Path"; | ||
|
|
||
| import { discoverCursorSkills, renderCursorSkillInvocations } from "./CursorSkills.ts"; | ||
|
|
||
| const writeSkill = Effect.fn(function* ( | ||
| skillsDir: string, | ||
| relativeDirectory: string, | ||
| contents: string, | ||
| ) { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const skillDirectory = path.join(skillsDir, relativeDirectory); | ||
| yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); | ||
| yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); | ||
| }); | ||
|
|
||
| const skill = (name: string, description = `Use ${name}.`) => | ||
| ["---", `name: ${name}`, `description: ${description}`, "---", "", `# ${name}`].join("\n"); | ||
|
|
||
| it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { | ||
| it.effect("discovers every documented direct user and project root", () => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" }); | ||
| const home = path.join(tempDir, "home"); | ||
| const workspace = path.join(tempDir, "workspace"); | ||
| const rootNames = [".agents", ".cursor", ".claude", ".codex"] as const; | ||
|
|
||
| for (const rootName of rootNames) { | ||
| yield* writeSkill( | ||
| path.join(home, rootName, "skills"), | ||
| `user-${rootName.slice(1)}`, | ||
| skill(`user-${rootName.slice(1)}`), | ||
| ); | ||
| yield* writeSkill( | ||
| path.join(workspace, rootName, "skills"), | ||
| `project-${rootName.slice(1)}`, | ||
| skill(`project-${rootName.slice(1)}`), | ||
| ); | ||
| } | ||
|
|
||
| const skills = yield* discoverCursorSkills(workspace, { HOME: home }); | ||
|
|
||
| assert.deepEqual( | ||
| skills.map((entry) => [entry.name, entry.scope]), | ||
| [ | ||
| ["project-agents", "project"], | ||
| ["project-claude", "project"], | ||
| ["project-codex", "project"], | ||
| ["project-cursor", "project"], | ||
| ["user-agents", "user"], | ||
| ["user-claude", "user"], | ||
| ["user-codex", "user"], | ||
| ["user-cursor", "user"], | ||
| ], | ||
| ); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("discovers nested skills and rejects invalid entries", () => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" }); | ||
| const workspace = path.join(tempDir, "workspace"); | ||
| const skillsDirectory = path.join(workspace, ".cursor", "skills"); | ||
|
|
||
| yield* writeSkill(skillsDirectory, "shipping/deploy", skill("deploy")); | ||
| yield* writeSkill(skillsDirectory, "wrong-folder", skill("another-name")); | ||
| yield* writeSkill( | ||
| skillsDirectory, | ||
| "no-description", | ||
| ["---", "name: no-description", "---"].join("\n"), | ||
| ); | ||
|
|
||
| const skills = yield* discoverCursorSkills(workspace, { HOME: path.join(tempDir, "home") }); | ||
|
|
||
| assert.deepEqual( | ||
| skills.map((entry) => entry.name), | ||
| ["deploy"], | ||
| ); | ||
| assert.equal(skills[0]?.path, path.join(skillsDirectory, "shipping", "deploy", "SKILL.md")); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it("renders only known `$skill` tokens as Cursor skill invocations", () => { | ||
| assert.equal( | ||
| renderCursorSkillInvocations( | ||
| "$deploy, then $123-skill. Keep $unknown and $review!", | ||
| new Set(["deploy", "123-skill", "review"]), | ||
| ), | ||
| "/deploy, then /123-skill. Keep $unknown and /review!", | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /** | ||
| * CursorSkills — filesystem discovery for the Cursor `$` picker. | ||
| * | ||
| * Cursor Agent discovers skills from its own, Agent Skills, Claude, and Codex | ||
| * directories. T3 reads the same on-disk skills because Cursor ACP does not | ||
| * expose a skill catalogue. | ||
| * | ||
| * @module provider/Drivers/CursorSkills | ||
| */ | ||
| import * as NodeOS from "node:os"; | ||
|
|
||
| import type { ServerProviderSkill } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Path from "effect/Path"; | ||
| import { parse as parseYamlDocument } from "yaml"; | ||
|
|
||
| type CursorSkillScope = "user" | "project"; | ||
|
|
||
| const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; | ||
| const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/; | ||
| const SKILL_TOKEN_PATTERN = /(^|\s)\$([a-z0-9-]+)(?=\s|$|[^\w-])/g; | ||
|
|
||
| type SkillFrontmatter = | ||
| | { readonly kind: "malformed" } | ||
| | { readonly kind: "parsed"; readonly name: string; readonly description: string }; | ||
|
|
||
| function parseSkillFrontmatter(contents: string): SkillFrontmatter { | ||
| const match = FRONTMATTER_PATTERN.exec(contents); | ||
| if (!match) { | ||
| return { kind: "malformed" }; | ||
| } | ||
|
|
||
| let parsed: unknown; | ||
| try { | ||
| parsed = parseYamlDocument(match[1] ?? ""); | ||
| } catch { | ||
| return { kind: "malformed" }; | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null) { | ||
| return { kind: "malformed" }; | ||
| } | ||
|
|
||
| const record = parsed as Record<string, unknown>; | ||
| const name = typeof record.name === "string" ? record.name.trim() : ""; | ||
| const description = typeof record.description === "string" ? record.description.trim() : ""; | ||
| if (!SKILL_NAME_PATTERN.test(name) || !description) { | ||
| return { kind: "malformed" }; | ||
| } | ||
|
|
||
| return { kind: "parsed", name, description }; | ||
| } | ||
|
|
||
| function isSkillFile(entry: string): boolean { | ||
| return entry === "SKILL.md" || entry.replaceAll("\\", "/").endsWith("/SKILL.md"); | ||
| } | ||
|
|
||
| /** | ||
| * List skills from Cursor's documented user and project roots. The scan is | ||
| * best-effort so unreadable or malformed entries never affect provider state. | ||
| */ | ||
| export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( | ||
| cwd?: string, | ||
| environment?: NodeJS.ProcessEnv, | ||
| ): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const homePath = environment?.HOME?.trim() || NodeOS.homedir(); | ||
| const roots: ReadonlyArray<{ directory: string; scope: CursorSkillScope }> = [ | ||
| { directory: path.join(homePath, ".agents", "skills"), scope: "user" }, | ||
| { directory: path.join(homePath, ".cursor", "skills"), scope: "user" }, | ||
| { directory: path.join(homePath, ".claude", "skills"), scope: "user" }, | ||
| { directory: path.join(homePath, ".codex", "skills"), scope: "user" }, | ||
| ...(cwd | ||
| ? [ | ||
| { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, | ||
| { directory: path.join(cwd, ".cursor", "skills"), scope: "project" as const }, | ||
| { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, | ||
| { directory: path.join(cwd, ".codex", "skills"), scope: "project" as const }, | ||
| ] | ||
| : []), | ||
| ]; | ||
|
|
||
| const skillsByName = new Map<string, ServerProviderSkill>(); | ||
| for (const root of roots) { | ||
| const entries = yield* fileSystem | ||
| .readDirectory(root.directory, { recursive: true }) | ||
| .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])); | ||
|
|
||
| for (const entry of [...entries].filter(isSkillFile).sort()) { | ||
| const skillPath = path.join(root.directory, entry); | ||
| const contents = yield* fileSystem | ||
| .readFileString(skillPath) | ||
| .pipe(Effect.orElseSucceed(() => undefined)); | ||
| if (contents === undefined) { | ||
| continue; | ||
| } | ||
|
|
||
| const frontmatter = parseSkillFrontmatter(contents); | ||
| if (frontmatter.kind === "malformed") { | ||
| continue; | ||
| } | ||
| if (frontmatter.name !== path.basename(path.dirname(skillPath))) { | ||
| continue; | ||
| } | ||
|
|
||
| skillsByName.set(frontmatter.name, { | ||
| name: frontmatter.name, | ||
| description: frontmatter.description, | ||
| path: skillPath, | ||
| enabled: true, | ||
| scope: root.scope, | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Home cwd mislabels user skillsMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 37be043. Configure here. |
||
| } | ||
| } | ||
|
|
||
| return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); | ||
| }); | ||
|
|
||
| /** | ||
| * Cheap pre-check so sendTurn only pays for skill rediscovery when the input | ||
| * actually carries a `$token` worth translating. | ||
| */ | ||
| export function mayContainSkillToken(input: string): boolean { | ||
| return /\$[a-z0-9-]/.test(input); | ||
| } | ||
|
|
||
| /** | ||
| * T3 uses `$skill` for its shared picker. Cursor invokes selected skills with | ||
| * `/skill`, so translate only known skill tokens before the ACP prompt runs. | ||
| */ | ||
| export function renderCursorSkillInvocations( | ||
| input: string, | ||
| skillNames: ReadonlySet<string>, | ||
| ): string { | ||
| return input.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, name: string) => | ||
| skillNames.has(name) ? `${prefix}/${name}` : match, | ||
| ); | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.