From 7173f10a3a1d0dcfa642124aa733c32431147ada Mon Sep 17 00:00:00 2001 From: blaqat Date: Wed, 22 Jul 2026 14:13:13 -0400 Subject: [PATCH 1/5] feat(cursor): discover FS skills and apply $mentions on send Populate Cursor provider snapshot.skills from project/user .cursor/skills SKILL.md files, and inject matched skill bodies in CursorAdapter.sendTurn so Codex-style $name tokens are useful under ACP (which has no $ interpreter). Co-authored-by: Cursor --- .../src/provider/Layers/CursorAdapter.test.ts | 66 ++ .../src/provider/Layers/CursorAdapter.ts | 13 +- .../provider/Layers/CursorProvider.test.ts | 32 + .../src/provider/Layers/CursorProvider.ts | 10 + .../src/provider/cursorSkillDiscovery.test.ts | 285 +++++++++ .../src/provider/cursorSkillDiscovery.ts | 329 ++++++++++ docs/plans/cursor-opencode-skill-discovery.md | 591 ++++++++++++++++++ 7 files changed, 1325 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/cursorSkillDiscovery.test.ts create mode 100644 apps/server/src/provider/cursorSkillDiscovery.ts create mode 100644 docs/plans/cursor-opencode-skill-discovery.md diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a977..7d847e9dfb1 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1429,4 +1429,70 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }).pipe(Effect.provide(customAdapterLayer)); }, ); + + it.effect("injects discovered Cursor FS skill content when sendTurn includes $skill", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-apply-probe"); + + const projectDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-project-")), + ); + const skillDir = NodePath.join(projectDir, ".cursor", "skills", "ship-it"); + yield* Effect.promise(() => NodeFSP.mkdir(skillDir, { recursive: true })); + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(skillDir, "SKILL.md"), + `--- +name: ship-it +description: Ships changes carefully +--- + +# Ship It + +Always verify tests before shipping. +`, + "utf8", + ), + ); + + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: projectDir, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "$ship-it prepare the release", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + const prompt = (promptRequest?.params as { prompt?: Array<{ type?: string; text?: string }> }) + ?.prompt; + const textPart = prompt?.find((part) => part.type === "text")?.text ?? ""; + assert.include(textPart, "Always verify tests before shipping."); + assert.include(textPart, "prepare the release"); + assert.include(textPart, "Skill `ship-it` (applied by T3 Code for Cursor ACP):"); + assert.notMatch(textPart, /\$ship-it\b/); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 4dd38519c5a..845e96e30a4 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -77,6 +77,7 @@ import { import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { applyCursorSkillMentions, discoverCursorSkills } from "../cursorSkillDiscovery.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const PROVIDER = ProviderDriverKind.make("cursor"); @@ -962,7 +963,17 @@ export function makeCursorAdapter( const promptParts: Array = []; if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); + // Codex-style `$name` is inert under Cursor ACP. Rediscover FS skills + // for this session cwd and inject matched SKILL.md bodies before prompt. + const discoveredSkills = yield* discoverCursorSkills({ + projectCwd: ctx.session.cwd, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => [] as const), + ); + const promptText = applyCursorSkillMentions(input.input.trim(), discoveredSkills); + promptParts.push({ type: "text", text: promptText }); } 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..d5e5f2dbf9a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -339,6 +339,38 @@ describe("buildCursorProviderSnapshot", () => { status: "warning", message: "Cursor ACP model discovery timed out after 15000ms.", models: [], + skills: [], + }); + }); + + it("includes filesystem-discovered skills in the provider snapshot", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { status: "authenticated" }, + }, + skills: [ + { + name: "ship-it", + path: "/tmp/project/.cursor/skills/ship-it/SKILL.md", + enabled: true, + scope: "repo", + description: "Ships changes carefully", + }, + ], + }), + ).toMatchObject({ + skills: [ + { + name: "ship-it", + enabled: true, + scope: "repo", + }, + ], }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5..5a8567b9903 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -46,6 +46,7 @@ import { } from "../providerMaintenance.ts"; import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; +import { discoverCursorSkills, toServerProviderSkills } from "../cursorSkillDiscovery.ts"; const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( CursorListAvailableModelsResponse, @@ -628,6 +629,7 @@ export function buildCursorProviderSnapshot(input: { readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; + readonly skills?: ServerProviderDraft["skills"]; }): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); return buildServerProvider({ @@ -639,6 +641,7 @@ export function buildCursorProviderSnapshot(input: { input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), + skills: input.skills ?? [], probe: { installed: true, version: input.parsed.version, @@ -1101,6 +1104,12 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( discoveredModels = discoveryExit.value; } } + // Soft-fail FS skill discovery for the `$` menu snapshot. Project skills use + // process.cwd(); session send-path rediscovers against the thread cwd. + const discoveredSkills = yield* discoverCursorSkills({ + projectCwd: process.cwd(), + }).pipe(Effect.orElseSucceed(() => [] as const)); + return buildCursorProviderSnapshot({ checkedAt, cursorSettings, @@ -1109,6 +1118,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( Option.filter(discoveredModels, (models) => models.length > 0), () => [] as const, ), + skills: toServerProviderSkills(discoveredSkills), ...(discoveryWarning ? { discoveryWarning } : {}), }); }); diff --git a/apps/server/src/provider/cursorSkillDiscovery.test.ts b/apps/server/src/provider/cursorSkillDiscovery.test.ts new file mode 100644 index 00000000000..2e84ade3338 --- /dev/null +++ b/apps/server/src/provider/cursorSkillDiscovery.test.ts @@ -0,0 +1,285 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + applyCursorSkillMentions, + collectCursorSkillMentions, + discoverCursorSkillsWithFs, + parseCursorSkillMarkdown, + resolveCursorSkillRoots, + toServerProviderSkills, +} from "./cursorSkillDiscovery.ts"; + +describe("resolveCursorSkillRoots", () => { + it("resolves project and user Cursor skill directories", () => { + expect( + resolveCursorSkillRoots({ + projectCwd: "/tmp/project", + userHome: "/Users/demo", + }), + ).toEqual({ + projectSkillsDir: NodePath.join("/tmp/project", ".cursor", "skills"), + userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), + }); + }); + + it("omits project root when cwd is missing", () => { + expect(resolveCursorSkillRoots({ projectCwd: null, userHome: "/Users/demo" })).toEqual({ + projectSkillsDir: null, + userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), + }); + }); +}); + +describe("parseCursorSkillMarkdown", () => { + it("parses name and folded description from frontmatter", () => { + const skill = parseCursorSkillMarkdown( + `--- +name: demo-skill +description: >- + Helps with demos when the user asks. +disable-model-invocation: true +--- + +# Demo + +Do the demo. +`, + "/tmp/project/.cursor/skills/demo-skill/SKILL.md", + "demo-skill", + "repo", + ); + + expect(skill).toMatchObject({ + name: "demo-skill", + description: "Helps with demos when the user asks.", + scope: "repo", + enabled: true, + path: "/tmp/project/.cursor/skills/demo-skill/SKILL.md", + }); + expect(skill?.content).toContain("# Demo"); + }); + + it("falls back to directory name when frontmatter name is missing", () => { + const skill = parseCursorSkillMarkdown( + `--- +description: A simple skill +--- + +Body +`, + "/tmp/x/.cursor/skills/my-skill/SKILL.md", + "my-skill", + "user", + ); + expect(skill?.name).toBe("my-skill"); + expect(skill?.scope).toBe("user"); + }); + + it("rejects invalid skill names", () => { + expect( + parseCursorSkillMarkdown( + `--- +name: Bad_Name +--- +`, + "/tmp/x/.cursor/skills/Bad_Name/SKILL.md", + "Bad_Name", + ), + ).toBeNull(); + }); +}); + +describe("discoverCursorSkillsWithFs", () => { + it("discovers project and user skills, preferring project on name collision", async () => { + const files = new Map([ + [ + "/proj/.cursor/skills/shared/SKILL.md", + `--- +name: shared +description: Project shared skill +--- +# Project shared +`, + ], + [ + "/proj/.cursor/skills/local-only/SKILL.md", + `--- +name: local-only +description: Only in project +--- +# Local +`, + ], + [ + "/home/.cursor/skills/shared/SKILL.md", + `--- +name: shared +description: User shared skill +--- +# User shared +`, + ], + [ + "/home/.cursor/skills/user-only/SKILL.md", + `--- +name: user-only +description: Only for user +--- +# User +`, + ], + ]); + const directories = new Set([ + "/proj/.cursor/skills", + "/proj/.cursor/skills/shared", + "/proj/.cursor/skills/local-only", + "/home/.cursor/skills", + "/home/.cursor/skills/shared", + "/home/.cursor/skills/user-only", + ]); + + const skills = await discoverCursorSkillsWithFs( + { + projectSkillsDir: "/proj/.cursor/skills", + userSkillsDir: "/home/.cursor/skills", + }, + { + readFile: async (path) => files.get(path) ?? null, + readDirectory: async (path) => { + if (path === "/proj/.cursor/skills") { + return ["shared", "local-only"]; + } + if (path === "/home/.cursor/skills") { + return ["shared", "user-only"]; + } + return null; + }, + isDirectory: async (path) => directories.has(path), + join: (...parts) => parts.join("/"), + }, + ); + + expect(skills.map((skill) => skill.name).sort()).toEqual(["local-only", "shared", "user-only"]); + expect(skills.find((skill) => skill.name === "shared")).toMatchObject({ + scope: "repo", + description: "Project shared skill", + }); + expect(toServerProviderSkills(skills).every((skill) => !("content" in skill))).toBe(true); + }); + + it("soft-fails missing skill roots", async () => { + const skills = await discoverCursorSkillsWithFs( + { + projectSkillsDir: "/missing/.cursor/skills", + userSkillsDir: null, + }, + { + readFile: async () => null, + readDirectory: async () => null, + isDirectory: async () => false, + join: (...parts) => parts.join("/"), + }, + ); + expect(skills).toEqual([]); + }); + + it("discovers real temp directories on disk", async () => { + const root = await NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skills-")); + const projectSkills = NodePath.join(root, "project", ".cursor", "skills", "ship-it"); + await NodeFS.mkdir(projectSkills, { recursive: true }); + await NodeFS.writeFile( + NodePath.join(projectSkills, "SKILL.md"), + `--- +name: ship-it +description: Ships the thing +--- + +# Ship it +`, + "utf8", + ); + + const skills = await discoverCursorSkillsWithFs( + { + projectSkillsDir: NodePath.join(root, "project", ".cursor", "skills"), + userSkillsDir: NodePath.join(root, "nouser", ".cursor", "skills"), + }, + { + readFile: async (path) => { + try { + return await NodeFS.readFile(path, "utf8"); + } catch { + return null; + } + }, + readDirectory: async (path) => { + try { + return await NodeFS.readdir(path); + } catch { + return null; + } + }, + isDirectory: async (path) => { + try { + const stat = await NodeFS.stat(path); + return stat.isDirectory(); + } catch { + return false; + } + }, + join: NodePath.join, + }, + ); + + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + name: "ship-it", + scope: "repo", + enabled: true, + }); + }); +}); + +describe("applyCursorSkillMentions", () => { + it("collects unique $skill mentions", () => { + expect(collectCursorSkillMentions("$ship-it please and $ship-it again $unknown")).toEqual([ + "ship-it", + "unknown", + ]); + }); + + it("injects SKILL.md bodies for matched mentions and strips $tokens", () => { + const applied = applyCursorSkillMentions("$ship-it do the thing", [ + { + name: "ship-it", + enabled: true, + content: `--- +name: ship-it +--- + +# Ship it instructions +`, + }, + ]); + + expect(applied).toContain("Skill `ship-it` (applied by T3 Code for Cursor ACP):"); + expect(applied).toContain("# Ship it instructions"); + expect(applied).toContain("do the thing"); + expect(applied).not.toMatch(/\$ship-it\b/); + }); + + it("leaves unknown $mentions unchanged", () => { + expect(applyCursorSkillMentions("$missing please", [])).toBe("$missing please"); + }); + + it("skips disabled skills", () => { + expect( + applyCursorSkillMentions("$off please", [{ name: "off", enabled: false, content: "# Off" }]), + ).toBe("$off please"); + }); +}); diff --git a/apps/server/src/provider/cursorSkillDiscovery.ts b/apps/server/src/provider/cursorSkillDiscovery.ts new file mode 100644 index 00000000000..b3af1b7c094 --- /dev/null +++ b/apps/server/src/provider/cursorSkillDiscovery.ts @@ -0,0 +1,329 @@ +/** + * Cursor filesystem skill discovery and send-path apply. + * + * Cursor skills live as `skill-name/SKILL.md` under: + * - Project: `/.cursor/skills/` + * - User: `~/.cursor/skills/` + * + * Do not scan `~/.cursor/skills-cursor/` (Cursor built-ins). + * + * Composer keeps Codex-style `$name` insert UX. Codex interprets `$name` itself; + * Cursor ACP does not. On send, T3 expands matched `$skill` tokens by injecting + * the skill's SKILL.md body into the ACP prompt text (Cursor-native `/name` + * under ACP is not guaranteed, so content injection is the apply path). + */ + +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +const SKILL_FILE_NAME = "SKILL.md"; +const CURSOR_SKILLS_REL = NodePath.join(".cursor", "skills"); + +/** `$skill-name` mentions (Codex-style composer insert). */ +const CURSOR_SKILL_MENTION_RE = /\$([a-z0-9]+(?:-[a-z0-9]+)*)\b/gi; + +export interface CursorSkillRoots { + readonly projectCwd?: string | null | undefined; + readonly userHome?: string | null | undefined; +} + +export interface DiscoveredCursorSkill { + readonly name: string; + readonly path: string; + readonly enabled: boolean; + readonly content: string; + readonly description?: string; + readonly scope?: string; + readonly displayName?: string; + readonly shortDescription?: string; +} + +export function resolveCursorSkillRoots(input: CursorSkillRoots = {}): { + readonly projectSkillsDir: string | null; + readonly userSkillsDir: string | null; +} { + const projectCwd = input.projectCwd?.trim() || null; + const userHome = (input.userHome ?? NodeOS.homedir()).trim() || null; + return { + projectSkillsDir: projectCwd ? NodePath.join(projectCwd, CURSOR_SKILLS_REL) : null, + userSkillsDir: userHome ? NodePath.join(userHome, CURSOR_SKILLS_REL) : null, + }; +} + +function normalizeSkillName(raw: string | undefined, fallbackDirName: string): string | null { + const candidate = (raw?.trim() || fallbackDirName).trim().toLowerCase(); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate) || candidate.length > 64) { + return null; + } + return candidate; +} + +function parseDescriptionField(frontmatter: string): string | undefined { + const folded = frontmatter.match(/^description:\s*[>|]-?[ \t]*\r?\n((?:[ \t]+.+\r?\n?)*)/m); + if (folded?.[1]) { + const collapsed = folded[1] + .split(/\r?\n/) + .map((line) => line.replace(/^[ \t]+/, "").trim()) + .filter((line) => line.length > 0) + .join(" ") + .trim(); + return collapsed.length > 0 ? collapsed.slice(0, 1024) : undefined; + } + + const single = frontmatter.match(/^description:\s*(?:>-?\s*)?(.+)$/m); + if (!single?.[1]) { + return undefined; + } + const value = single[1].trim().replace(/^['"]|['"]$/g, ""); + return value.length > 0 ? value.slice(0, 1024) : undefined; +} + +export function parseCursorSkillMarkdown( + raw: string, + skillPath: string, + directoryName: string, + scope?: "user" | "repo", +): DiscoveredCursorSkill | null { + const content = raw.replace(/^\uFEFF/, ""); + let frontmatter = ""; + + if (content.startsWith("---")) { + const endIdx = content.indexOf("\n---", 3); + if (endIdx !== -1) { + frontmatter = content.slice(3, endIdx).replace(/^\r?\n/, ""); + } + } + + const nameFromFm = frontmatter.match(/^name:\s*['"]?([a-z0-9-]+)['"]?\s*$/im)?.[1]; + const name = normalizeSkillName(nameFromFm, directoryName); + if (!name) { + return null; + } + + const description = parseDescriptionField(frontmatter); + return { + name, + path: skillPath, + enabled: true, + content: content.trim(), + ...(scope ? { scope } : {}), + ...(description + ? { + description, + shortDescription: + description.length > 160 ? `${description.slice(0, 157)}...` : description, + } + : {}), + }; +} + +/** + * Pure scan helper for tests — pass filesystem callbacks. + */ +export async function discoverCursorSkillsWithFs( + roots: { + readonly projectSkillsDir?: string | null; + readonly userSkillsDir?: string | null; + }, + fs: { + readonly readFile: (path: string) => Promise; + readonly readDirectory: (path: string) => Promise | null>; + readonly isDirectory: (path: string) => Promise; + readonly join: (...parts: string[]) => string; + }, +): Promise> { + const discovered: Array = []; + const seenNames = new Set(); + + const scanRoot = async (skillsRoot: string | null | undefined, scope: "user" | "repo") => { + if (!skillsRoot) { + return; + } + const entries = await fs.readDirectory(skillsRoot); + if (!entries) { + return; + } + + for (const entryName of entries) { + if (entryName.startsWith(".")) { + continue; + } + const skillDir = fs.join(skillsRoot, entryName); + if (!(await fs.isDirectory(skillDir))) { + continue; + } + const skillPath = fs.join(skillDir, SKILL_FILE_NAME); + const raw = await fs.readFile(skillPath); + if (raw === null) { + continue; + } + const skill = parseCursorSkillMarkdown(raw, skillPath, entryName, scope); + if (!skill || seenNames.has(skill.name)) { + continue; + } + seenNames.add(skill.name); + discovered.push(skill); + } + }; + + // Project skills win over user skills on name collision. + await scanRoot(roots.projectSkillsDir, "repo"); + await scanRoot(roots.userSkillsDir, "user"); + + return discovered; +} + +export function toServerProviderSkills( + skills: ReadonlyArray, +): ReadonlyArray { + return skills.map((skill) => ({ + name: skill.name, + path: skill.path, + enabled: skill.enabled, + ...(skill.description ? { description: skill.description } : {}), + ...(skill.scope ? { scope: skill.scope } : {}), + ...(skill.displayName ? { displayName: skill.displayName } : {}), + ...(skill.shortDescription ? { shortDescription: skill.shortDescription } : {}), + })); +} + +export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( + input: CursorSkillRoots = {}, +) { + const fileSystem = yield* FileSystem.FileSystem; + const pathApi = yield* Path.Path; + const roots = resolveCursorSkillRoots(input); + + const readFile = (filePath: string) => + fileSystem.readFileString(filePath).pipe( + Effect.map((value) => value as string | null), + Effect.orElseSucceed(() => null), + ); + + const readDirectory = (dirPath: string) => + fileSystem.readDirectory(dirPath).pipe( + Effect.map((entries) => entries as ReadonlyArray | null), + Effect.orElseSucceed(() => null), + ); + + const isDirectory = (dirPath: string) => + fileSystem.stat(dirPath).pipe( + Effect.map((stat) => stat.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + + const discovered: Array = []; + const seenNames = new Set(); + + const scanRoot = (skillsRoot: string | null, scope: "user" | "repo") => + Effect.gen(function* () { + if (!skillsRoot) { + return; + } + const entries = yield* readDirectory(skillsRoot); + if (!entries) { + return; + } + for (const entryName of entries) { + if (entryName.startsWith(".")) { + continue; + } + const skillDir = pathApi.join(skillsRoot, entryName); + if (!(yield* isDirectory(skillDir))) { + continue; + } + const skillPath = pathApi.join(skillDir, SKILL_FILE_NAME); + const raw = yield* readFile(skillPath); + if (raw === null) { + continue; + } + const skill = parseCursorSkillMarkdown(raw, skillPath, entryName, scope); + if (!skill || seenNames.has(skill.name)) { + continue; + } + seenNames.add(skill.name); + discovered.push(skill); + } + }); + + yield* scanRoot(roots.projectSkillsDir, "repo"); + yield* scanRoot(roots.userSkillsDir, "user"); + return discovered; +}); + +export function collectCursorSkillMentions(prompt: string): ReadonlyArray { + const names: Array = []; + const seen = new Set(); + for (const match of prompt.matchAll(CURSOR_SKILL_MENTION_RE)) { + const name = match[1]?.toLowerCase(); + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + names.push(name); + } + return names; +} + +function formatInjectedSkillBlock(skill: Pick): string { + return [ + `Skill \`${skill.name}\` (applied by T3 Code for Cursor ACP):`, + "", + skill.content.trim(), + ].join("\n"); +} + +/** + * Expand Codex-style `$skill` mentions into SKILL.md body injections. + * Unknown mentions are left unchanged. + */ +export function applyCursorSkillMentions( + prompt: string, + skills: ReadonlyArray>, +): string { + if (!prompt.includes("$")) { + return prompt; + } + + const byName = new Map( + skills + .filter((skill) => skill.enabled !== false) + .map((skill) => [skill.name.toLowerCase(), skill] as const), + ); + if (byName.size === 0) { + return prompt; + } + + const mentioned = collectCursorSkillMentions(prompt); + const applied: Array> = []; + for (const name of mentioned) { + const skill = byName.get(name); + if (skill) { + applied.push(skill); + } + } + if (applied.length === 0) { + return prompt; + } + + const appliedNames = new Set(applied.map((skill) => skill.name.toLowerCase())); + const withoutMentions = prompt + .replace(CURSOR_SKILL_MENTION_RE, (full, name: string) => + appliedNames.has(name.toLowerCase()) ? "" : full, + ) + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+\n/g, "\n") + .trim(); + + const blocks = applied.map(formatInjectedSkillBlock).join("\n\n---\n\n"); + if (!withoutMentions) { + return blocks; + } + return `${blocks}\n\n---\n\n${withoutMentions}`; +} diff --git a/docs/plans/cursor-opencode-skill-discovery.md b/docs/plans/cursor-opencode-skill-discovery.md new file mode 100644 index 00000000000..422c280d7f0 --- /dev/null +++ b/docs/plans/cursor-opencode-skill-discovery.md @@ -0,0 +1,591 @@ +# Cursor + OpenCode Skill Discovery — Development Plan + +> **Status:** Ready for Solo execution (project `t3code`, `project_id: 5`) +> **Do not implement from this chat unless a Solo agent is spawned with an explicit phase prompt.** +> **Nested Task model allowlist:** only `composer-2.5-fast` or `cursor-grok-4.5-high-fast` (always set explicitly). +> **Solo agents:** **Cursor only** (`agent_tool_id: 8`). Do not spawn Claude / OpenCode / Codex Solo agents for this work. + +--- + +## TL;DR (paste into Solo agent first prompt) + +```text +You are implementing Cursor filesystem $ skill discovery in the t3code fork at /Users/blaqat/dev/forks/t3code. + +Read the full plan first: + docs/plans/cursor-opencode-skill-discovery.md + +Priority (user-locked): +- Cursor first. OpenCode phases are deferred — do not start OpenCode work unless the human explicitly unblocks it. +- Filesystem Cursor skills → provider snapshot.skills → composer $ menu. ACP slash commands are a later follow-up. +- Solo agents: ONLY Cursor (agent_tool_id: 8, project_id: 5). Nested Task models: ONLY composer-2.5-fast or cursor-grok-4.5-high-fast (set explicitly). + +Ground truth: +- Web/mobile $ and / menus read ONLY selectedProviderStatus.skills / .slashCommands from the provider snapshot (not live RPC). +- Selecting a $ skill only inserts `$skillName ` into the composer. Send passes that text through unchanged (no T3-side skill expansion). +- Codex already interprets `$name` in the prompt → listing + insert is enough for Codex. +- CursorAdapter sends plain text via ACP session/prompt. Native Cursor invokes skills with `/name` (and/or auto-apply). Cursor v1 must verify that selecting a skill is useful on send — not only that it lists. +- Prefer snapshot population first (smallest PR). Do NOT blindly merge upstream PRs #3154/#3787/#3788/#4031/#3982/#3059 — cherry-pick ideas only. +- Verify with Solo processes :typecheck and :test (and focused vitest where noted). +- Implement ONE phase at a time. Stop at phase acceptance criteria and report what changed. + +Start with Phase 0 only unless the human says otherwise. +``` + +--- + +## 1. Problem statement + +In T3 Code, the composer `$` (skills) and `/` (slash commands) menus are driven exclusively by the **selected provider’s snapshot fields**: + +| Trigger | Snapshot field | Codex today | Claude today | Cursor today | OpenCode today | +| ------- | -------------------------------------- | --------------------------- | -------------------------------- | --------------- | --------------- | +| `$` | `selectedProviderStatus.skills` | populated via `skills/list` | empty | **always `[]`** | **always `[]`** | +| `/` | `selectedProviderStatus.slashCommands` | empty / N/A | populated from init/capabilities | **always `[]`** | **always `[]`** | + +**User-facing symptom:** With Cursor selected, typing `$` shows empty-state copy (“No skills found…”) even when Cursor skills exist on disk (e.g. `.cursor/skills/**/SKILL.md`). OpenCode has the same empty-snapshot hole, but **OpenCode work is deferred** until Cursor FS `$` lands. + +**Architectural constraint (do not fight it in v1):** +`ChatComposer.tsx` / `ThreadComposer.tsx` / `providerSkillSearch.ts` do **not** call a live `listSkills` RPC on main. They map the snapshot arrays. Empty arrays → empty menus. + +**Root causes:** + +1. **Cursor (v1 focus):** `CursorProvider` / `CursorDriver` never pass `skills` or `slashCommands` into `buildServerProvider`. There is no `.cursor/skills` (or equivalent) scanner. Upstream ACP slash-command work (#3787/#3788/#3757) still leaves `skills: []`. +2. **OpenCode (deferred):** Inventory is `{ providerList, agents }` only (`opencodeRuntime.ts` → `loadOpenCodeInventory` / `loadInventoryFromCli`). `app.skills()` is unused on the SDK path; the local CLI inventory path has no skills command/parsing at all. Upstream #3154 maps SDK skills only. + +### What selecting a `$` skill does today (listing vs send) + +Plain language: + +1. **Menu / select (UI only):** Typing `$` searches `selectedProviderStatus.skills`. Choosing an item replaces the `$…` token with the literal text `` `$skillName ` `` in the composer (`ChatComposer.tsx` skill branch → `` `$${item.skill.name} ` ``). Nothing is loaded from disk at select time. +2. **Send (no T3 expansion):** On submit, that composer string is sent as the turn `input` unchanged. Neither Codex nor Cursor adapters rewrite `$name` into SKILL.md body in this fork. +3. **Codex:** Enough for real use. Codex app-server / runtime interprets `$name` after receive, so **menu insert + provider interpretation** is the full Codex path. No extra T3 “runtime loader” is required for Codex. +4. **Cursor:** Not automatically enough. `CursorAdapter.sendTurn` forwards plain text via ACP `session/prompt`. Native Cursor docs invoke skills with `/skill-name` (and/or auto-apply from discovered skills). T3 does **not** currently expand or remap `$name` for Cursor. So “it lists and I can select it” only guarantees the text `$skillName ` is in the message — **not** that Cursor applied the skill. + +**Cursor v1 implication:** Snapshot listing is necessary but not sufficient. Cursor Phase 1 acceptance must include a short send-path check (see Phase 1): confirm whether `$name`, `/name`, or another insert form causes the Cursor ACP agent to load/apply the skill; if `$name` is inert, fix insert format or document/implement the minimal send-path behavior before calling Cursor `$` done. + +--- + +## 2. Goals / non-goals + +### Goals + +1. **Cursor (first):** Users see filesystem-discovered Cursor skills in `$` when Cursor is the selected provider. +2. **Cursor (first):** Selecting a listed skill is useful on send — not a dead `$name` token. Resolve insert/send behavior as part of Cursor v1 acceptance (see Phase 1). +3. Keep the existing composer UX: snapshot → `searchProviderSkills` — no mandatory live RPC for v1 menus. +4. Align contracts with `ServerProviderSkill` / `ServerProviderSlashCommand` in `packages/contracts/src/server.ts`. +5. Ship as **small, reviewable PR slices** executable by **Cursor-only** Solo agents (`agent_tool_id: 8`) with `:typecheck` / `:test` gates. +6. **OpenCode (deferred):** Later, populate OpenCode skills into `$` for SDK and local CLI paths — only after Cursor FS `$` ships, when the human unblocks OpenCode phases. + +### Non-goals (v1) + +- OpenCode skill discovery (deferred; keep Phase 0 baseline only). +- Cursor ACP `available_commands` → `/` (follow-up after FS `$`). +- Full merge of upstream PRs (#3154, #3787, #3788, #4031, #3982, #3059) — learn/cherry-pick only. +- Replacing Codex `skills/list` or Claude slash-command plumbing. +- Building a generic cross-provider skill marketplace UI. +- Live FS watchers / `skills/changed`-style push invalidation (nice-to-have; later scoping phase). +- Mobile-only UX redesign (mobile should work if snapshot is filled; no separate mobile discovery path). +- Guaranteeing Cursor ACP `available_commands_update` session-time updates land in the **provider status** snapshot without a clear design (session events ≠ provider probe). +- Spawning non-Cursor Solo agents (Claude / OpenCode / Codex) for implementation. + +### Locked product decisions + +| Decision | Choice | +| ------------------ | ------------------------------------------------------------------------------------------------ | +| Priority | **Cursor first**; OpenCode deferred | +| Cursor `$` source | **Filesystem** skill dirs (`.cursor/skills`, user skills roots as confirmed in Phase 1 research) | +| Cursor `/` (ACP) | **Follow-up** — not required for Cursor FS `$` v1 | +| Semantic model | Option A long-term: FS skills → `$`; ACP available commands → `/` later | +| Solo execution | **Cursor agent only** (`agent_tool_id: 8`, `project_id: 5`) | +| Nested Task models | Only `composer-2.5-fast` or `cursor-grok-4.5-high-fast` | + +**OpenCode default (when unblocked later):** Skills → `$` only (no Claude-style slash inventory in current fork). + +--- + +## 3. Recommended approach (with tradeoffs) + +### Recommendation: **Cursor FS snapshot first**, then verify send usefulness; OpenCode later + +**v1 shape (Cursor)** + +1. Add a Cursor skill discovery module (FS scan of known Cursor skill roots), map into `skills` on `buildServerProvider`. +2. Leave `slashCommands: []` for Cursor until the ACP `/` follow-up. +3. Confirm select → send behavior for Cursor (Codex-style `$name` vs Cursor-native `/name` vs content injection). Ship the minimal fix if `$name` is inert under ACP. +4. Defer OpenCode inventory skills, ACP slash bridging, and workspace RPC until Cursor FS `$` is accepted. + +### Why snapshot-first + +| Approach | Pros | Cons | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **Snapshot-only menus (recommended)** | Matches current UI; smallest change surface; easy to test with fixture inventories; no web/mobile contract churn | Stale until provider re-probe; cwd/workspace switches may need a later phase | +| **projectCapabilities / listSkills RPC** | Fresher, cwd-aware, closer to #3787 | Larger contract + web/mobile work; still need a server inventory source; overkill until snapshot works | +| **Blind merge of open PRs** | Faster looking | Incomplete (OpenCode CLI hole; Cursor skills still `[]`); merge conflict risk on fork | + +### Reuse vs rewrite from open PRs + +| PR | Steal | Do not blindly take | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| **#3787 / #3788** Local Skills + slash / projectCapabilities | Cwd scoping ideas; slash-command snapshot shape; capability probe patterns | Full projectCapabilities stack before snapshot works; leaving `skills: []` for Cursor; ACP `/` before FS `$` | +| **#4031 / #3982 / #3059** workspace-scoped Codex/Grok skills | Scope field / cwd filtering conventions | Codex-specific app-server RPC as Cursor solution | +| **#3154** OpenCode skill discovery | SDK `app.skills()` mapping → `ServerProviderSkill` (when OpenCode is unblocked) | Assuming CLI path is fixed; starting OpenCode before Cursor | + +--- + +## 4. Phased implementation (PR-sized slices) + +### Phase 0 — Repro / prove empty snapshot (Cursor-first evidence) + +**Intent:** Lock the bug with evidence so later phases have a regression baseline. Emphasize Cursor; keep a light OpenCode empty-array assertion for when OpenCode is unblocked. + +**Work** + +1. Confirm UI path: `ChatComposer.tsx` uses `selectedProviderStatus?.skills` / `.slashCommands`; empty → empty-state string. +2. Confirm select path: skill menu item inserts `` `$skillName ` `` only (no disk read / no expansion). +3. Confirm send path: Cursor `sendTurn` passes trimmed prompt text to ACP; no skill rewrite. +4. Confirm server path: Cursor `buildServerProvider` calls omit `skills`/`slashCommands` (default `[]` in `providerSnapshot.ts`). +5. Add or extend a focused test that Cursor provider draft has `skills: []` and `slashCommands: []` when enabled/installed (current behavior). +6. Optionally keep/assert OpenCode `skills: []` after inventory load (baseline only; do not implement OpenCode discovery). +7. Document manual repro steps for Solo QA (Cursor selected + `$` primary). + +**Acceptance criteria** + +- [ ] Written repro steps in this plan’s Phase 0 notes (or PR description) that a human can follow in ~2 minutes (Cursor `$` empty). +- [ ] Automated assertion(s) capturing **today’s empty Cursor arrays** (will be inverted/updated in Phase 1). +- [ ] Short note in Phase 0 output confirming select inserts `$name` and Cursor send does not expand skills. +- [ ] `:typecheck` green; no product behavior change required. + +**Estimated file touch list** + +- `apps/server/src/provider/Layers/CursorProvider.ts` (read-only / test hooks) +- Possibly new/extend: `apps/server/src/provider/Layers/CursorProvider.test.ts` +- `apps/web/src/components/chat/ChatComposer.tsx` (read-only confirmation) +- `apps/server/src/provider/Layers/CursorAdapter.ts` (read-only send-path confirmation) +- Optional baseline: `OpenCodeProvider.test.ts` +- This plan file (optional Phase 0 notes) + +--- + +### Phase 1 — Cursor FS skills into snapshot + send-path acceptance + +**Intent:** First user-visible win for the primary provider. Fill `$` from disk, then prove select→send is useful for Cursor (not listing-only theater). + +**Work — 1a Discover (FS)** + +1. Spike and record skill roots Cursor actually uses: + - Project: `.cursor/skills/**/SKILL.md` + - User: `~/.cursor/skills/**` (confirm layout) + - Agents-compatible paths only if Cursor actually reads them — don’t invent +2. Add `cursorSkillDiscovery.ts` (name flexible) with pure scan/parse helpers (SKILL.md frontmatter → `ServerProviderSkill`). +3. Call from `CursorProvider` refresh/probe path; pass `skills` into `buildServerProvider`. +4. Soft-fail: missing dirs → `[]`; parse errors skip file, don’t fail provider. +5. Explicitly leave `slashCommands: []` (ACP `/` is a follow-up). +6. Unit tests with temp dirs / fixtures. + +**Work — 1b Send-path / invocation (Cursor v1 gate)** + +1. Manual or scripted check with Cursor ACP: after selecting a skill from `$`, does the agent actually apply it? +2. Compare insert forms if needed: + - Current UI: `$skillName ` (Codex-aligned) + - Cursor-native docs: `/skillName` + - Fallback only if required: inject/attach skill content on send (prefer not; larger change) +3. If `$name` is inert under Cursor ACP, implement the **smallest** fix so select→send is useful (likely provider-aware insert text, or mapping Cursor skills into a form Cursor honors). Do not ship listing-only if select is known-useless. +4. Document the chosen behavior in the PR description. + +**Acceptance criteria** + +- [ ] With fixture skills on disk, Cursor snapshot `skills` non-empty. +- [ ] Composer `$` search returns those skills (web; mobile inherits snapshot). +- [ ] **Send-path:** Selecting a listed Cursor skill and submitting applies/uses the skill usefully (or the PR documents a verified Cursor-native mechanism that makes the inserted token work). Listing alone is not enough for Phase 1 done. +- [ ] No regression to Cursor model discovery / ACP model picker. +- [ ] `slashCommands` still empty unless a tiny incidental change is required for the send-path fix. +- [ ] `:typecheck` + Cursor discovery unit tests green. + +**Estimated file touch list** + +- `apps/server/src/provider/Layers/CursorProvider.ts` +- `apps/server/src/provider/Drivers/CursorDriver.ts` (only if needed) +- New: `apps/server/src/provider/cursorSkillDiscovery.ts` (+ `.test.ts`) +- Possibly `apps/web/src/components/chat/ChatComposer.tsx` if Cursor needs provider-aware insert text +- `packages/effect-acp` — **read-only** unless a typed client call is required + +--- + +### Phase 2 — Wire `$` semantics for Cursor; keep ACP `/` deferred + +**Intent:** Make menus match the locked model without pulling ACP slash into v1. + +**Work** + +1. **Cursor:** `$` ← FS skills (Phase 1); `/` provider entries remain empty until ACP follow-up. +2. Confirm web `ChatComposer` empty-state copy still makes sense when Cursor has `$` skills but no provider `/` list. +3. Confirm mobile `ThreadComposer` uses the same snapshot fields (no duplicate discovery). +4. Add/adjust presentation tests if Cursor skills need different install/source badges (`providerSkillPresentation`). +5. Explicitly do **not** start OpenCode or ACP command bridging unless the human unblocks. + +**Acceptance criteria** + +- [ ] Cursor `$` shows FS skills; Cursor provider `/` list empty (follow-up). +- [ ] No double-listing the same item under both `$` and `/`. +- [ ] Codex/Claude behavior unchanged. +- [ ] Manual QA checklist for web (mobile smoke optional). + +**Provider menu matrix (target after Cursor v1; OpenCode still deferred)** + +| Provider | `$` | `/` (provider entries) | +| -------- | ---------------------- | ------------------------- | +| Codex | skills | (unchanged) | +| Claude | (unchanged) | slash commands | +| Cursor | FS Cursor skills | empty until ACP follow-up | +| OpenCode | still empty (deferred) | empty | + +**Estimated file touch list** + +- `apps/web/src/components/chat/ChatComposer.tsx` (copy / filtering only if needed) +- `apps/web/src/providerSkillSearch.ts` / presentation helpers (only if needed) +- `apps/mobile/src/features/threads/ThreadComposer.tsx` (only if filtering differs) +- `apps/server/src/provider/Layers/CursorProvider.ts` +- Tests colocated with the above + +--- + +### Phase 3 — Cursor ACP `/` follow-up (optional, after FS `$`) + +**Intent:** Option A second half — ACP available commands as slash entries when snapshot-able. + +**Work** + +1. Research whether ACP available commands exist at probe time vs session-only. +2. If snapshot-able without a live session, map to `ServerProviderSlashCommand`. +3. If session-only, either defer again or design a minimal session→snapshot bridge (larger; needs explicit human go-ahead). +4. Ensure no double-listing with FS `$` skills. + +**Acceptance criteria** + +- [ ] Written decision: ship `/` now vs keep deferred. +- [ ] If shipped: Cursor `/` lists ACP commands; Codex/Claude unchanged. +- [ ] `:typecheck` + tests green. + +**Estimated file touch list** + +- `apps/server/src/provider/Layers/CursorProvider.ts` +- ACP helpers under `apps/server/src/provider/acp/` +- Composer presentation only if needed + +--- + +### Phase 4 — OpenCode skills into snapshot (DEFERRED) + +**Intent:** Second provider; do not start until human unblocks after Cursor FS `$`. + +**Work (when unblocked)** + +1. Extend `OpenCodeInventory` in `opencodeRuntime.ts` with `skills`. +2. **SDK path:** `client.app.skills()` (or current SDK method); soft-fail skills errors. +3. **CLI path:** CLI skills command if stable, else documented FS fallback (#3154 ideas). +4. Map → `ServerProviderSkill`; thread into `buildServerProvider`. +5. Unit tests for SDK-shaped and CLI/FS-shaped skills. +6. Revisit OpenCode select→send the same way as Cursor (listing vs provider interpretation). + +**Acceptance criteria** + +- [ ] Human explicitly unblocked OpenCode work. +- [ ] With OpenCode enabled and at least one skill available, snapshot `skills.length > 0` on SDK **and** local path (or tested fallback). +- [ ] Composer `$` returns those skills. +- [ ] Skills-only inventory failure does not mark whole provider `error`. +- [ ] `:typecheck` + focused OpenCode tests green. + +**Estimated file touch list** + +- `apps/server/src/provider/opencodeRuntime.ts` +- `apps/server/src/provider/Layers/OpenCodeProvider.ts` +- `apps/server/src/provider/Layers/OpenCodeProvider.test.ts` +- Fixtures under `apps/server/src/provider/**` + +--- + +### Phase 5 — Workspace / cwd scoping (if needed) + +**Intent:** Align with #3787/#4031 direction only after Cursor (and later OpenCode) global/project skills work. + +**Work** + +1. Determine whether Cursor (and later OpenCode) skills are already cwd-aware at scan time. +2. If provider probe uses a workspace cwd, pass it into discovery. +3. Optionally set `ServerProviderSkill.scope` for UI. +4. Prefer existing provider refresh hooks before inventing `listSkills` RPC. + +**Acceptance criteria** + +- [ ] Switching workspace changes project-scoped skills appropriately (or documented limitation). +- [ ] Global/user skills still appear when expected. +- [ ] No cross-workspace leakage of project-only skills in tests. +- [ ] `:typecheck` + scoping tests green. + +**Estimated file touch list** + +- Discovery helpers + `CursorProvider` (and OpenCode when live) +- Maybe `providerStatusCache.ts` if cache keys must include cwd + +--- + +### Phase 6 — Tests + manual QA matrix + +**Intent:** Harden and hand off shipped phases (Cursor-first). + +**Work** + +1. Expand unit/integration coverage for shipped phases: + - Cursor FS discovery + - Provider registry snapshot propagation (skills survive cache merge) + - Composer search still filters `enabled: false` + - Select→send behavior covered or manually signed off +2. Manual QA matrix (web; mobile optional): + +| # | Setup | Action | Expected | +| --- | --------------------------------------------- | ------------------- | --------------------------------------------------- | +| 1 | Codex + known skill | `$` → select → send | skill listed and applied (Codex interprets `$name`) | +| 2 | Claude + known command | `/` | command listed | +| 3 | Cursor + `.cursor/skills` fixture | `$` | skill listed | +| 4 | Cursor + listed skill | select → send | skill applied/used (Phase 1 gate) | +| 5 | Cursor without skills | `$` | empty state (not crash) | +| 6 | Cursor ACP commands (only if Phase 3 shipped) | `/` | commands listed | +| 7 | Disable a skill if applicable | `$` | omitted | +| 8 | Provider switch Codex→Cursor | `$`/`/` | lists follow selected provider | +| 9 | Workspace switch (if Phase 5) | `$` | project skills update | +| 10 | OpenCode rows | — | skip until Phase 4 unblocked | + +3. Run Solo `:typecheck`, `:lint`, `:test` (or project-standard CI subset). +4. Update user-facing docs only if the fork already documents provider skill UX — keep doc edits minimal. + +**Acceptance criteria** + +- [ ] All automated tests for shipped phases green under `:test` / focused suites. +- [ ] Manual matrix rows for shipped phases checked (OpenCode rows explicitly skipped if still deferred). +- [ ] Short “how Cursor skills are discovered and applied on send” note in PR description. + +**Estimated file touch list** + +- Tests across `apps/server/src/provider/**` +- `apps/web/src/providerSkillSearch.test.ts` (if new edge cases) +- Optional docs under `docs/providers/cursor.md` + +--- + +## 5. Acceptance criteria (rollup) + +| Phase | Ship gate | +| ----- | --------------------------------------------------------------------------------------- | +| 0 | Empty-snapshot repro + baseline tests; document select=`$name` insert + no T3 expansion | +| 1 | Cursor FS `$` lists skills **and** select→send is useful for Cursor | +| 2 | Cursor `$` semantics correct; ACP `/` still deferred; Codex/Claude unchanged | +| 3 | Optional ACP `/` follow-up | +| 4 | OpenCode `$` (SDK + local) — **deferred until human unblocks** | +| 5 | Cwd/workspace scoping correct or explicitly deferred with reason | +| 6 | Full test + QA matrix for shipped phases | + +--- + +## 6. Solo execution playbook + +### Context + +| Item | Value | +| ----------------------- | ------------------------------------------------------------------------ | +| Fork path | `/Users/blaqat/dev/forks/t3code` | +| Solo project | **t3code** | +| `project_id` | `5` | +| **Only allowed agent** | **Cursor** (`agent_tool_id: **8**`) | +| Preferred control plane | Solo MCP (`select_project` → `spawn_agent` → `send_input`) | +| Nested Task models | **only** `composer-2.5-fast` or `cursor-grok-4.5-high-fast` | +| Do not use | Claude / OpenCode / Codex Solo agents; Solo CLI; assuming HTTP API is on | +| Deferred | OpenCode implementation phases (Phase 4+) until human unblocks | + +### Processes already in `solo.yml` + +| Process | Command | When to use | +| ------------- | --------------------- | ----------------------------------------------------- | +| `:dev` | `pnpm run dev` | Full-stack manual QA | +| `:dev:server` | `pnpm run dev:server` | Server-only iteration | +| `:dev:web` | `pnpm run dev:web` | UI menu checks | +| `:typecheck` | `pnpm run typecheck` | Every phase gate | +| `:lint` | `pnpm run lint` | Before PR / Phase 6 | +| `:test` | `pnpm run test` | Every phase gate (or focused vitest first, then full) | + +### Bootstrap (orchestrator / human via Solo MCP) + +```text +1. select_project(project_id=5) +2. list_agent_tools → confirm Cursor agent id is 8 (do not use other agent tools) +3. list_processes → see :dev / :typecheck / :test entries +4. For implementation: + spawn_agent(agent_tool_id=8, project_id=5, name="skills-phase-N") + send_input(process_id=, input=) +5. For verification (after agent claims done): + start_process(process_name=":typecheck", project_id=5) + start_process(process_name=":test", project_id=5) + (Optional) start_process(process_name=":dev", project_id=5) for manual UI +6. Read tails via get_process_output / get_process_raw_output as needed +``` + +**Notes** + +- **Cursor-only:** Every implementation agent must use `agent_tool_id: 8`. The user has a Cursor subscription only; do not recommend or spawn Claude/OpenCode/Codex Solo agents for this plan. +- OpenCode phases are deferred; spawn agents for Phases 0–2 (and 3/5/6 as needed for Cursor). Do not spawn Phase 4 (OpenCode) until the human says so. +- Command processes may need to be **trusted** in the Solo UI before MCP can start them. +- Prefer one Solo agent per phase (smaller blast radius). Resume with `send_input` rather than spawning duplicates. +- If the agent needs nested Task/subagents, it must pass an allowlisted model explicitly (`composer-2.5-fast` or `cursor-grok-4.5-high-fast`). + +### Suggested prompt templates (per phase) + +#### Phase 0 + +```text +Read docs/plans/cursor-opencode-skill-discovery.md (TL;DR + Phase 0). +Solo project_id=5. Spawn/continue as Cursor agent only (agent_tool_id=8). Nested Task models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Task: Phase 0 only — repro and baseline-test that Cursor provider snapshots expose skills: [] and slashCommands: [] (or omit → default empty). Confirm ChatComposer select inserts `$skillName ` and Cursor sendTurn does not expand skills. Do not implement discovery yet. OpenCode implementation is deferred (optional empty-array baseline only). +When done: summarize evidence, list files touched, run :typecheck (and focused tests). Stop. +``` + +#### Phase 1 + +```text +Read docs/plans/cursor-opencode-skill-discovery.md Phase 1. +You are a Cursor Solo agent (agent_tool_id=8, project_id=5). Nested models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Implement Cursor FS skill discovery into provider snapshot.skills. Soft-fail missing dirs. Unit-test with fixtures. +Then verify select→send: if `$name` is inert for Cursor ACP, apply the smallest fix so selecting a skill is useful on send. +Do not implement OpenCode. Do not implement ACP slashCommands unless required for the send-path fix. +Verify :typecheck + Cursor discovery tests. Stop at Phase 1 acceptance criteria. +``` + +#### Phase 2 + +```text +Read docs/plans/cursor-opencode-skill-discovery.md Phase 2. +Cursor Solo agent only (agent_tool_id=8). Nested models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Wire Cursor $ semantics for FS skills; leave Cursor provider / empty (ACP follow-up). Ensure Codex/Claude unchanged. Adjust empty-state copy only if misleading. +Do not start OpenCode. Verify :typecheck + relevant tests. Stop. +``` + +#### Phase 3 (optional ACP `/`) + +```text +Read docs/plans/cursor-opencode-skill-discovery.md Phase 3. +Cursor Solo agent only (agent_tool_id=8). Nested models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Only if human asked for ACP / follow-up: wire Cursor ACP available commands into slashCommands when snapshot-able. Otherwise stop and report that Phase 3 remains deferred. +Verify :typecheck + tests. Stop. +``` + +#### Phase 4 (OpenCode — deferred) + +```text +Do not run unless the human explicitly unblocked OpenCode. +Read docs/plans/cursor-opencode-skill-discovery.md Phase 4. +Still use Cursor Solo agent only (agent_tool_id=8, project_id=5). Nested models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Implement OpenCode skills into the provider snapshot for BOTH SDK and local CLI paths. Soft-fail skills errors. Map to ServerProviderSkill. Cherry-pick ideas from upstream #3154; do not merge the PR wholesale. +Verify with focused OpenCode tests + :typecheck. Stop at Phase 4 acceptance criteria. +``` + +#### Phase 5 + +```text +Read docs/plans/cursor-opencode-skill-discovery.md Phase 5. +Cursor Solo agent only (agent_tool_id=8). Nested models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Add cwd/workspace scoping for Cursor skills if Phase 1–2 discovery is not already project-aware. Align with ideas from #3787/#4031 without merging those PRs. Prefer refresh-on-probe over new listSkills RPC unless necessary. +Verify scoping tests + :typecheck. Stop. +``` + +#### Phase 6 + +```text +Read docs/plans/cursor-opencode-skill-discovery.md Phase 6. +Cursor Solo agent only (agent_tool_id=8). Nested models: only composer-2.5-fast or cursor-grok-4.5-high-fast. + +Fill remaining automated tests and run the manual QA matrix for shipped Cursor phases. Skip OpenCode rows if Phase 4 still deferred. Run :typecheck, :lint, and :test. Report any skipped matrix rows and why. +Do not start unrelated refactors. +``` + +### Verification cadence + +After each phase agent stops: + +1. `start_process(:typecheck)` — must pass +2. Focused tests the agent names — must pass +3. `start_process(:test)` before merging a phase PR (or once before the final PR if stacking locally) +4. Manual `:dev` UI check when Cursor skills become non-empty (Phase 1+) + +--- + +## 7. Risks / open questions (remaining) + +Resolved and removed from this section: Cursor-first priority; FS before ACP; Solo = Cursor agent 8 only; listing vs runtime clarified (Codex OK with `$name`; Cursor send-path is a Phase 1 acceptance gate). + +### Still open + +1. **Cursor skill roots:** Exact on-disk layouts to scan (project `.cursor/skills`, user `~/.cursor/skills`, any agents-compatible roots Cursor actually reads). Confirm during Phase 1a — don’t invent paths. +2. **Cursor insert token if `$name` is inert:** Smallest fix — provider-aware `/name` insert, other Cursor-honored form, or (last resort) content injection on send? +3. **OpenCode CLI skills (when unblocked):** Stable CLI command vs FS fallback for local mode? +4. **Project-scoped skills:** Wait for Phase 5, or require cwd correctness inside Phase 1? +5. **Presentation:** `scope` / install badges for Cursor in the web skill picker (Codex has presentation heuristics)? +6. **Staleness:** Cache TTL / re-probe when user adds a skill while the app is running? + +--- + +## 8. File touch list (estimated by phase) + +| Phase | Primary files | +| ----- | ------------------------------------------------------------------------------------------------------------------------ | +| **0** | `CursorProvider.test.ts`; read `ChatComposer.tsx`, `CursorAdapter.ts`, `providerSnapshot.ts`; optional OpenCode baseline | +| **1** | `CursorProvider.ts`, new `cursorSkillDiscovery.ts`(+test); maybe `ChatComposer.tsx` for insert-form fix | +| **2** | Cursor provider; possibly `ChatComposer.tsx`, `ThreadComposer.tsx`, `providerSkillSearch.ts`, presentation tests | +| **3** | Cursor ACP helpers + `CursorProvider` (optional follow-up) | +| **4** | `opencodeRuntime.ts`, `OpenCodeProvider.ts`(+test), fixtures — **deferred** | +| **5** | Discovery helpers + provider probe cwd threading; maybe `providerStatusCache.ts` | +| **6** | Tests across server/web; optional `docs/providers/cursor.md` | + +**Shared contracts (touch sparingly):** + +- `packages/contracts/src/server.ts` — `ServerProviderSkill` / `ServerProviderSlashCommand` already sufficient for v1 +- `packages/shared/src/composerTrigger.ts` — trigger detection already supports `$` / `/`; unlikely to change + +**UI already wired (prefer no discovery logic here):** + +- `apps/web/src/components/chat/ChatComposer.tsx` +- `apps/web/src/providerSkillSearch.ts` +- `apps/mobile/src/features/threads/ThreadComposer.tsx` + +--- + +## 9. Suggested PR stacking + +1. `test(cursor): baseline empty skills snapshot` — Phase 0 (optional if tiny, fold into #2) +2. `feat(cursor): discover FS skills into provider snapshot` — Phase 1 (+ send-path fix if needed) +3. `fix(composer): align Cursor $ semantics` — Phase 2 +4. `feat(cursor): ACP slash commands into snapshot` — Phase 3 (optional follow-up) +5. `feat(opencode): populate provider skills in snapshot (sdk + cli)` — Phase 4 (**deferred**) +6. `feat(providers): workspace-scope Cursor skills` — Phase 5 (optional) +7. Tests-only follow-up only if Phase 6 spills + +--- + +## 10. Phase 0 repro notes (fill during Phase 0) + +_Completed during Cursor FS skill implementation:_ + +- Cursor selected → `$` → empty before this work (`skills: []`); after → FS skills from `.cursor/skills` + `~/.cursor/skills` +- Select skill (once listed) inserts → `$skillName ` (unchanged composer UX) +- Cursor send path → `CursorAdapter.sendTurn` injects matched SKILL.md bodies (Codex-style `$name` is inert under ACP; `/name` not relied on) +- OpenCode selected → `$` → still empty (implementation deferred) +- Snapshot evidence: `buildCursorProviderSnapshot({ skills })` + `cursorSkillDiscovery.test.ts` +- Date / commit SHA: see `feat/cursor-fs-skill-discovery` + +### Send-apply choice (Phase 1) + +Cursor ACP `session/prompt` receives plain text. Unlike Codex, there is no runtime `$skill` interpreter. T3 therefore rediscovers FS skills for the session cwd on send and **injects SKILL.md content** for matched `$name` tokens before building prompt parts. Composer insert remains `$name` for UX parity with Codex. From 95e17c1c750b68d46ee37d3693705740b7a55788 Mon Sep 17 00:00:00 2001 From: blaqat Date: Wed, 22 Jul 2026 14:15:38 -0400 Subject: [PATCH 2/5] fix(cursor): discover FS skills using ServerConfig.cwd Align snapshot `$` skill listing with send-path apply by scanning project skills under the active workspace cwd instead of process.cwd(). Co-authored-by: Cursor --- .../src/provider/Drivers/CursorDriver.ts | 7 +- .../provider/Layers/CursorProvider.test.ts | 85 +++++++++++++++++-- .../src/provider/Layers/CursorProvider.ts | 11 ++- .../src/provider/cursorSkillDiscovery.test.ts | 12 +++ 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 61f80489774..8af5a0bb050 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -106,6 +106,7 @@ export const CursorDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const serverConfig = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -132,7 +133,11 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index d5e5f2dbf9a..a51671bc2fa 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -462,12 +462,15 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { describe("checkCursorProviderStatus", () => { it("reports the install docs when the Cursor CLI command is missing", async () => { const provider = await runNode( - checkCursorProviderStatus({ - enabled: true, - binaryPath: missingCursorBinaryPath, - apiEndpoint: "", - customModels: [], - }), + checkCursorProviderStatus( + { + enabled: true, + binaryPath: missingCursorBinaryPath, + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ), ); expect(provider).toMatchObject({ @@ -489,6 +492,7 @@ describe("checkCursorProviderStatus", () => { apiEndpoint: "", customModels: [], }, + process.cwd(), { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, @@ -504,6 +508,75 @@ describe("checkCursorProviderStatus", () => { ]); await expect(runNode(waitForFileContent(requestLogPath))).resolves.toContain("initialize"); }); + + it("discovers project skills from the provided workspace cwd, not process.cwd()", async () => { + const { wrapperPath } = await runNode(makeProviderStatusEnvFixture()); + + const fixture = await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectCwd = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-project-cwd-", + }); + const otherCwd = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-other-cwd-", + }); + const skillDir = path.join(projectCwd, ".cursor", "skills", "ship-it"); + yield* fileSystem.makeDirectory(skillDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(skillDir, "SKILL.md"), + `--- +name: ship-it +description: Ships changes carefully +--- + +# Ship It +`, + ); + return { projectCwd, otherCwd }; + }), + ); + + const withProjectCwd = await runNode( + checkCursorProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + fixture.projectCwd, + ), + ); + expect(withProjectCwd.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "ship-it", + scope: "repo", + enabled: true, + }), + ]), + ); + expect(withProjectCwd.skills.find((skill) => skill.name === "ship-it")?.path).toContain( + fixture.projectCwd, + ); + + const withOtherCwd = await runNode( + checkCursorProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + fixture.otherCwd, + ), + ); + expect(withOtherCwd.skills.some((skill) => skill.name === "ship-it")).toBe(false); + }); }); describe("discoverCursorModelsViaAcp", () => { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 5a8567b9903..a2e571c138a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -989,6 +989,11 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, + /** + * Active workspace cwd (same source as session start / OpenCode status). + * Project skills are discovered under `/.cursor/skills`. + */ + cwd: string, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, @@ -1104,10 +1109,10 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( discoveredModels = discoveryExit.value; } } - // Soft-fail FS skill discovery for the `$` menu snapshot. Project skills use - // process.cwd(); session send-path rediscovers against the thread cwd. + // Soft-fail FS skill discovery for the `$` menu snapshot. Use the same + // workspace cwd as session start / send-path apply (ServerConfig.cwd via driver). const discoveredSkills = yield* discoverCursorSkills({ - projectCwd: process.cwd(), + projectCwd: cwd, }).pipe(Effect.orElseSucceed(() => [] as const)); return buildCursorProviderSnapshot({ diff --git a/apps/server/src/provider/cursorSkillDiscovery.test.ts b/apps/server/src/provider/cursorSkillDiscovery.test.ts index 2e84ade3338..39c027fc0e2 100644 --- a/apps/server/src/provider/cursorSkillDiscovery.test.ts +++ b/apps/server/src/provider/cursorSkillDiscovery.test.ts @@ -33,6 +33,18 @@ describe("resolveCursorSkillRoots", () => { userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), }); }); + + it("scopes project skills to the provided workspace cwd", () => { + expect( + resolveCursorSkillRoots({ + projectCwd: "/workspace/active-project", + userHome: "/Users/demo", + }), + ).toEqual({ + projectSkillsDir: NodePath.join("/workspace/active-project", ".cursor", "skills"), + userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), + }); + }); }); describe("parseCursorSkillMarkdown", () => { From 44c3e9ad49b696603750bc87cd7cdf690bfb0d6e Mon Sep 17 00:00:00 2001 From: blaqat Date: Wed, 22 Jul 2026 14:18:09 -0400 Subject: [PATCH 3/5] fix(cursor): apply listed skills when session cwd diverges Provider `$` listing is process-wide from ServerConfig.cwd, while send used only session cwd. Merge both project roots on send-apply so menu-listed skills still inject when a thread/worktree cwd differs. Co-authored-by: Cursor --- .../src/provider/Layers/CursorAdapter.test.ts | 94 +++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 7 +- .../src/provider/Layers/CursorProvider.ts | 6 +- .../src/provider/cursorSkillDiscovery.test.ts | 113 +++++++++++++++++- .../src/provider/cursorSkillDiscovery.ts | 55 ++++++++- 5 files changed, 261 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 7d847e9dfb1..dcb71b2fbe0 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1495,4 +1495,98 @@ Always verify tests before shipping. assert.notMatch(textPart, /\$ship-it\b/); }), ); + + it.effect("applies listed Cursor skills from ServerConfig.cwd when session cwd diverges", () => { + // Provider `$` listing uses ServerConfig.cwd; threads/worktrees may use a + // different session cwd. Send-apply must still resolve skills that the + // menu listed from the process-wide listing root. + return Effect.gen(function* () { + const listingCwd = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-listing-cwd-")), + ); + const sessionCwd = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-session-cwd-")), + ); + const skillDir = NodePath.join(listingCwd, ".cursor", "skills", "ship-it"); + yield* Effect.promise(() => NodeFSP.mkdir(skillDir, { recursive: true })); + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(skillDir, "SKILL.md"), + `--- +name: ship-it +description: Ships changes carefully +--- + +# Ship It + +Apply listed workspace skill even from a worktree session. +`, + "utf8", + ), + ); + + const divergedAdapterLayer = Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = decodeCursorSettings({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(listingCwd, { + prefix: "t3code-cursor-adapter-skill-cwd-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-cwd-diverge"); + + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-cwd-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: sessionCwd, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "$ship-it prepare the release", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + const prompt = ( + promptRequest?.params as { prompt?: Array<{ type?: string; text?: string }> } + )?.prompt; + const textPart = prompt?.find((part) => part.type === "text")?.text ?? ""; + assert.include(textPart, "Apply listed workspace skill even from a worktree session."); + assert.include(textPart, "prepare the release"); + assert.include(textPart, "Skill `ship-it` (applied by T3 Code for Cursor ACP):"); + assert.notMatch(textPart, /\$ship-it\b/); + }).pipe(Effect.provide(divergedAdapterLayer)); + }); + }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 845e96e30a4..3d866e82985 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -964,9 +964,14 @@ export function makeCursorAdapter( const promptParts: Array = []; if (input.input?.trim()) { // Codex-style `$name` is inert under Cursor ACP. Rediscover FS skills - // for this session cwd and inject matched SKILL.md bodies before prompt. + // and inject matched SKILL.md bodies before prompt. + // Scan session cwd first (thread/worktree), then ServerConfig.cwd + // (provider `$` snapshot source) so listed skills still apply when + // those roots diverge. Snapshot listing stays process-wide until + // per-project capabilities exist. const discoveredSkills = yield* discoverCursorSkills({ projectCwd: ctx.session.cwd, + additionalProjectCwds: [serverConfig.cwd], }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index a2e571c138a..70746cb3450 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1109,8 +1109,10 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( discoveredModels = discoveryExit.value; } } - // Soft-fail FS skill discovery for the `$` menu snapshot. Use the same - // workspace cwd as session start / send-path apply (ServerConfig.cwd via driver). + // Soft-fail FS skill discovery for the `$` menu snapshot. + // Provider status is process-wide: this `cwd` is ServerConfig.cwd (via driver). + // Send-apply also scans session cwd + this same ServerConfig.cwd so listed + // skills remain applicable when a thread/worktree cwd diverges. const discoveredSkills = yield* discoverCursorSkills({ projectCwd: cwd, }).pipe(Effect.orElseSucceed(() => [] as const)); diff --git a/apps/server/src/provider/cursorSkillDiscovery.test.ts b/apps/server/src/provider/cursorSkillDiscovery.test.ts index 39c027fc0e2..309461e2349 100644 --- a/apps/server/src/provider/cursorSkillDiscovery.test.ts +++ b/apps/server/src/provider/cursorSkillDiscovery.test.ts @@ -22,14 +22,14 @@ describe("resolveCursorSkillRoots", () => { userHome: "/Users/demo", }), ).toEqual({ - projectSkillsDir: NodePath.join("/tmp/project", ".cursor", "skills"), + projectSkillsDirs: [NodePath.join(NodePath.resolve("/tmp/project"), ".cursor", "skills")], userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), }); }); it("omits project root when cwd is missing", () => { expect(resolveCursorSkillRoots({ projectCwd: null, userHome: "/Users/demo" })).toEqual({ - projectSkillsDir: null, + projectSkillsDirs: [], userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), }); }); @@ -41,7 +41,27 @@ describe("resolveCursorSkillRoots", () => { userHome: "/Users/demo", }), ).toEqual({ - projectSkillsDir: NodePath.join("/workspace/active-project", ".cursor", "skills"), + projectSkillsDirs: [ + NodePath.join(NodePath.resolve("/workspace/active-project"), ".cursor", "skills"), + ], + userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), + }); + }); + + it("merges additional project cwds and dedupes resolved paths", () => { + const sessionCwd = "/tmp/worktree"; + const listingCwd = "/tmp/workspace"; + expect( + resolveCursorSkillRoots({ + projectCwd: sessionCwd, + additionalProjectCwds: [listingCwd, `${sessionCwd}/`], + userHome: "/Users/demo", + }), + ).toEqual({ + projectSkillsDirs: [ + NodePath.join(NodePath.resolve(sessionCwd), ".cursor", "skills"), + NodePath.join(NodePath.resolve(listingCwd), ".cursor", "skills"), + ], userSkillsDir: NodePath.join("/Users/demo", ".cursor", "skills"), }); }); @@ -157,7 +177,7 @@ description: Only for user const skills = await discoverCursorSkillsWithFs( { - projectSkillsDir: "/proj/.cursor/skills", + projectSkillsDirs: ["/proj/.cursor/skills"], userSkillsDir: "/home/.cursor/skills", }, { @@ -184,10 +204,93 @@ description: Only for user expect(toServerProviderSkills(skills).every((skill) => !("content" in skill))).toBe(true); }); + it("merges skills from multiple project roots with earlier roots winning", async () => { + const files = new Map([ + [ + "/session/.cursor/skills/session-only/SKILL.md", + `--- +name: session-only +description: Only in session cwd +--- +# Session +`, + ], + [ + "/session/.cursor/skills/shared/SKILL.md", + `--- +name: shared +description: Session shared skill +--- +# Session shared +`, + ], + [ + "/listing/.cursor/skills/shared/SKILL.md", + `--- +name: shared +description: Listing shared skill +--- +# Listing shared +`, + ], + [ + "/listing/.cursor/skills/listed-only/SKILL.md", + `--- +name: listed-only +description: Only in listing cwd +--- +# Listed +`, + ], + ]); + const directories = new Set([ + "/session/.cursor/skills", + "/session/.cursor/skills/session-only", + "/session/.cursor/skills/shared", + "/listing/.cursor/skills", + "/listing/.cursor/skills/shared", + "/listing/.cursor/skills/listed-only", + ]); + + const skills = await discoverCursorSkillsWithFs( + { + projectSkillsDirs: ["/session/.cursor/skills", "/listing/.cursor/skills"], + userSkillsDir: null, + }, + { + readFile: async (path) => files.get(path) ?? null, + readDirectory: async (path) => { + if (path === "/session/.cursor/skills") { + return ["session-only", "shared"]; + } + if (path === "/listing/.cursor/skills") { + return ["shared", "listed-only"]; + } + return null; + }, + isDirectory: async (path) => directories.has(path), + join: (...parts) => parts.join("/"), + }, + ); + + expect(skills.map((skill) => skill.name).sort()).toEqual([ + "listed-only", + "session-only", + "shared", + ]); + expect(skills.find((skill) => skill.name === "shared")).toMatchObject({ + description: "Session shared skill", + path: "/session/.cursor/skills/shared/SKILL.md", + }); + expect(skills.find((skill) => skill.name === "listed-only")).toMatchObject({ + path: "/listing/.cursor/skills/listed-only/SKILL.md", + }); + }); + it("soft-fails missing skill roots", async () => { const skills = await discoverCursorSkillsWithFs( { - projectSkillsDir: "/missing/.cursor/skills", + projectSkillsDirs: ["/missing/.cursor/skills"], userSkillsDir: null, }, { diff --git a/apps/server/src/provider/cursorSkillDiscovery.ts b/apps/server/src/provider/cursorSkillDiscovery.ts index b3af1b7c094..71fbebbecf2 100644 --- a/apps/server/src/provider/cursorSkillDiscovery.ts +++ b/apps/server/src/provider/cursorSkillDiscovery.ts @@ -29,7 +29,18 @@ const CURSOR_SKILLS_REL = NodePath.join(".cursor", "skills"); const CURSOR_SKILL_MENTION_RE = /\$([a-z0-9]+(?:-[a-z0-9]+)*)\b/gi; export interface CursorSkillRoots { + /** + * Primary project cwd (session/thread cwd on send-apply). + * Scanned first so worktree-local skills win on name collision. + */ readonly projectCwd?: string | null | undefined; + /** + * Extra project roots scanned after `projectCwd`. + * Send-apply passes `ServerConfig.cwd` here so `$` menu skills (provider + * snapshot, process-wide) still resolve when session cwd diverges + * (worktree/thread). Deduped against `projectCwd` after path resolve. + */ + readonly additionalProjectCwds?: ReadonlyArray; readonly userHome?: string | null | undefined; } @@ -44,14 +55,36 @@ export interface DiscoveredCursorSkill { readonly shortDescription?: string; } +function normalizeProjectCwds( + primary: string | null | undefined, + additional: ReadonlyArray | undefined, +): ReadonlyArray { + const out: Array = []; + const seen = new Set(); + for (const raw of [primary, ...(additional ?? [])]) { + const trimmed = raw?.trim(); + if (!trimmed) { + continue; + } + const resolved = NodePath.resolve(trimmed); + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + out.push(resolved); + } + return out; +} + export function resolveCursorSkillRoots(input: CursorSkillRoots = {}): { - readonly projectSkillsDir: string | null; + /** Ordered project skill dirs (primary first). Empty when no project cwd. */ + readonly projectSkillsDirs: ReadonlyArray; readonly userSkillsDir: string | null; } { - const projectCwd = input.projectCwd?.trim() || null; + const projectCwds = normalizeProjectCwds(input.projectCwd, input.additionalProjectCwds); const userHome = (input.userHome ?? NodeOS.homedir()).trim() || null; return { - projectSkillsDir: projectCwd ? NodePath.join(projectCwd, CURSOR_SKILLS_REL) : null, + projectSkillsDirs: projectCwds.map((cwd) => NodePath.join(cwd, CURSOR_SKILLS_REL)), userSkillsDir: userHome ? NodePath.join(userHome, CURSOR_SKILLS_REL) : null, }; } @@ -128,6 +161,8 @@ export function parseCursorSkillMarkdown( */ export async function discoverCursorSkillsWithFs( roots: { + readonly projectSkillsDirs?: ReadonlyArray; + /** @deprecated Prefer `projectSkillsDirs`. Kept for call-site convenience. */ readonly projectSkillsDir?: string | null; readonly userSkillsDir?: string | null; }, @@ -172,8 +207,14 @@ export async function discoverCursorSkillsWithFs( } }; - // Project skills win over user skills on name collision. - await scanRoot(roots.projectSkillsDir, "repo"); + const projectDirs = [ + ...(roots.projectSkillsDirs ?? []), + ...(roots.projectSkillsDir ? [roots.projectSkillsDir] : []), + ]; + // Earlier project roots win on name collision; project wins over user. + for (const projectDir of projectDirs) { + await scanRoot(projectDir, "repo"); + } await scanRoot(roots.userSkillsDir, "user"); return discovered; @@ -252,7 +293,9 @@ export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* } }); - yield* scanRoot(roots.projectSkillsDir, "repo"); + for (const projectDir of roots.projectSkillsDirs) { + yield* scanRoot(projectDir, "repo"); + } yield* scanRoot(roots.userSkillsDir, "user"); return discovered; }); From 82c445ede9e5d3c26eb7b1bd231d3aaffec4adb0 Mon Sep 17 00:00:00 2001 From: blaqat Date: Wed, 22 Jul 2026 14:20:14 -0400 Subject: [PATCH 4/5] fix(cursor): harden FS skill discovery before ship Reject symlink escapes via realpath/lstat containment, require a leading boundary on $skill mentions so ENV=$name is not expanded, and cap injected SKILL.md bodies (strip frontmatter, 64KiB) at discover and apply. Co-authored-by: Cursor --- .../src/provider/cursorSkillDiscovery.test.ts | 219 ++++++++++++++++-- .../src/provider/cursorSkillDiscovery.ts | 149 ++++++++++-- 2 files changed, 323 insertions(+), 45 deletions(-) diff --git a/apps/server/src/provider/cursorSkillDiscovery.test.ts b/apps/server/src/provider/cursorSkillDiscovery.test.ts index 309461e2349..8f1c248d301 100644 --- a/apps/server/src/provider/cursorSkillDiscovery.test.ts +++ b/apps/server/src/provider/cursorSkillDiscovery.test.ts @@ -9,11 +9,58 @@ import { applyCursorSkillMentions, collectCursorSkillMentions, discoverCursorSkillsWithFs, + MAX_CURSOR_SKILL_CONTENT_BYTES, parseCursorSkillMarkdown, resolveCursorSkillRoots, + skillBodyForInjection, + stripYamlFrontmatter, toServerProviderSkills, + type CursorSkillDiscoveryFs, } from "./cursorSkillDiscovery.ts"; +function nodeDiscoveryFs(): CursorSkillDiscoveryFs { + return { + readFile: async (path) => { + try { + return await NodeFS.readFile(path, "utf8"); + } catch { + return null; + } + }, + readDirectory: async (path) => { + try { + return await NodeFS.readdir(path); + } catch { + return null; + } + }, + isDirectory: async (path) => { + try { + const stat = await NodeFS.stat(path); + return stat.isDirectory(); + } catch { + return false; + } + }, + join: NodePath.join, + realPath: async (path) => { + try { + return await NodeFS.realpath(path); + } catch { + return null; + } + }, + lstatIsRegularFile: async (path) => { + try { + const stat = await NodeFS.lstat(path); + return stat.isFile(); + } catch { + return false; + } + }, + }; +} + describe("resolveCursorSkillRoots", () => { it("resolves project and user Cursor skill directories", () => { expect( @@ -94,6 +141,8 @@ Do the demo. path: "/tmp/project/.cursor/skills/demo-skill/SKILL.md", }); expect(skill?.content).toContain("# Demo"); + expect(skill?.content).not.toContain("disable-model-invocation"); + expect(skill?.content).not.toMatch(/^---/); }); it("falls back to directory name when frontmatter name is missing", () => { @@ -110,6 +159,7 @@ Body ); expect(skill?.name).toBe("my-skill"); expect(skill?.scope).toBe("user"); + expect(skill?.content).toBe("Body"); }); it("rejects invalid skill names", () => { @@ -124,6 +174,40 @@ name: Bad_Name ), ).toBeNull(); }); + + it("rejects skill bodies over the size cap", () => { + const oversizedBody = "x".repeat(MAX_CURSOR_SKILL_CONTENT_BYTES + 1); + expect( + parseCursorSkillMarkdown( + `--- +name: huge +--- + +${oversizedBody} +`, + "/tmp/x/.cursor/skills/huge/SKILL.md", + "huge", + ), + ).toBeNull(); + }); +}); + +describe("stripYamlFrontmatter / skillBodyForInjection", () => { + it("strips YAML frontmatter for injection", () => { + expect( + stripYamlFrontmatter(`--- +name: demo +--- + +# Body +`), + ).toBe("# Body"); + }); + + it("returns null for oversized injection bodies", () => { + expect(skillBodyForInjection("x".repeat(MAX_CURSOR_SKILL_CONTENT_BYTES + 1))).toBeNull(); + expect(skillBodyForInjection("ok")).toBe("ok"); + }); }); describe("discoverCursorSkillsWithFs", () => { @@ -324,31 +408,7 @@ description: Ships the thing projectSkillsDir: NodePath.join(root, "project", ".cursor", "skills"), userSkillsDir: NodePath.join(root, "nouser", ".cursor", "skills"), }, - { - readFile: async (path) => { - try { - return await NodeFS.readFile(path, "utf8"); - } catch { - return null; - } - }, - readDirectory: async (path) => { - try { - return await NodeFS.readdir(path); - } catch { - return null; - } - }, - isDirectory: async (path) => { - try { - const stat = await NodeFS.stat(path); - return stat.isDirectory(); - } catch { - return false; - } - }, - join: NodePath.join, - }, + nodeDiscoveryFs(), ); expect(skills).toHaveLength(1); @@ -356,8 +416,87 @@ description: Ships the thing name: "ship-it", scope: "repo", enabled: true, + content: "# Ship it", }); }); + + it("rejects skill directories that symlink outside the skills root", async () => { + const root = await NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skills-escape-dir-")); + const skillsRoot = NodePath.join(root, "project", ".cursor", "skills"); + const outsideDir = NodePath.join(root, "outside", "leaked"); + await NodeFS.mkdir(skillsRoot, { recursive: true }); + await NodeFS.mkdir(outsideDir, { recursive: true }); + await NodeFS.writeFile( + NodePath.join(outsideDir, "SKILL.md"), + `--- +name: leaked +--- + +# Outside +`, + "utf8", + ); + await NodeFS.symlink(outsideDir, NodePath.join(skillsRoot, "leaked")); + + const skills = await discoverCursorSkillsWithFs( + { projectSkillsDirs: [skillsRoot], userSkillsDir: null }, + nodeDiscoveryFs(), + ); + expect(skills).toEqual([]); + }); + + it("rejects SKILL.md files that symlink outside the skills root", async () => { + const root = await NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skills-escape-file-")); + const skillDir = NodePath.join(root, "project", ".cursor", "skills", "leaked"); + const outsideFile = NodePath.join(root, "outside", "secret.md"); + await NodeFS.mkdir(skillDir, { recursive: true }); + await NodeFS.mkdir(NodePath.dirname(outsideFile), { recursive: true }); + await NodeFS.writeFile( + outsideFile, + `--- +name: leaked +--- + +# Outside secret +`, + "utf8", + ); + await NodeFS.symlink(outsideFile, NodePath.join(skillDir, "SKILL.md")); + + const skills = await discoverCursorSkillsWithFs( + { + projectSkillsDirs: [NodePath.join(root, "project", ".cursor", "skills")], + userSkillsDir: null, + }, + nodeDiscoveryFs(), + ); + expect(skills).toEqual([]); + }); + + it("skips oversized skill files during discovery", async () => { + const root = await NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skills-huge-")); + const skillDir = NodePath.join(root, "project", ".cursor", "skills", "huge"); + await NodeFS.mkdir(skillDir, { recursive: true }); + await NodeFS.writeFile( + NodePath.join(skillDir, "SKILL.md"), + `--- +name: huge +--- + +${"x".repeat(MAX_CURSOR_SKILL_CONTENT_BYTES + 1)} +`, + "utf8", + ); + + const skills = await discoverCursorSkillsWithFs( + { + projectSkillsDirs: [NodePath.join(root, "project", ".cursor", "skills")], + userSkillsDir: null, + }, + nodeDiscoveryFs(), + ); + expect(skills).toEqual([]); + }); }); describe("applyCursorSkillMentions", () => { @@ -368,6 +507,11 @@ describe("applyCursorSkillMentions", () => { ]); }); + it("does not treat ENV=$name or mid-token $ as mentions", () => { + expect(collectCursorSkillMentions("ENV=$ship-it and pre$ship-it mid")).toEqual([]); + expect(collectCursorSkillMentions("foo_ship-it $ok end")).toEqual(["ok"]); + }); + it("injects SKILL.md bodies for matched mentions and strips $tokens", () => { const applied = applyCursorSkillMentions("$ship-it do the thing", [ { @@ -385,9 +529,25 @@ name: ship-it expect(applied).toContain("Skill `ship-it` (applied by T3 Code for Cursor ACP):"); expect(applied).toContain("# Ship it instructions"); expect(applied).toContain("do the thing"); + expect(applied).not.toContain("name: ship-it"); expect(applied).not.toMatch(/\$ship-it\b/); }); + it("leaves ENV=$name unchanged while still applying a leading $name mention", () => { + const applied = applyCursorSkillMentions("ENV=$ship-it and $ship-it please", [ + { + name: "ship-it", + enabled: true, + content: "# Body", + }, + ]); + expect(applied).toContain("ENV=$ship-it"); + expect(applied).toContain("# Body"); + expect(applied).toContain("please"); + // Only the ENV= form remains; the free-standing mention was stripped. + expect(applied.match(/\$ship-it/g)).toEqual(["$ship-it"]); + }); + it("leaves unknown $mentions unchanged", () => { expect(applyCursorSkillMentions("$missing please", [])).toBe("$missing please"); }); @@ -397,4 +557,13 @@ name: ship-it applyCursorSkillMentions("$off please", [{ name: "off", enabled: false, content: "# Off" }]), ).toBe("$off please"); }); + + it("skips oversized skills at apply time", () => { + const oversized = "x".repeat(MAX_CURSOR_SKILL_CONTENT_BYTES + 1); + expect( + applyCursorSkillMentions("$huge please", [ + { name: "huge", enabled: true, content: oversized }, + ]), + ).toBe("$huge please"); + }); }); diff --git a/apps/server/src/provider/cursorSkillDiscovery.ts b/apps/server/src/provider/cursorSkillDiscovery.ts index 71fbebbecf2..eff0ed43548 100644 --- a/apps/server/src/provider/cursorSkillDiscovery.ts +++ b/apps/server/src/provider/cursorSkillDiscovery.ts @@ -14,6 +14,7 @@ */ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -25,8 +26,18 @@ import * as Path from "effect/Path"; const SKILL_FILE_NAME = "SKILL.md"; const CURSOR_SKILLS_REL = NodePath.join(".cursor", "skills"); -/** `$skill-name` mentions (Codex-style composer insert). */ -const CURSOR_SKILL_MENTION_RE = /\$([a-z0-9]+(?:-[a-z0-9]+)*)\b/gi; +/** + * Max UTF-8 bytes for a skill body stored/injected after frontmatter strip. + * Keeps ACP prompts bounded if a skill file is accidentally huge. + */ +export const MAX_CURSOR_SKILL_CONTENT_BYTES = 64 * 1024; + +/** + * `$skill-name` mentions (Codex-style composer insert). + * Leading lookbehind rejects mid-token / assignment forms like `ENV=$skill` + * and `pre$skill` (must not be preceded by ident or `=`). + */ +const CURSOR_SKILL_MENTION_RE = /(? MAX_CURSOR_SKILL_CONTENT_BYTES) { + return null; + } + return body; +} + function normalizeProjectCwds( primary: string | null | undefined, additional: ReadonlyArray | undefined, @@ -125,12 +176,16 @@ export function parseCursorSkillMarkdown( ): DiscoveredCursorSkill | null { const content = raw.replace(/^\uFEFF/, ""); let frontmatter = ""; + let body = content; if (content.startsWith("---")) { const endIdx = content.indexOf("\n---", 3); if (endIdx !== -1) { frontmatter = content.slice(3, endIdx).replace(/^\r?\n/, ""); + body = stripYamlFrontmatter(content); } + } else { + body = content.trim(); } const nameFromFm = frontmatter.match(/^name:\s*['"]?([a-z0-9-]+)['"]?\s*$/im)?.[1]; @@ -139,12 +194,16 @@ export function parseCursorSkillMarkdown( return null; } + if (utf8ByteLength(body) > MAX_CURSOR_SKILL_CONTENT_BYTES) { + return null; + } + const description = parseDescriptionField(frontmatter); return { name, path: skillPath, enabled: true, - content: content.trim(), + content: body, ...(scope ? { scope } : {}), ...(description ? { @@ -156,6 +215,17 @@ export function parseCursorSkillMarkdown( }; } +export type CursorSkillDiscoveryFs = { + readonly readFile: (path: string) => Promise; + readonly readDirectory: (path: string) => Promise | null>; + readonly isDirectory: (path: string) => Promise; + readonly join: (...parts: string[]) => string; + /** Canonicalize path; return null when the path cannot be resolved. */ + readonly realPath?: (path: string) => Promise; + /** `lstat`-based regular-file check (rejects symlinks). Defaults to true. */ + readonly lstatIsRegularFile?: (path: string) => Promise; +}; + /** * Pure scan helper for tests — pass filesystem callbacks. */ @@ -166,20 +236,21 @@ export async function discoverCursorSkillsWithFs( readonly projectSkillsDir?: string | null; readonly userSkillsDir?: string | null; }, - fs: { - readonly readFile: (path: string) => Promise; - readonly readDirectory: (path: string) => Promise | null>; - readonly isDirectory: (path: string) => Promise; - readonly join: (...parts: string[]) => string; - }, + fs: CursorSkillDiscoveryFs, ): Promise> { const discovered: Array = []; const seenNames = new Set(); + const realPath = fs.realPath ?? (async (path: string) => path); + const lstatIsRegularFile = fs.lstatIsRegularFile ?? (async () => true); const scanRoot = async (skillsRoot: string | null | undefined, scope: "user" | "repo") => { if (!skillsRoot) { return; } + const realSkillsRoot = await realPath(skillsRoot); + if (!realSkillsRoot) { + return; + } const entries = await fs.readDirectory(skillsRoot); if (!entries) { return; @@ -193,12 +264,23 @@ export async function discoverCursorSkillsWithFs( if (!(await fs.isDirectory(skillDir))) { continue; } + const realSkillDir = await realPath(skillDir); + if (!realSkillDir || !isPathInsideRoot(realSkillsRoot, realSkillDir)) { + continue; + } const skillPath = fs.join(skillDir, SKILL_FILE_NAME); - const raw = await fs.readFile(skillPath); + if (!(await lstatIsRegularFile(skillPath))) { + continue; + } + const realSkillPath = await realPath(skillPath); + if (!realSkillPath || !isPathInsideRoot(realSkillsRoot, realSkillPath)) { + continue; + } + const raw = await fs.readFile(realSkillPath); if (raw === null) { continue; } - const skill = parseCursorSkillMarkdown(raw, skillPath, entryName, scope); + const skill = parseCursorSkillMarkdown(raw, realSkillPath, entryName, scope); if (!skill || seenNames.has(skill.name)) { continue; } @@ -259,6 +341,16 @@ export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* Effect.orElseSucceed(() => false), ); + const realPath = (filePath: string) => + fileSystem.realPath(filePath).pipe(Effect.orElseSucceed(() => null as string | null)); + + const lstatIsRegularFile = (filePath: string) => + Effect.promise(() => + NodeFS.lstat(filePath) + .then((stat) => stat.isFile()) + .catch(() => false), + ); + const discovered: Array = []; const seenNames = new Set(); @@ -267,6 +359,10 @@ export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* if (!skillsRoot) { return; } + const realSkillsRoot = yield* realPath(skillsRoot); + if (!realSkillsRoot) { + return; + } const entries = yield* readDirectory(skillsRoot); if (!entries) { return; @@ -279,12 +375,23 @@ export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* if (!(yield* isDirectory(skillDir))) { continue; } + const realSkillDir = yield* realPath(skillDir); + if (!realSkillDir || !isPathInsideRoot(realSkillsRoot, realSkillDir)) { + continue; + } const skillPath = pathApi.join(skillDir, SKILL_FILE_NAME); - const raw = yield* readFile(skillPath); + if (!(yield* lstatIsRegularFile(skillPath))) { + continue; + } + const realSkillPath = yield* realPath(skillPath); + if (!realSkillPath || !isPathInsideRoot(realSkillsRoot, realSkillPath)) { + continue; + } + const raw = yield* readFile(realSkillPath); if (raw === null) { continue; } - const skill = parseCursorSkillMarkdown(raw, skillPath, entryName, scope); + const skill = parseCursorSkillMarkdown(raw, realSkillPath, entryName, scope); if (!skill || seenNames.has(skill.name)) { continue; } @@ -315,11 +422,8 @@ export function collectCursorSkillMentions(prompt: string): ReadonlyArray): string { - return [ - `Skill \`${skill.name}\` (applied by T3 Code for Cursor ACP):`, - "", - skill.content.trim(), - ].join("\n"); + const body = skillBodyForInjection(skill.content) ?? ""; + return [`Skill \`${skill.name}\` (applied by T3 Code for Cursor ACP):`, "", body].join("\n"); } /** @@ -347,9 +451,14 @@ export function applyCursorSkillMentions( const applied: Array> = []; for (const name of mentioned) { const skill = byName.get(name); - if (skill) { - applied.push(skill); + if (!skill) { + continue; + } + const body = skillBodyForInjection(skill.content); + if (body === null) { + continue; } + applied.push({ name: skill.name, content: body }); } if (applied.length === 0) { return prompt; From 7678e7bbfccca8569f90c60457799f0ffb0f3103 Mon Sep 17 00:00:00 2001 From: blaqat Date: Wed, 22 Jul 2026 14:21:19 -0400 Subject: [PATCH 5/5] docs(plans): mark Cursor FS skill Phase 1 done Record beach-mode gate status, shipped SHAs, and residuals (per-thread menu, OpenCode, ACP slash) before push. Co-authored-by: Cursor --- docs/plans/cursor-opencode-skill-discovery.md | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/plans/cursor-opencode-skill-discovery.md b/docs/plans/cursor-opencode-skill-discovery.md index 422c280d7f0..26f9f0a8006 100644 --- a/docs/plans/cursor-opencode-skill-discovery.md +++ b/docs/plans/cursor-opencode-skill-discovery.md @@ -1,9 +1,15 @@ # Cursor + OpenCode Skill Discovery — Development Plan -> **Status:** Ready for Solo execution (project `t3code`, `project_id: 5`) -> **Do not implement from this chat unless a Solo agent is spawned with an explicit phase prompt.** -> **Nested Task model allowlist:** only `composer-2.5-fast` or `cursor-grok-4.5-high-fast` (always set explicitly). -> **Solo agents:** **Cursor only** (`agent_tool_id: 8`). Do not spawn Claude / OpenCode / Codex Solo agents for this work. +> **Status (2026-07-22):** **Phase 1 Cursor FS list + apply — DONE** on `feat/cursor-fs-skill-discovery` (tip `82c445ede`). +> Snapshot `$` skills from `.cursor/skills` + `~/.cursor/skills`; send-path injects matched SKILL.md bodies under Cursor ACP. Hardened (symlink containment, `$` mention boundary, 64KiB body cap). +> +> **Residuals (not blocking ship):** +> +> - **Per-thread / per-worktree `$` menu** — provider snapshot remains process-wide (`ServerConfig.cwd`); send-apply already merges session cwd + server cwd so listed skills still inject when roots diverge. True per-thread menu listing is a follow-up. +> - **OpenCode `$`** — deferred until human unblocks. +> - **Cursor ACP `/` (slashCommands)** — deferred follow-up after FS `$`. +> +> Nested Task model allowlist: only `composer-2.5-fast` or `cursor-grok-4.5-high-fast`. Solo agents: **Cursor only** (`agent_tool_id: 8`). --- @@ -195,12 +201,14 @@ Plain language: **Acceptance criteria** -- [ ] With fixture skills on disk, Cursor snapshot `skills` non-empty. -- [ ] Composer `$` search returns those skills (web; mobile inherits snapshot). -- [ ] **Send-path:** Selecting a listed Cursor skill and submitting applies/uses the skill usefully (or the PR documents a verified Cursor-native mechanism that makes the inserted token work). Listing alone is not enough for Phase 1 done. -- [ ] No regression to Cursor model discovery / ACP model picker. -- [ ] `slashCommands` still empty unless a tiny incidental change is required for the send-path fix. -- [ ] `:typecheck` + Cursor discovery unit tests green. +- [x] With fixture skills on disk, Cursor snapshot `skills` non-empty. +- [x] Composer `$` search returns those skills (web; mobile inherits snapshot). +- [x] **Send-path:** Selecting a listed Cursor skill and submitting applies/uses the skill usefully (or the PR documents a verified Cursor-native mechanism that makes the inserted token work). Listing alone is not enough for Phase 1 done. +- [x] No regression to Cursor model discovery / ACP model picker. +- [x] `slashCommands` still empty unless a tiny incidental change is required for the send-path fix. +- [x] `:typecheck` + Cursor discovery unit tests green. + +**Shipped (Phase 1 DONE):** discover + apply (`7173f10a3`), ServerConfig.cwd listing (`95e17c1c7`), send-apply merge session+server cwd (`44c3e9ad4`), P1/P2/P3 harden (`82c445ede`). **Estimated file touch list** @@ -529,12 +537,13 @@ Resolved and removed from this section: Cursor-first priority; FS before ACP; So ### Still open -1. **Cursor skill roots:** Exact on-disk layouts to scan (project `.cursor/skills`, user `~/.cursor/skills`, any agents-compatible roots Cursor actually reads). Confirm during Phase 1a — don’t invent paths. -2. **Cursor insert token if `$name` is inert:** Smallest fix — provider-aware `/name` insert, other Cursor-honored form, or (last resort) content injection on send? -3. **OpenCode CLI skills (when unblocked):** Stable CLI command vs FS fallback for local mode? -4. **Project-scoped skills:** Wait for Phase 5, or require cwd correctness inside Phase 1? +1. ~~**Cursor skill roots**~~ — **Resolved:** project `.cursor/skills`, user `~/.cursor/skills` (not `skills-cursor` built-ins). +2. ~~**Cursor insert token if `$name` is inert**~~ — **Resolved:** keep `$name` insert UX; inject SKILL.md body on send (see §10). +3. **OpenCode CLI skills (when unblocked):** Stable CLI command vs FS fallback for local mode? _(deferred)_ +4. ~~**Project-scoped skills / cwd**~~ — **Partially resolved:** listing uses `ServerConfig.cwd`; send-apply merges session cwd + server cwd. **Residual:** per-thread `$` menu still process-wide. 5. **Presentation:** `scope` / install badges for Cursor in the web skill picker (Codex has presentation heuristics)? 6. **Staleness:** Cache TTL / re-probe when user adds a skill while the app is running? +7. **Cursor ACP `/`:** still deferred (Phase 3). ---