From 17e73fc986f011e0e1f3c788e2803197891ce91e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:57:02 +0000 Subject: [PATCH 1/2] test(cursor): user-invoked Cursor skills reach the SDK prompt (repro #15606) AI-assisted; verified with the tests and tools listed above. --- .../src/server/adapter.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 9d2162f0337fd..e0f31cd8fb4a9 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -2064,6 +2064,12 @@ describe("CursorAdapterV2", () => { path.join(skill, "SKILL.md"), "# Review\nReview the code.", ); + const userOnlySkill = path.join(workspace, ".cursor", "skills", "grill-me"); + yield* fileSystem.makeDirectory(userOnlySkill, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(userOnlySkill, "SKILL.md"), + "---\nname: grill-me\ndescription: Grill the plan.\ndisable-model-invocation: true\n---\nAsk one question at a time.", + ); const steered: string[] = []; let cancelled = 0; let outcome: "complete_delivered" | "revert_to_followup" = "complete_delivered"; @@ -2198,6 +2204,21 @@ describe("CursorAdapterV2", () => { }); assert.deepEqual(steered, ["/review Look at the tests instead."]); assert.equal(cancelled, 0); + yield* runtime.steerTurn({ + threadId, + runId: turn.runId, + providerThread, + providerTurnId, + message: { + messageId: MessageId.make("cursor-steer-user-only-skill"), + createdBy: "user", + creationSource: "web", + text: "$grill-me My plan.", + attachments: [], + }, + }); + // Cursor hides `disable-model-invocation` skills from the model, so the SDK prompt must carry them. + assert.include(steered.at(-1), "Ask one question at a time."); outcome = "revert_to_followup"; const declined = yield* runtime @@ -2216,7 +2237,7 @@ describe("CursorAdapterV2", () => { }) .pipe(Effect.exit); if (!Exit.isFailure(declined)) return yield* Effect.die("Expected a declined steer"); - assert.deepEqual(steered, ["/review Look at the tests instead.", "Stop and do this."]); + assert.equal(steered.at(-1), "Stop and do this."); assert.equal(cancelled, 0); const declinedError = Cause.squash(declined.cause); assert.instanceOf(declinedError, ProviderAdapter.ProviderAdapterSteerDeclinedError); From 329988da19ca95b1d0d32c84641b470f6b6ee795 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:01:23 +0000 Subject: [PATCH 2/2] fix(cursor): user-invoked skills hidden from the model reach the Cursor prompt Cursor leaves disable-model-invocation skills out of the model's catalog, and the SDK takes only a string, so /name alone never loaded them. The adapter now appends the SKILL.md body of each invoked user-only skill to the prompt. Fixes pingdotgg/t3code#15606 AI-assisted; verified with the tests and tools listed above. --- .../provider-cursor/src/server/adapter.ts | 30 ++++++++++++++----- packages/provider-cursor/src/server/skills.ts | 19 ++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 65d47f57f97f9..243cf7341d405 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -28,6 +28,7 @@ import { type OrchestrationV2TurnItem, type ProviderInstanceId, type ServerProviderModel, + type ServerProviderSkill, type ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -48,6 +49,8 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { CursorTransportFailure } from "./transportFailure.ts"; import { cursorContextWindowTokens, cursorSdkModelSelection } from "./sdkModel.ts"; import { + appendCursorInvokedSkills, + cursorSlashSkillNames, discoverCursorSkills, hasCursorSkillMention, rewriteCursorSkillMentions, @@ -2309,23 +2312,36 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( return next; }); - let cursorSkillNames: ReadonlySet | undefined; + let cursorSkills: ReadonlyMap | undefined; const resolveCursorSkillText = Effect.fn("CursorAdapterV2.resolveCursorSkillText")( function* (rawText: string, cwd: string | undefined) { - if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) { + if ( + cursorSkills === undefined && + (hasCursorSkillMention(rawText) || cursorSlashSkillNames(rawText).size > 0) + ) { const skills = yield* discoverCursorSkills(cwd, adapterOptions.environment).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); - cursorSkillNames = new Set( + cursorSkills = new Map( skills .filter((skill) => skill.enabled && skill.userInvocable !== false) - .map((skill) => skill.name), + .map((skill) => [skill.name, skill]), ); } - return cursorSkillNames === undefined - ? rawText - : rewriteCursorSkillMentions(rawText, cursorSkillNames); + if (cursorSkills === undefined) return rawText; + const skillsByName = cursorSkills; + const text = rewriteCursorSkillMentions(rawText, new Set(skillsByName.keys())); + const invoked = yield* Effect.forEach(cursorSlashSkillNames(text), (name) => { + const skill = skillsByName.get(name); + return skill?.userInvocationOnly === true + ? fileSystem.readFileString(skill.path).pipe( + Effect.map((contents) => [{ name, contents }]), + Effect.orElseSucceed(() => []), + ) + : Effect.succeed([]); + }); + return appendCursorInvokedSkills(text, invoked.flat()); }, ); const resolveUserMessage = Effect.fnUntraced(function* ( diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts index 5ef6888876036..e68fe23053eac 100644 --- a/packages/provider-cursor/src/server/skills.ts +++ b/packages/provider-cursor/src/server/skills.ts @@ -14,6 +14,7 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); +const SLASH_SKILL_PATTERN = /(?:^|\s)\/([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; const MAX_SKILL_DEPTH = 10; const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000); const MAX_SKILL_SCAN_ENTRIES = 10_000; @@ -305,3 +306,21 @@ export function rewriteCursorSkillMentions( skillNames.has(name) ? `${prefix}/${name}` : match, ); } + +/** Names invoked as `/name`, the form Cursor uses and `rewriteCursorSkillMentions` produces. */ +export function cursorSlashSkillNames(prompt: string): ReadonlySet { + return new Set(Array.from(prompt.matchAll(SLASH_SKILL_PATTERN), (match) => match[1] ?? "")); +} + +/** Cursor hides `disable-model-invocation` skills from the model, so the prompt carries them. */ +export function appendCursorInvokedSkills( + prompt: string, + skills: ReadonlyArray<{ readonly name: string; readonly contents: string }>, +): string { + if (skills.length === 0) return prompt; + const blocks = skills.map( + (skill) => + `\n${skill.contents.replace(FRONTMATTER_PATTERN, "").trim()}\n`, + ); + return `${prompt}\n\nThe user invoked these skills. Follow their instructions:\n\n${blocks.join("\n\n")}`; +}