From 1aa466c1f8b3e1f44b856e99597033a6de737739 Mon Sep 17 00:00:00 2001 From: Darren Bryant Date: Fri, 21 Aug 2026 08:28:06 +0100 Subject: [PATCH 1/3] feat(cursor): discover filesystem skills --- .../src/provider/Drivers/CursorDriver.ts | 3 +- .../src/provider/Drivers/CursorSkills.test.ts | 90 ++++++++++++ .../src/provider/Drivers/CursorSkills.ts | 131 ++++++++++++++++++ .../src/provider/Layers/CursorAdapter.test.ts | 47 +++++++ .../src/provider/Layers/CursorAdapter.ts | 9 +- .../provider/Layers/CursorProvider.test.ts | 31 +++++ .../src/provider/Layers/CursorProvider.ts | 7 + docs/user/composer.md | 10 ++ 8 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/CursorSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/CursorSkills.ts diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb1..daa22a201a55 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -105,6 +105,7 @@ export const CursorDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -132,7 +133,7 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts new file mode 100644 index 000000000000..b65eef3bf17b --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -0,0 +1,90 @@ +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 $unknown and $review", new Set(["deploy", "review"])), + "/deploy then $unknown and /review", + ); +}); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts new file mode 100644 index 000000000000..a450f657a292 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -0,0 +1,131 @@ +/** + * 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-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/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; + 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, 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(); + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory, { recursive: true }) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + 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, + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); + +/** + * 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 { + return input.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, name: string) => + skillNames.has(name) ? `${prefix}/${name}` : match, + ); +} diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index cd5cdb7f01aa..40528dc5a090 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -168,6 +168,53 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("sends selected Cursor skills as slash commands", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-invocation"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-")), + ); + const workspace = NodePath.join(tempDir, "workspace"); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const skillPath = NodePath.join(workspace, ".cursor", "skills", "deploy", "SKILL.md"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(skillPath), { recursive: true }); + await NodeFSP.writeFile( + skillPath, + ["---", "name: deploy", "description: Deploy the service.", "---"].join("\n"), + "utf8", + ); + await NodeFSP.writeFile(requestLogPath, "", "utf8"); + }); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, NodePath.join(tempDir, "argv.txt")), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + yield* adapter.sendTurn({ threadId, input: "$deploy ship the release", attachments: [] }); + + const requests = yield* waitForJsonLogMatch( + requestLogPath, + (entry) => entry.method === "session/prompt", + ); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + const prompt = (promptRequest?.params as { prompt?: Array<{ text?: string }> } | undefined) + ?.prompt?.[0]?.text; + assert.equal(prompt, "/deploy ship the release"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae8..ff67e1ca085b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -43,6 +43,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { discoverCursorSkills, renderCursorSkillInvocations } from "../Drivers/CursorSkills.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -133,6 +134,7 @@ interface CursorSessionContext { readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + readonly skillNames: ReadonlySet; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ @@ -530,6 +532,7 @@ export function makeCursorAdapter( const effectiveCursorSettings = options?.resolveSettings ? yield* options.resolveSettings : cursorSettings; + const skills = yield* discoverCursorSkills(cwd, options?.environment); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ @@ -778,6 +781,7 @@ export function makeCursorAdapter( turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + skillNames: new Set(skills.map((skill) => skill.name)), promptsInFlight: 0, stopped: false, }; @@ -968,7 +972,10 @@ export function makeCursorAdapter( const promptParts: Array = []; if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); + promptParts.push({ + type: "text", + text: renderCursorSkillInvocations(input.input.trim(), ctx.skillNames), + }); } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index e969a7beab41..d8b7a4be5a0b 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -323,6 +323,37 @@ describe("getCursorFallbackModels", () => { }); describe("buildCursorProviderSnapshot", () => { + it("publishes discovered skills for the shared composer picker", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { status: "authenticated" }, + }, + skills: [ + { + name: "deploy", + description: "Deploy the service.", + path: "/workspace/.cursor/skills/deploy/SKILL.md", + enabled: true, + scope: "project", + }, + ], + }).skills, + ).toEqual([ + { + name: "deploy", + description: "Deploy the service.", + path: "/workspace/.cursor/skills/deploy/SKILL.md", + enabled: true, + scope: "project", + }, + ]); + }); + it("downgrades ready status to warning when ACP model discovery times out", () => { expect( buildCursorProviderSnapshot({ diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5c..f07b03d553db 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -7,6 +7,7 @@ import type { ServerProviderAuth, ServerProviderModel, ServerProviderState, + ServerProviderSkill, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -30,6 +31,7 @@ import { } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { discoverCursorSkills } from "../Drivers/CursorSkills.ts"; import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, @@ -627,6 +629,7 @@ export function buildCursorProviderSnapshot(input: { readonly cursorSettings: CursorSettings; readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; + readonly skills?: ReadonlyArray; readonly discoveryWarning?: string; }): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); @@ -639,6 +642,7 @@ export function buildCursorProviderSnapshot(input: { input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), + skills: input.skills ?? [], probe: { installed: true, version: input.parsed.version, @@ -987,6 +991,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1056,6 +1061,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const parsed = parseCursorAboutOutput(aboutProbe.success.value); + const skills = yield* discoverCursorSkills(cwd, environment); const cursorCliConfigChannel = yield* readCursorCliConfigChannel(); const parameterizedModelPickerUnsupportedMessage = getCursorParameterizedModelPickerUnsupportedMessage({ @@ -1109,6 +1115,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( Option.filter(discoveredModels, (models) => models.length > 0), () => [] as const, ), + skills, ...(discoveryWarning ? { discoveryWarning } : {}), }); }); diff --git a/docs/user/composer.md b/docs/user/composer.md index d2e49db247b0..31cbc1d5453e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -3,3 +3,13 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. + +## Cursor skills + +When a thread uses Cursor, type `$` in the composer to search the skills T3 Code found. Choose a +skill to add it to your message. T3 Code sends the selected skill to Cursor in the format Cursor +expects. + +T3 Code finds skills stored in your project or user skill folders. Cursor-managed built-in, +marketplace, and plugin skills are not shown because Cursor does not make that list available to +T3 Code. From 2d8b64cdae4c6be172ee99ca726f91630fe9564f Mon Sep 17 00:00:00 2001 From: Darren Bryant Date: Fri, 21 Aug 2026 09:01:01 +0100 Subject: [PATCH 2/3] fix(cursor): preserve discovered skills --- .../src/provider/Drivers/CursorSkills.test.ts | 18 ++++++-- .../src/provider/Drivers/CursorSkills.ts | 2 +- .../provider/Layers/CursorProvider.test.ts | 43 ++++++++++++++++++- .../src/provider/Layers/CursorProvider.ts | 1 + 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts index b65eef3bf17b..9bc3e6b09e06 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.test.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -72,11 +72,18 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { 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")); + 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.deepEqual( + skills.map((entry) => entry.name), + ["deploy"], + ); assert.equal(skills[0]?.path, path.join(skillsDirectory, "shipping", "deploy", "SKILL.md")); }), ); @@ -84,7 +91,10 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { it("renders only known `$skill` tokens as Cursor skill invocations", () => { assert.equal( - renderCursorSkillInvocations("$deploy then $unknown and $review", new Set(["deploy", "review"])), - "/deploy then $unknown and /review", + renderCursorSkillInvocations( + "$deploy, then $123-skill. Keep $unknown and $review!", + new Set(["deploy", "123-skill", "review"]), + ), + "/deploy, then /123-skill. Keep $unknown and /review!", ); }); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index a450f657a292..e6537c43ef3f 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -19,7 +19,7 @@ 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-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_TOKEN_PATTERN = /(^|\s)\$([a-z0-9-]+)(?=\s|$|[^\w-])/g; type SkillFrontmatter = | { readonly kind: "malformed" } diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index d8b7a4be5a0b..cd410eb9e513 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -87,7 +87,9 @@ exec ${mockAgentCommand} "$@" return wrapperPath; }); -const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { +const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* ( + version = "2026.04.09-f2b0fcd", +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const mockAgentPath = yield* resolveMockAgentPath(); @@ -99,7 +101,7 @@ const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper") const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); const script = `#!/bin/sh if [ "$1" = "about" ]; then - printf 'CLI Version 2026.04.09-f2b0fcd\\n' + printf 'CLI Version ${version}\\n' printf 'User Email cursor@example.com\\n' exit 0 fi @@ -503,6 +505,43 @@ describe("checkCursorProviderStatus", () => { ]); await expect(runNode(waitForFileContent(requestLogPath))).resolves.toContain("initialize"); }); + + it("keeps discovered skills when the parameterized model picker is unavailable", async () => { + const fixture = await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-skills-", + }); + const workspace = path.join(tempDir, "workspace"); + const skillPath = path.join(workspace, ".cursor", "skills", "deploy", "SKILL.md"); + yield* fileSystem.makeDirectory(path.dirname(skillPath), { recursive: true }); + yield* fileSystem.writeFileString( + skillPath, + ["---", "name: deploy", "description: Deploy the service.", "---"].join("\n"), + ); + return { workspace, home: path.join(tempDir, "home") }; + }), + ); + const wrapperPath = await runNode(makeMockAgentWithAboutWrapper("2026.04.07-f2b0fcd")); + + const provider = await runNode( + checkCursorProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + { HOME: fixture.home }, + fixture.workspace, + ), + ); + + expect(provider.skills.map((skill) => skill.name)).toEqual(["deploy"]); + }); }); describe("discoverCursorModelsViaAcp", () => { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index f07b03d553db..a764a9a645de 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1074,6 +1074,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version: parsed.version, From f059af382b57e3b8dde2aebda4875dbd881da0ac Mon Sep 17 00:00:00 2001 From: Darren Bryant Date: Fri, 21 Aug 2026 18:22:34 +0100 Subject: [PATCH 3/3] fix(cursor): rediscover skills before translating tokens - Detect skill tokens and refresh skills from the session workspace - Add coverage for skills created after session start --- .../src/provider/Drivers/CursorSkills.ts | 8 +++ .../src/provider/Layers/CursorAdapter.test.ts | 53 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 32 +++++++++-- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index e6537c43ef3f..33eb5b01f184 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -117,6 +117,14 @@ export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* 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. diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 40528dc5a090..ec244bbbc23e 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -871,6 +871,59 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("rediscovers skills that appear after the session started", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-late-discovery"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-late-")), + ); + const workspace = NodePath.join(tempDir, "workspace"); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const skillPath = NodePath.join(workspace, ".cursor", "skills", "deploy", "SKILL.md"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(workspace, { recursive: true }); + await NodeFSP.writeFile(requestLogPath, "", "utf8"); + }); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, NodePath.join(tempDir, "argv.txt")), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + // The skill lands on disk after the session captured its skill set. + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(skillPath), { recursive: true }); + await NodeFSP.writeFile( + skillPath, + ["---", "name: deploy", "description: Deploy the service.", "---"].join("\n"), + "utf8", + ); + }); + + yield* adapter.sendTurn({ threadId, input: "$deploy ship the release", attachments: [] }); + + const requests = yield* waitForJsonLogMatch( + requestLogPath, + (entry) => entry.method === "session/prompt", + ); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + const prompt = (promptRequest?.params as { prompt?: Array<{ text?: string }> } | undefined) + ?.prompt?.[0]?.text; + assert.equal(prompt, "/deploy ship the release"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("segments assistant messages around ACP tool activity in full-access mode", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index ff67e1ca085b..2c5908970f64 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -43,7 +43,11 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { discoverCursorSkills, renderCursorSkillInvocations } from "../Drivers/CursorSkills.ts"; +import { + discoverCursorSkills, + mayContainSkillToken, + renderCursorSkillInvocations, +} from "../Drivers/CursorSkills.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -134,7 +138,10 @@ interface CursorSessionContext { readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; - readonly skillNames: ReadonlySet; + /** Known skill names at session start, refreshed lazily by sendTurn when a + * `$token` shows up so skills created after the session (or discovered from + * a different root by the provider snapshot) still translate. */ + skillNames: ReadonlySet; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ @@ -338,6 +345,12 @@ export function makeCursorAdapter( const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); + const discoverSkills = (cwd?: string) => + discoverCursorSkills(cwd, options?.environment).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( @@ -532,7 +545,7 @@ export function makeCursorAdapter( const effectiveCursorSettings = options?.resolveSettings ? yield* options.resolveSettings : cursorSettings; - const skills = yield* discoverCursorSkills(cwd, options?.environment); + const skills = yield* discoverSkills(cwd); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ @@ -972,9 +985,20 @@ export function makeCursorAdapter( const promptParts: Array = []; if (input.input?.trim()) { + const promptText = input.input.trim(); + if (mayContainSkillToken(promptText)) { + // Rediscover against the session's own cwd (the picker snapshot + // scans the server root, and skills can also appear on disk + // mid-session) so the token set matches what this workspace + // actually offers before translating. + const discovered = yield* discoverSkills(ctx.session.cwd); + if (discovered.length > 0) { + ctx.skillNames = new Set([...ctx.skillNames, ...discovered.map((s) => s.name)]); + } + } promptParts.push({ type: "text", - text: renderCursorSkillInvocations(input.input.trim(), ctx.skillNames), + text: renderCursorSkillInvocations(promptText, ctx.skillNames), }); } if (input.attachments && input.attachments.length > 0) {