1/**
2 * CursorSkills — workspace-aware discovery and native invocation for Cursor.
3 *
4 * Cursor discovers Agent Skills recursively from user and project roots but
5 * its ACP command catalog only appears after opening a real session. Scanning
6 * the same roots avoids starting an agent and its MCP servers just to populate
7 * a composer menu.
8 *
9 * @module provider/Drivers/CursorSkills
10 */
11
12import type { ServerProviderSkill } from "@t3tools/contracts";
13import * as ByteSize from "effect/ByteSize";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
18import * as Schema from "effect/Schema";
19import { parse as parseYamlDocument } from "yaml";
20import * as HostProcess from "@t3tools/shared/HostProcess";
21
22const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
23const SKILL_MENTION_PATTERN =
24 /(^|\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;
25const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u");
26const MAX_SKILL_DEPTH = 10;
27const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000);
28const MAX_SKILL_SCAN_ENTRIES = 10_000;
29const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000);
30
31interface CursorSkillFrontmatter {
32 readonly description?: string;
33 readonly displayName?: string;
34 readonly userInvocationOnly?: boolean;
35 readonly userInvocable?: boolean;
36 readonly cliVisible: boolean;
37}
38
39interface CursorSkillScanBudget {
40 remainingEntries: number;
41 remainingBytes: bigint;
42 exhausted: boolean;
43 incomplete: boolean;
44}
45
46class CursorSkillsProbeError extends Schema.TaggedError<CursorSkillsProbeError>()(
47 "CursorSkillsProbeError",
48 {
49 reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]),
50 cwd: Schema.optional(Schema.String),
51 },
52) {
53 override get message(): string {
54 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
55 return `Cursor skill discovery${location} was incomplete (${this.reason}).`;
56 }
57}
58
59const orUndefined = <A, R>(
60 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
61 budget?: CursorSkillScanBudget,
62): Effect.Effect<A | undefined, never, R> =>
63 effect.pipe(
64 Effect.map((value): A | undefined => value),
65 Effect.catchTags({
66 PlatformError: (error) => {
67 if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true;
68 return Effect.void.pipe(Effect.as(undefined));
69 },
70 }),
71 );
72
73function parseFrontmatterBoolean(value: unknown): boolean | undefined {
74 if (typeof value === "boolean") return value;
75 if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined;
76 if (typeof value !== "string") return undefined;
77 switch (value.trim().toLowerCase()) {
78 case "true":
79 case "yes":
80 case "on":
81 return true;
82 case "false":
83 case "no":
84 case "off":
85 return false;
86 default:
87 return undefined;
88 }
89}
90
91function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined {
92 const match = FRONTMATTER_PATTERN.exec(contents);
93 if (!match) return { cliVisible: true };
94
95 let parsed: unknown;
96 try {
97 parsed = parseYamlDocument(match[1] ?? "");
98 } catch {
99 return undefined;
100 }
101 if (typeof parsed !== "object" || parsed === null) return undefined;
102
103 const record = parsed as Record<string, unknown>;
104 const metadata =
105 typeof record.metadata === "object" && record.metadata !== null
106 ? (record.metadata as Record<string, unknown>)
107 : undefined;
108 const rawSurfaces = metadata?.surfaces;
109 const surfaces = Array.isArray(rawSurfaces)
110 ? rawSurfaces.filter((surface): surface is string => typeof surface === "string")
111 : typeof rawSurfaces === "string"
112 ? rawSurfaces.split(",")
113 : [];
114 const description = typeof record.description === "string" ? record.description.trim() : "";
115 const displayName = typeof record.name === "string" ? record.name.trim() : "";
116 return {
117 cliVisible:
118 surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"),
119 ...(description ? { description } : {}),
120 ...(displayName ? { displayName } : {}),
121 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
122 ? { userInvocationOnly: true }
123 : {}),
124 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
125 ? { userInvocable: false }
126 : {}),
127 };
128}
129
130const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: {
131 readonly directory: string;
132 readonly scope: "user" | "project";
133 readonly budget: CursorSkillScanBudget;
134}): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
135 const fileSystem = yield* FileSystem.FileSystem;
136 const path = yield* Path.Path;
137 const skills: ServerProviderSkill[] = [];
138 if (input.budget.exhausted) return skills;
139 const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget);
140 if (!rootDirectory) return skills;
141 const visitedDirectories = new Set<string>();
142
143 const visit = Effect.fn("visitCursorSkillDirectory")(function* (
144 directory: string,
145 depth: number,
146 ): Effect.fn.Return<void, never> {
147 if (input.budget.exhausted) return;
148 const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget);
149 if (!resolvedDirectory) {
150 return;
151 }
152 if (visitedDirectories.has(resolvedDirectory)) {
153 return;
154 }
155 visitedDirectories.add(resolvedDirectory);
156 // A symlink whose target lives outside the root is a skill package
157 // boundary: read its own SKILL.md so linked skill libraries show up, but
158 // never walk the target tree.
159 const insideRoot =
160 resolvedDirectory === rootDirectory ||
161 resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`);
162
163 const skillPath = path.join(directory, "SKILL.md");
164 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);
165 if (skillInfo?.type === "File") {
166 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };
167 if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) {
168 const contents = yield* orUndefined(fileSystem.readFileString(skillPath));
169 if (contents !== undefined) {
170 input.budget.remainingBytes -= skillInfo.size;
171 frontmatter = parseSkillFrontmatter(contents);
172 }
173 }
174 const name = path.basename(directory).trim();
175 if (frontmatter?.cliVisible && name) {
176 skills.push({
177 name,
178 path: skillPath,
179 scope: input.scope,
180 enabled: true,
181 ...(frontmatter.displayName && frontmatter.displayName !== name
182 ? { displayName: frontmatter.displayName }
183 : {}),
184 ...(frontmatter.description ? { description: frontmatter.description } : {}),
185 ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}),
186 ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}),
187 });
188 }
189 }
190
191 if (!insideRoot) {
192 return;
193 }
194 const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget);
195 if (!entries) {
196 return;
197 }
198 for (const entry of [...entries].sort()) {
199 if (input.budget.remainingEntries === 0) {
200 input.budget.exhausted = true;
201 return;
202 }
203 input.budget.remainingEntries -= 1;
204 const child = path.join(directory, entry);
205 const info = yield* orUndefined(fileSystem.stat(child), input.budget);
206 if (info?.type !== "Directory") continue;
207 // A tree deeper than the limit is skipped rather than fatal: the same tree would fail
208 // every refresh and hide every other skill with it.
209 if (depth >= MAX_SKILL_DEPTH) continue;
210 yield* visit(child, depth + 1);
211 }
212 });
213
214 yield* visit(rootDirectory, 0);
215 return skills;
216});
217
218const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* (
219 cwd?: string,
220 environment: NodeJS.ProcessEnv = process.env,
221) {
222 const path = yield* Path.Path;
223 const userHome =
224 environment.HOME?.trim() ||
225 environment.USERPROFILE?.trim() ||
226 (yield* HostProcess.HomeDirectory);
227 const rootsBelow = (base: string, scope: "user" | "project") => [
228 { directory: path.join(base, ".cursor", "skills"), scope },
229 { directory: path.join(base, ".agents", "skills"), scope },
230 { directory: path.join(base, ".codex", "skills"), scope },
231 { directory: path.join(base, ".claude", "skills"), scope },
232 ];
233 const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")];
234
235 const skillsByName = new Map<string, ServerProviderSkill>();
236 const budget: CursorSkillScanBudget = {
237 remainingEntries: MAX_SKILL_SCAN_ENTRIES,
238 remainingBytes: MAX_SKILL_SCAN_BYTES,
239 exhausted: false,
240 incomplete: false,
241 };
242 for (const root of roots) {
243 if (budget.exhausted) break;
244 const skills = yield* discoverSkillsInRoot({ ...root, budget });
245 for (const skill of skills) {
246 if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill);
247 }
248 }
249 return {
250 skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)),
251 failureReason: budget.exhausted
252 ? ("scan-budget-exhausted" as const)
253 : budget.incomplete
254 ? ("filesystem-error" as const)
255 : undefined,
256 };
257});
258
259export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* (
260 cwd?: string,
261 environment: NodeJS.ProcessEnv = process.env,
262) {
263 return (yield* inspectCursorSkills(cwd, environment)).skills;
264});
265
266export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* (
267 cwd?: string,
268 environment: NodeJS.ProcessEnv = process.env,
269) {
270 const inspection = yield* inspectCursorSkills(cwd, environment);
271 if (inspection.failureReason) {
272 return yield* new CursorSkillsProbeError({
273 reason: inspection.failureReason,
274 ...(cwd ? { cwd } : {}),
275 });
276 }
277 return inspection.skills;
278});
279
280/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */
281export function hasCursorSkillMention(prompt: string): boolean {
282 return HAS_SKILL_MENTION_PATTERN.test(prompt);
283}
284
285export function rewriteCursorSkillMentions(
286 prompt: string,
287 skillNames: ReadonlySet<string>,
288): string {
289 return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) =>
290 skillNames.has(name) ? `${prefix}/${name}` : match,
291 );
292}