diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb..daa22a201a5 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 00000000000..9bc3e6b09e0 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -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!", + ); +}); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts new file mode 100644 index 00000000000..33eb5b01f18 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -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; + 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)); +}); + +/** + * 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 { + 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 cd5cdb7f01a..ec244bbbc23 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; @@ -824,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 30c173d8fae..2c5908970f6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -43,6 +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, + mayContainSkillToken, + renderCursorSkillInvocations, +} from "../Drivers/CursorSkills.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -133,6 +138,10 @@ interface CursorSessionContext { readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + /** 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. */ @@ -336,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( @@ -530,6 +545,7 @@ export function makeCursorAdapter( const effectiveCursorSettings = options?.resolveSettings ? yield* options.resolveSettings : cursorSettings; + const skills = yield* discoverSkills(cwd); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ @@ -778,6 +794,7 @@ export function makeCursorAdapter( turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + skillNames: new Set(skills.map((skill) => skill.name)), promptsInFlight: 0, stopped: false, }; @@ -968,7 +985,21 @@ export function makeCursorAdapter( const promptParts: Array = []; if (input.input?.trim()) { - promptParts.push({ type: "text", text: 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(promptText, 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 e969a7beab4..cd410eb9e51 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 @@ -323,6 +325,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({ @@ -472,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 fee4306c4c5..a764a9a645d 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({ @@ -1068,6 +1074,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version: parsed.version, @@ -1109,6 +1116,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 f4dd49e513e..46cc0e002b4 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -8,3 +8,13 @@ On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from start it in the background. T3 Code opens another new thread and shows an **Open** action for the thread that started. The new thread keeps the selected workspace mode and base branch. If **New worktree** is selected, each background thread creates its own worktree. + +## 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.