Skip to content

Commit 17fa7e1

Browse files
committed
feat: Add custom skills support
- Load skill.md files from workspace, user, and cursor directories - Expose skills as /skill:<name> slash commands in sessions - Resolve skill templates when user invokes skill commands
1 parent 55452e5 commit 17fa7e1

5 files changed

Lines changed: 345 additions & 9 deletions

File tree

src/cursor-acp-agent.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ import {
4141
CustomSlashCommand,
4242
handleSlashCommand,
4343
loadCustomSlashCommands,
44+
resolveCustomSlashCommandPrompt,
45+
resolveSkillSlashCommandPrompt,
4446
} from "./slash-commands.js";
47+
import { CustomSkill, loadCustomSkills } from "./skills.js";
4548
import {
4649
parseLeadingSlashCommand,
4750
promptToCursorText,
@@ -63,6 +66,7 @@ interface SessionState {
6366
modeId: SessionModeId;
6467
modelId?: string;
6568
customCommands: CustomSlashCommand[];
69+
skills: CustomSkill[];
6670
cancelled: boolean;
6771
activeRun?: {
6872
cancel: () => void;
@@ -192,7 +196,7 @@ export class CursorAcpAgent implements Agent {
192196
}
193197

194198
session.cancelled = false;
195-
const promptText = promptToCursorText(params);
199+
let promptText = promptToCursorText(params);
196200

197201
const slash = parseLeadingSlashCommand(promptText);
198202
if (slash.hasSlash) {
@@ -201,6 +205,7 @@ export class CursorAcpAgent implements Agent {
201205
auth: this.auth,
202206
listModels: async () => await this.runner.listModels(),
203207
customCommands: session.customCommands,
208+
skills: session.skills,
204209
onModeChanged: async (modeId) => {
205210
await this.client.sessionUpdate({
206211
sessionId: session.sessionId,
@@ -236,6 +241,24 @@ export class CursorAcpAgent implements Agent {
236241

237242
return { stopReason: "end_turn" };
238243
}
244+
245+
const skillPrompt = resolveSkillSlashCommandPrompt(
246+
slash.command,
247+
session.skills,
248+
);
249+
if (skillPrompt) {
250+
const extra = slash.args.trim();
251+
promptText = extra ? `${skillPrompt}\n\n${extra}` : skillPrompt;
252+
} else {
253+
const customPrompt = resolveCustomSlashCommandPrompt(
254+
slash.command,
255+
slash.args,
256+
session.customCommands,
257+
);
258+
if (customPrompt) {
259+
promptText = customPrompt;
260+
}
261+
}
239262
}
240263

241264
const firstAttempt = await this.runPromptAttempt(
@@ -346,6 +369,7 @@ export class CursorAcpAgent implements Agent {
346369
modeId,
347370
modelId,
348371
customCommands: [],
372+
skills: [],
349373
cancelled: false,
350374
};
351375

@@ -362,16 +386,11 @@ export class CursorAcpAgent implements Agent {
362386

363387
const models = await this.getAvailableModels(session);
364388
session.customCommands = await this.getAvailableSlashCommands(session.cwd);
389+
session.skills = await this.getAvailableSkills(session.cwd);
365390
this.sessions[session.sessionId] = session;
366391

367392
setTimeout(() => {
368-
void this.client.sessionUpdate({
369-
sessionId: session.sessionId,
370-
update: {
371-
sessionUpdate: "available_commands_update",
372-
availableCommands: availableSlashCommands(session.customCommands),
373-
},
374-
});
393+
void this.emitAvailableCommands(session);
375394
}, 0);
376395

377396
return {
@@ -422,6 +441,29 @@ export class CursorAcpAgent implements Agent {
422441
}
423442
}
424443

444+
private async getAvailableSkills(workspace: string): Promise<CustomSkill[]> {
445+
try {
446+
const skills = await loadCustomSkills(workspace);
447+
return skills.filter((skill) => skill.origin === "user");
448+
} catch (error) {
449+
this.logger.error("[cursor-acp] Unable to load skills", error);
450+
return [];
451+
}
452+
}
453+
454+
private async emitAvailableCommands(session: SessionState): Promise<void> {
455+
await this.client.sessionUpdate({
456+
sessionId: session.sessionId,
457+
update: {
458+
sessionUpdate: "available_commands_update",
459+
availableCommands: availableSlashCommands(
460+
session.customCommands,
461+
session.skills,
462+
),
463+
},
464+
});
465+
}
466+
425467
private modeToRunnerOptions(
426468
session: SessionState,
427469
forceRetry: boolean,

src/skills.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { promises as fs } from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
5+
export type SkillOrigin = "workspace" | "user" | "cursor";
6+
7+
export interface CustomSkill {
8+
name: string;
9+
description: string;
10+
template: string;
11+
sourcePath: string;
12+
origin: SkillOrigin;
13+
}
14+
15+
async function collectSkillFiles(dir: string): Promise<string[]> {
16+
let entries: import("node:fs").Dirent[];
17+
try {
18+
entries = await fs.readdir(dir, { withFileTypes: true, encoding: "utf8" });
19+
} catch (error: unknown) {
20+
if (
21+
typeof error === "object" &&
22+
error !== null &&
23+
"code" in error &&
24+
(error as { code?: string }).code === "ENOENT"
25+
) {
26+
return [];
27+
}
28+
throw error;
29+
}
30+
31+
entries.sort((a, b) => a.name.localeCompare(b.name));
32+
33+
const files: string[] = [];
34+
for (const entry of entries) {
35+
const fullPath = path.join(dir, entry.name);
36+
if (entry.isDirectory()) {
37+
files.push(...(await collectSkillFiles(fullPath)));
38+
continue;
39+
}
40+
41+
if (entry.isFile() && entry.name.toLowerCase() === "skill.md") {
42+
files.push(fullPath);
43+
}
44+
}
45+
46+
return files;
47+
}
48+
49+
function parseFrontmatter(markdown: string): {
50+
metadata: Record<string, string>;
51+
body: string;
52+
} {
53+
const lines = markdown.split(/\r?\n/);
54+
if (lines[0]?.trim() !== "---") {
55+
return { metadata: {}, body: markdown };
56+
}
57+
58+
let end = -1;
59+
for (let i = 1; i < lines.length; i += 1) {
60+
if (lines[i]?.trim() === "---") {
61+
end = i;
62+
break;
63+
}
64+
}
65+
66+
if (end === -1) {
67+
return { metadata: {}, body: markdown };
68+
}
69+
70+
const metadata: Record<string, string> = {};
71+
for (const line of lines.slice(1, end)) {
72+
const delimiter = line.indexOf(":");
73+
if (delimiter <= 0) {
74+
continue;
75+
}
76+
const key = line.slice(0, delimiter).trim().toLowerCase();
77+
const value = line.slice(delimiter + 1).trim();
78+
if (key && value) {
79+
metadata[key] = value;
80+
}
81+
}
82+
83+
return {
84+
metadata,
85+
body: lines.slice(end + 1).join("\n"),
86+
};
87+
}
88+
89+
function firstHeading(markdown: string): string | undefined {
90+
const match = markdown.match(/^\s*#\s+(.+)$/m);
91+
if (!match?.[1]) {
92+
return undefined;
93+
}
94+
const heading = match[1].trim();
95+
return heading.length > 0 ? heading : undefined;
96+
}
97+
98+
async function readSkill(
99+
filePath: string,
100+
origin: SkillOrigin,
101+
): Promise<CustomSkill | null> {
102+
const raw = await fs.readFile(filePath, "utf8");
103+
const { metadata, body } = parseFrontmatter(raw);
104+
const template = body.trim();
105+
if (!template) {
106+
return null;
107+
}
108+
109+
const heading = firstHeading(body);
110+
const dirName = path.basename(path.dirname(filePath)).trim();
111+
const name = metadata.name?.trim() || heading || dirName;
112+
if (!name) {
113+
return null;
114+
}
115+
116+
const description =
117+
metadata.description?.trim() ||
118+
(heading && heading !== name ? heading : undefined) ||
119+
`Skill from ${dirName || path.basename(filePath)}`;
120+
121+
return {
122+
name,
123+
description,
124+
template,
125+
sourcePath: filePath,
126+
origin,
127+
};
128+
}
129+
130+
export async function loadCustomSkills(
131+
workspace: string,
132+
homeDirectory: string = os.homedir(),
133+
): Promise<CustomSkill[]> {
134+
const skillRoots: Array<{ root: string; origin: SkillOrigin }> = [
135+
{ root: path.join(workspace, ".cursor", "skills"), origin: "workspace" },
136+
{ root: path.join(homeDirectory, ".cursor", "skills"), origin: "user" },
137+
{
138+
root: path.join(homeDirectory, ".cursor", "skills-cursor"),
139+
origin: "cursor",
140+
},
141+
];
142+
143+
const byName = new Map<string, CustomSkill>();
144+
for (const { root, origin } of skillRoots) {
145+
const files = await collectSkillFiles(root);
146+
for (const file of files) {
147+
const skill = await readSkill(file, origin);
148+
if (!skill) {
149+
continue;
150+
}
151+
const key = skill.name.toLowerCase();
152+
if (!byName.has(key)) {
153+
byName.set(key, skill);
154+
}
155+
}
156+
}
157+
158+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
159+
}
160+
161+
export function resolveSkillPrompt(
162+
commandName: string,
163+
skills: CustomSkill[],
164+
): string | null {
165+
const normalized = commandName.toLowerCase();
166+
const stripped = normalized.startsWith("skill:")
167+
? normalized.slice("skill:".length)
168+
: normalized.startsWith("skills:")
169+
? normalized.slice("skills:".length)
170+
: normalized.startsWith("skills/")
171+
? normalized.slice("skills/".length)
172+
: normalized;
173+
const match = skills.find((skill) => skill.name.toLowerCase() === stripped);
174+
return match?.template ?? null;
175+
}

src/slash-commands.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { CursorAuthClient } from "./auth.js";
66
import { SessionModeId, SUPPORTED_MODE_IDS } from "./settings.js";
7+
import { CustomSkill, resolveSkillPrompt } from "./skills.js";
78

89
export interface CursorModelDescriptor {
910
modelId: string;
@@ -29,6 +30,7 @@ export interface SlashCommandContext {
2930
auth: CursorAuthClient;
3031
listModels: () => Promise<CursorModelDescriptor[]>;
3132
customCommands?: CustomSlashCommand[];
33+
skills?: CustomSkill[];
3234
onModeChanged?: (modeId: SessionModeId) => Promise<void>;
3335
}
3436

@@ -56,6 +58,7 @@ const BUILTIN_SLASH_COMMANDS: AvailableCommand[] = [
5658

5759
export function availableSlashCommands(
5860
customCommands: CustomSlashCommand[] = [],
61+
skills: CustomSkill[] = [],
5962
): AvailableCommand[] {
6063
const deduped = new Map<string, AvailableCommand>();
6164
for (const command of BUILTIN_SLASH_COMMANDS) {
@@ -74,9 +77,26 @@ export function availableSlashCommands(
7477
});
7578
}
7679

80+
for (const skill of skills) {
81+
const name = skillCommandName(skill.name);
82+
const key = name.toLowerCase();
83+
if (deduped.has(key)) {
84+
continue;
85+
}
86+
deduped.set(key, {
87+
name,
88+
description: skill.description,
89+
input: null,
90+
});
91+
}
92+
7793
return [...deduped.values()];
7894
}
7995

96+
function skillCommandName(skillName: string): string {
97+
return `skill:${skillName}`;
98+
}
99+
80100
async function collectMarkdownFiles(dir: string): Promise<string[]> {
81101
let entries: import("node:fs").Dirent[];
82102
try {
@@ -270,6 +290,13 @@ export function resolveCustomSlashCommandPrompt(
270290
return applyCustomCommandArgs(command.template, args);
271291
}
272292

293+
export function resolveSkillSlashCommandPrompt(
294+
commandName: string,
295+
skills: CustomSkill[],
296+
): string | null {
297+
return resolveSkillPrompt(commandName, skills);
298+
}
299+
273300
export function builtInSlashCommandNames(): string[] {
274301
return [
275302
...BUILTIN_SLASH_COMMANDS.map((command) =>
@@ -337,6 +364,11 @@ export async function handleSlashCommand(
337364
)
338365
.join(", ")}`
339366
: null,
367+
context.skills?.length
368+
? `Skills: ${context.skills
369+
.map((skill) => `/${skillCommandName(skill.name)}`)
370+
.join(", ")}`
371+
: null,
340372
]
341373
.filter(Boolean)
342374
.join("\n"),

src/tests/cursor-acp-agent.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,9 @@ describe("CursorAcpAgent", () => {
411411
} as any);
412412

413413
expect(response.stopReason).toBe("end_turn");
414-
expect(promptText).toBe("/commit feat(parser)");
414+
expect(promptText).toBe(
415+
"Write a concise conventional commit message.\nScope: feat(parser)",
416+
);
415417
} finally {
416418
await rm(tempRoot, { recursive: true, force: true });
417419
}

0 commit comments

Comments
 (0)