import type { Dirent } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext, Skill, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { arePiToolsDisabled, isCursorModel, registerCursorModelLifecycle, type CursorModelLifecycleExtensionApi, } from "./model.js"; import { resolveCursorPiToolBridgeConfig } from "./pi-tool-bridge-config.js"; export const CURSOR_ACTIVATE_SKILL_TOOL_NAME = "cursor_activate_skill"; export const CURSOR_ACTIVATE_SKILL_MCP_NAME = "pi__cursor_activate_skill"; const AVAILABLE_SKILLS_SECTION_PATTERN = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/; const MAX_SKILL_RESOURCES = 80; const RESOURCE_DIR_NAMES = ["scripts", "references", "assets"] as const; type CursorSkillToolExtensionApi = Pick & CursorModelLifecycleExtensionApi; type CursorActivateSkillParams = { name?: string; }; interface CursorSkillActivationDetails { name?: string; filePath?: string; baseDir?: string; resources: string[]; availableSkillNames: string[]; } let currentSkillsByName = new Map(); function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function getVisibleSkills(skills: readonly Skill[] | undefined): Skill[] { return (skills ?? []).filter((skill) => !skill.disableModelInvocation); } function setCurrentSkills(skills: readonly Skill[] | undefined): void { currentSkillsByName = new Map(getVisibleSkills(skills).map((skill) => [skill.name, skill])); } function getAvailableSkillNames(): string[] { return [...currentSkillsByName.keys()].sort(); } function shouldExposeSkillTool(model: ExtensionContext["model"]): boolean { return isCursorModel(model) && resolveCursorPiToolBridgeConfig().enabled && currentSkillsByName.size > 0; } function syncCursorSkillToolForModel( pi: Pick, model: ExtensionContext["model"], ): void { const activeToolNames = new Set(pi.getActiveTools()); const shouldBeActive = !arePiToolsDisabled(pi) && shouldExposeSkillTool(model); const alreadyActive = activeToolNames.has(CURSOR_ACTIVATE_SKILL_TOOL_NAME); if (shouldBeActive === alreadyActive) return; if (shouldBeActive) { activeToolNames.add(CURSOR_ACTIVATE_SKILL_TOOL_NAME); } else { activeToolNames.delete(CURSOR_ACTIVATE_SKILL_TOOL_NAME); } pi.setActiveTools([...activeToolNames]); } export function formatCursorSkillsForPrompt(skills: readonly Skill[]): string { const visibleSkills = getVisibleSkills(skills); if (visibleSkills.length === 0) return ""; const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", `When a task matches a skill's description, call ${CURSOR_ACTIVATE_SKILL_MCP_NAME} with the skill name to load its full SKILL.md instructions before proceeding.`, "If the pi bridge is disabled and the activation tool is unavailable, use Cursor's file-read capability on the listed SKILL.md location instead.", "When a skill references relative paths, resolve them against the skill directory (the parent of SKILL.md / dirname of the path) and use absolute paths in tool calls.", "", "", ]; for (const skill of visibleSkills) { lines.push(" "); lines.push(` ${escapeXml(skill.name)}`); lines.push(` ${escapeXml(skill.description)}`); lines.push(` ${escapeXml(skill.filePath)}`); lines.push(" "); } lines.push(""); return lines.join("\n"); } export function resolveCursorSkillSystemPrompt( systemPrompt: string, model: ExtensionContext["model"], systemPromptOptions?: BuildSystemPromptOptions, ): string { if (!isCursorModel(model)) return systemPrompt; const skills = getVisibleSkills(systemPromptOptions?.skills); if (skills.length === 0) return systemPrompt; const replacement = formatCursorSkillsForPrompt(skills); if (AVAILABLE_SKILLS_SECTION_PATTERN.test(systemPrompt)) { return systemPrompt.replace(AVAILABLE_SKILLS_SECTION_PATTERN, replacement); } return `${systemPrompt}${replacement}`; } async function collectResourcePaths(root: string, absoluteDir: string, output: string[]): Promise { if (output.length >= MAX_SKILL_RESOURCES) return; let entries: Dirent[]; try { entries = await readdir(absoluteDir, { withFileTypes: true }); } catch { return; } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (output.length >= MAX_SKILL_RESOURCES) return; const absolutePath = join(absoluteDir, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { await collectResourcePaths(root, absolutePath, output); continue; } if (!entry.isFile()) continue; output.push(relative(root, absolutePath).replace(/\\/g, "/")); } } async function listSkillResourcePaths(baseDir: string): Promise { const resources: string[] = []; for (const resourceDirName of RESOURCE_DIR_NAMES) { await collectResourcePaths(baseDir, join(baseDir, resourceDirName), resources); if (resources.length >= MAX_SKILL_RESOURCES) break; } return resources; } function buildActivationDetails(skill: Skill | undefined, resources: string[] = []): CursorSkillActivationDetails { return { name: skill?.name, filePath: skill?.filePath, baseDir: skill ? dirname(skill.filePath) : undefined, resources, availableSkillNames: getAvailableSkillNames(), }; } function formatSkillResources(resources: readonly string[]): string { if (resources.length === 0) return ""; return [ "", ...resources.map((resource) => ` ${escapeXml(resource)}`), "", ].join("\n"); } function wrapSkillContent(skill: Skill, content: string, resources: readonly string[]): string { const baseDir = dirname(skill.filePath); return [ ``, content.trim(), "", `Skill directory: ${baseDir}`, "Relative paths in this skill are relative to the skill directory.", formatSkillResources(resources), "", ].join("\n"); } export function registerCursorSkillTool(pi: CursorSkillToolExtensionApi): void { pi.registerTool({ name: CURSOR_ACTIVATE_SKILL_TOOL_NAME, label: "Cursor skill", description: "Load full pi Agent Skill instructions for Cursor. Use with a skill name from the current catalog before applying that skill.", promptSnippet: "Load full pi Agent Skill instructions for a listed skill before Cursor applies that skill", parameters: Type.Object({ name: Type.String({ description: "Skill name from the current catalog" }), }), promptGuidelines: [ `Use ${CURSOR_ACTIVATE_SKILL_TOOL_NAME} only for skill names listed in the current catalog.`, "After loading a skill, follow its instructions and resolve relative skill paths against the returned skill directory.", ], async execute(_toolCallId, params) { const requestedName = (params as CursorActivateSkillParams).name?.trim(); if (!requestedName) { throw new Error("No skill name was provided."); } const skill = currentSkillsByName.get(requestedName); if (!skill) { throw new Error( `Skill not available: ${requestedName}. Available skills: ${getAvailableSkillNames().join(", ") || "none"}.`, ); } try { const [content, resources] = await Promise.all([ readFile(skill.filePath, "utf8"), listSkillResourcePaths(dirname(skill.filePath)), ]); return { content: [{ type: "text" as const, text: wrapSkillContent(skill, content, resources) }], details: buildActivationDetails(skill, resources), }; } catch (error) { throw new Error( `Failed to load skill ${requestedName} from ${skill.filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } }, }); const clearSkillsAndSync = (model: ExtensionContext["model"]): void => { setCurrentSkills([]); syncCursorSkillToolForModel(pi, model); }; registerCursorModelLifecycle(pi, { sessionStart: (_event, ctx) => { clearSkillsAndSync(ctx.model); }, modelSelect: (event) => { clearSkillsAndSync(event.model); }, turnStart: (_event, ctx) => { if (!isCursorModel(ctx.model)) setCurrentSkills([]); syncCursorSkillToolForModel(pi, ctx.model); }, beforeAgentStart: (event, ctx) => { if (isCursorModel(ctx.model)) { setCurrentSkills(event.systemPromptOptions?.skills); } else { setCurrentSkills([]); } syncCursorSkillToolForModel(pi, ctx.model); const resolved = resolveCursorSkillSystemPrompt(event.systemPrompt, ctx.model, event.systemPromptOptions); if (resolved === event.systemPrompt) return undefined; return { systemPrompt: resolved }; }, }); }