Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
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);
Expand All @@ -132,7 +133,11 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
});
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),
Expand Down
160 changes: 160 additions & 0 deletions apps/server/src/provider/Layers/CursorAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1429,4 +1429,164 @@ 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/);
}),
);

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));
});
});
});
18 changes: 17 additions & 1 deletion apps/server/src/provider/Layers/CursorAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -962,7 +963,22 @@ export function makeCursorAdapter(

const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
if (input.input?.trim()) {
promptParts.push({ type: "text", text: input.input.trim() });
// Codex-style `$name` is inert under Cursor ACP. Rediscover FS skills
// 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),
Effect.orElseSucceed(() => [] as const),
);
const promptText = applyCursorSkillMentions(input.input.trim(), discoveredSkills);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scans skills on every send

Medium Severity

Every sendTurn with non-empty text unconditionally calls discoverCursorSkills, scanning project and user skill directories. This occurs even when no $ skill mention is present in the input, adding unnecessary filesystem I/O and latency to most turns.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7678e7b. Configure here.

promptParts.push({ type: "text", text: promptText });
}
if (input.attachments && input.attachments.length > 0) {
for (const attachment of input.attachments) {
Expand Down
117 changes: 111 additions & 6 deletions apps/server/src/provider/Layers/CursorProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
});
});

Expand Down Expand Up @@ -430,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({
Expand All @@ -457,6 +492,7 @@ describe("checkCursorProviderStatus", () => {
apiEndpoint: "",
customModels: [],
},
process.cwd(),
{
...process.env,
T3_ACP_REQUEST_LOG_PATH: requestLogPath,
Expand All @@ -472,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", () => {
Expand Down
Loading
Loading