Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { cwd } = yield* ServerConfig;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
Expand Down Expand Up @@ -132,7 +133,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
});
const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe(
const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv, cwd).pipe(
Comment thread
cursor[bot] marked this conversation as resolved.
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
100 changes: 100 additions & 0 deletions apps/server/src/provider/Drivers/CursorSkills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { discoverCursorSkills, renderCursorSkillInvocations } from "./CursorSkills.ts";

const writeSkill = Effect.fn(function* (
skillsDir: string,
relativeDirectory: string,
contents: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const skillDirectory = path.join(skillsDir, relativeDirectory);
yield* fileSystem.makeDirectory(skillDirectory, { recursive: true });
yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents);
});

const skill = (name: string, description = `Use ${name}.`) =>
["---", `name: ${name}`, `description: ${description}`, "---", "", `# ${name}`].join("\n");

it.layer(NodeServices.layer)("discoverCursorSkills", (it) => {
it.effect("discovers every documented direct user and project root", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" });
const home = path.join(tempDir, "home");
const workspace = path.join(tempDir, "workspace");
const rootNames = [".agents", ".cursor", ".claude", ".codex"] as const;

for (const rootName of rootNames) {
yield* writeSkill(
path.join(home, rootName, "skills"),
`user-${rootName.slice(1)}`,
skill(`user-${rootName.slice(1)}`),
);
yield* writeSkill(
path.join(workspace, rootName, "skills"),
`project-${rootName.slice(1)}`,
skill(`project-${rootName.slice(1)}`),
);
}

const skills = yield* discoverCursorSkills(workspace, { HOME: home });

assert.deepEqual(
skills.map((entry) => [entry.name, entry.scope]),
[
["project-agents", "project"],
["project-claude", "project"],
["project-codex", "project"],
["project-cursor", "project"],
["user-agents", "user"],
["user-claude", "user"],
["user-codex", "user"],
["user-cursor", "user"],
],
);
}),
);

it.effect("discovers nested skills and rejects invalid entries", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" });
const workspace = path.join(tempDir, "workspace");
const skillsDirectory = path.join(workspace, ".cursor", "skills");

yield* writeSkill(skillsDirectory, "shipping/deploy", skill("deploy"));
yield* writeSkill(skillsDirectory, "wrong-folder", skill("another-name"));
yield* writeSkill(
skillsDirectory,
"no-description",
["---", "name: no-description", "---"].join("\n"),
);

const skills = yield* discoverCursorSkills(workspace, { HOME: path.join(tempDir, "home") });

assert.deepEqual(
skills.map((entry) => entry.name),
["deploy"],
);
assert.equal(skills[0]?.path, path.join(skillsDirectory, "shipping", "deploy", "SKILL.md"));
}),
);
});

it("renders only known `$skill` tokens as Cursor skill invocations", () => {
assert.equal(
renderCursorSkillInvocations(
"$deploy, then $123-skill. Keep $unknown and $review!",
new Set(["deploy", "123-skill", "review"]),
),
"/deploy, then /123-skill. Keep $unknown and /review!",
);
});
139 changes: 139 additions & 0 deletions apps/server/src/provider/Drivers/CursorSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* CursorSkills — filesystem discovery for the Cursor `$` picker.
*
* Cursor Agent discovers skills from its own, Agent Skills, Claude, and Codex
* directories. T3 reads the same on-disk skills because Cursor ACP does not
* expose a skill catalogue.
*
* @module provider/Drivers/CursorSkills
*/
import * as NodeOS from "node:os";

import type { ServerProviderSkill } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { parse as parseYamlDocument } from "yaml";

type CursorSkillScope = "user" | "project";

const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
const SKILL_TOKEN_PATTERN = /(^|\s)\$([a-z0-9-]+)(?=\s|$|[^\w-])/g;

type SkillFrontmatter =
| { readonly kind: "malformed" }
| { readonly kind: "parsed"; readonly name: string; readonly description: string };

function parseSkillFrontmatter(contents: string): SkillFrontmatter {
const match = FRONTMATTER_PATTERN.exec(contents);
if (!match) {
return { kind: "malformed" };
}

let parsed: unknown;
try {
parsed = parseYamlDocument(match[1] ?? "");
} catch {
return { kind: "malformed" };
}
if (typeof parsed !== "object" || parsed === null) {
return { kind: "malformed" };
}

const record = parsed as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
if (!SKILL_NAME_PATTERN.test(name) || !description) {
return { kind: "malformed" };
}

return { kind: "parsed", name, description };
}

function isSkillFile(entry: string): boolean {
return entry === "SKILL.md" || entry.replaceAll("\\", "/").endsWith("/SKILL.md");
}

/**
* List skills from Cursor's documented user and project roots. The scan is
* best-effort so unreadable or malformed entries never affect provider state.
*/
export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* (
cwd?: string,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const homePath = environment?.HOME?.trim() || NodeOS.homedir();
const roots: ReadonlyArray<{ directory: string; scope: CursorSkillScope }> = [
{ directory: path.join(homePath, ".agents", "skills"), scope: "user" },
{ directory: path.join(homePath, ".cursor", "skills"), scope: "user" },
{ directory: path.join(homePath, ".claude", "skills"), scope: "user" },
{ directory: path.join(homePath, ".codex", "skills"), scope: "user" },
...(cwd
? [
{ directory: path.join(cwd, ".agents", "skills"), scope: "project" as const },
{ directory: path.join(cwd, ".cursor", "skills"), scope: "project" as const },
{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const },
{ directory: path.join(cwd, ".codex", "skills"), scope: "project" as const },
]
: []),
];

const skillsByName = new Map<string, ServerProviderSkill>();
for (const root of roots) {
const entries = yield* fileSystem
.readDirectory(root.directory, { recursive: true })
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));

for (const entry of [...entries].filter(isSkillFile).sort()) {
const skillPath = path.join(root.directory, entry);
const contents = yield* fileSystem
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) {
continue;
}

const frontmatter = parseSkillFrontmatter(contents);
if (frontmatter.kind === "malformed") {
continue;
}
if (frontmatter.name !== path.basename(path.dirname(skillPath))) {
continue;
}

skillsByName.set(frontmatter.name, {
name: frontmatter.name,
description: frontmatter.description,
path: skillPath,
enabled: true,
scope: root.scope,
});

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.

Home cwd mislabels user skills

Medium Severity

discoverCursorSkills always scans user roots under HOME, then project roots under cwd, with later entries winning. On packaged desktop, ServerConfig.cwd is the user home directory, so those paths are identical and every skill is overwritten with scope: "project". The picker then shows personal skills as project skills.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 37be043. Configure here.

}
}

return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
});

/**
* Cheap pre-check so sendTurn only pays for skill rediscovery when the input
* actually carries a `$token` worth translating.
*/
export function mayContainSkillToken(input: string): boolean {
return /\$[a-z0-9-]/.test(input);
}

/**
* T3 uses `$skill` for its shared picker. Cursor invokes selected skills with
* `/skill`, so translate only known skill tokens before the ACP prompt runs.
*/
export function renderCursorSkillInvocations(
input: string,
skillNames: ReadonlySet<string>,
): string {
return input.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, name: string) =>
skillNames.has(name) ? `${prefix}/${name}` : match,
);
}
100 changes: 100 additions & 0 deletions apps/server/src/provider/Layers/CursorAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,53 @@ const cursorAdapterTestLayer = it.layer(
);

cursorAdapterTestLayer("CursorAdapterLive", (it) => {
it.effect("sends selected Cursor skills as slash commands", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
const settings = yield* ServerSettingsService;
const threadId = ThreadId.make("cursor-skill-invocation");
const tempDir = yield* Effect.promise(() =>
NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-")),
);
const workspace = NodePath.join(tempDir, "workspace");
const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
const skillPath = NodePath.join(workspace, ".cursor", "skills", "deploy", "SKILL.md");
yield* Effect.promise(async () => {
await NodeFSP.mkdir(NodePath.dirname(skillPath), { recursive: true });
await NodeFSP.writeFile(
skillPath,
["---", "name: deploy", "description: Deploy the service.", "---"].join("\n"),
"utf8",
);
await NodeFSP.writeFile(requestLogPath, "", "utf8");
});
const wrapperPath = yield* Effect.promise(() =>
makeProbeWrapper(requestLogPath, NodePath.join(tempDir, "argv.txt")),
);
yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });

yield* adapter.startSession({
threadId,
provider: ProviderDriverKind.make("cursor"),
cwd: workspace,
runtimeMode: "full-access",
modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
});
yield* adapter.sendTurn({ threadId, input: "$deploy ship the release", attachments: [] });

const requests = yield* waitForJsonLogMatch(
requestLogPath,
(entry) => entry.method === "session/prompt",
);
const promptRequest = requests.find((entry) => entry.method === "session/prompt");
const prompt = (promptRequest?.params as { prompt?: Array<{ text?: string }> } | undefined)
?.prompt?.[0]?.text;
assert.equal(prompt, "/deploy ship the release");

yield* adapter.stopSession(threadId);
}),
);

it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
Expand Down Expand Up @@ -824,6 +871,59 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
}),
);

it.effect("rediscovers skills that appear after the session started", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
const settings = yield* ServerSettingsService;
const threadId = ThreadId.make("cursor-skill-late-discovery");
const tempDir = yield* Effect.promise(() =>
NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-late-")),
);
const workspace = NodePath.join(tempDir, "workspace");
const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
const skillPath = NodePath.join(workspace, ".cursor", "skills", "deploy", "SKILL.md");
yield* Effect.promise(async () => {
await NodeFSP.mkdir(workspace, { recursive: true });
await NodeFSP.writeFile(requestLogPath, "", "utf8");
});
const wrapperPath = yield* Effect.promise(() =>
makeProbeWrapper(requestLogPath, NodePath.join(tempDir, "argv.txt")),
);
yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });

yield* adapter.startSession({
threadId,
provider: ProviderDriverKind.make("cursor"),
cwd: workspace,
runtimeMode: "full-access",
modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
});

// The skill lands on disk after the session captured its skill set.
yield* Effect.promise(async () => {
await NodeFSP.mkdir(NodePath.dirname(skillPath), { recursive: true });
await NodeFSP.writeFile(
skillPath,
["---", "name: deploy", "description: Deploy the service.", "---"].join("\n"),
"utf8",
);
});

yield* adapter.sendTurn({ threadId, input: "$deploy ship the release", attachments: [] });

const requests = yield* waitForJsonLogMatch(
requestLogPath,
(entry) => entry.method === "session/prompt",
);
const promptRequest = requests.find((entry) => entry.method === "session/prompt");
const prompt = (promptRequest?.params as { prompt?: Array<{ text?: string }> } | undefined)
?.prompt?.[0]?.text;
assert.equal(prompt, "/deploy ship the release");

yield* adapter.stopSession(threadId);
}),
);

it.effect("segments assistant messages around ACP tool activity in full-access mode", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
Expand Down
Loading
Loading