diff --git a/lib/adapter-transforms.js b/lib/adapter-transforms.js index e62e573..895e074 100644 --- a/lib/adapter-transforms.js +++ b/lib/adapter-transforms.js @@ -297,10 +297,375 @@ function transformForCodex(content, options) { return content; } +/** + * Transform content for Cursor (.mdc rule files). + * + * MDC format uses YAML frontmatter with `description`, `globs`, and + * `alwaysApply` fields followed by a markdown body. + * + * @param {string} content - Source markdown content (may have frontmatter) + * @param {Object} options + * @param {string} options.description - Rule description + * @param {string} options.pluginInstallPath - Absolute path to plugin install dir + * @param {string} [options.globs] - Optional glob pattern for file matching + * @param {boolean} [options.alwaysApply] - Whether rule always applies (default true) + * @returns {string} Transformed MDC content + */ +function transformRuleForCursor(content, options) { + const { description = '', pluginInstallPath, globs = '', alwaysApply = true } = options; + + // Strip control characters and escape description for YAML + const cleanDescription = description.replace(/[\x00-\x1f\x7f]/g, ' '); + const escapedDescription = cleanDescription.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const yamlDescription = `"${escapedDescription}"`; + + // Build MDC frontmatter + let frontmatter = `---\ndescription: ${yamlDescription}\n`; + if (globs) { + frontmatter += `globs: ${JSON.stringify(globs)}\n`; + } + frontmatter += `alwaysApply: ${alwaysApply}\n---\n`; + + // Strip existing frontmatter if present + if (content.startsWith('---')) { + content = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); + } + + content = frontmatter + content; + + // Replace PLUGIN_ROOT paths with actual install path + // Use function replacement to avoid $ pattern interpretation in replacement string + content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath); + content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath); + + // Strip Claude-specific syntax: Task tool calls (handles one level of nested braces) + content = content.replace(/await\s+Task\s*\(\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\s*\);?/g, (match) => { + const agentMatch = match.match(/subagent_type:\s*["'](?:[^"':]+:)?([^"']+)["']/); + if (agentMatch) { + return `Invoke the ${agentMatch[1]} agent`; + } + return ''; + }); + + // Strip require() statements + content = content.replace(/(?:const|let|var)\s+\{?[^}=\n]+\}?\s*=\s*require\s*\([^)]+\);?/g, ''); + content = content.replace(/require\s*\(['"][^'"]+['"]\)/g, ''); + + // Strip plugin namespacing (e.g. next-task:agent-name -> agent-name) + content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1'); + + return content; +} + +/** + * Transform skill content for Cursor. + * + * Minimal transform - Cursor reads SKILL.md frontmatter natively so we + * preserve it. Only replaces PLUGIN_ROOT paths and strips namespace prefixes. + * + * @param {string} content - Source SKILL.md content + * @param {Object} options + * @param {string} options.pluginInstallPath - Absolute path to plugin install dir + * @returns {string} Transformed skill content + */ +function transformSkillForCursor(content, options) { + const { pluginInstallPath } = options; + + // Replace PLUGIN_ROOT paths with actual install path + content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath); + content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath); + + // Strip plugin namespacing (e.g. next-task:agent-name -> agent-name) + content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1'); + + return content; +} + +/** + * Transform command content for Cursor. + * + * Light transform - strips frontmatter, replaces PLUGIN_ROOT paths, + * removes require() statements and Task() calls, strips namespace prefixes. + * + * @param {string} content - Source command markdown content + * @param {Object} options + * @param {string} options.pluginInstallPath - Absolute path to plugin install dir + * @returns {string} Transformed command content + */ +function transformCommandForCursor(content, options) { + const { pluginInstallPath } = options; + + // Strip existing frontmatter if present + if (content.startsWith('---')) { + content = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); + } + + // Replace PLUGIN_ROOT paths with actual install path + content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath); + content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath); + + // Strip require() statements + content = content.replace(/(?:const|let|var)\s+\{?[^}=\n]+\}?\s*=\s*require\s*\([^)]+\);?/g, ''); + content = content.replace(/require\s*\(['"][^'"]+['"]\)/g, ''); + + // Strip Claude-specific syntax: Task tool calls (handles one level of nested braces) + content = content.replace(/await\s+Task\s*\(\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\s*\);?/g, (match) => { + const agentMatch = match.match(/subagent_type:\s*["'](?:[^"':]+:)?([^"']+)["']/); + if (agentMatch) { + return `Invoke the ${agentMatch[1]} agent`; + } + return ''; + }); + + // Strip plugin namespacing (e.g. next-task:agent-name -> agent-name) + content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1'); + + return content; +} + +/** + * Transform skill content for Kiro. + * + * Minimal transform - Kiro reads standard SKILL.md format natively so we + * preserve it. Only replaces PLUGIN_ROOT paths and strips namespace prefixes. + * + * @param {string} content - Source SKILL.md content + * @param {Object} options + * @param {string} options.pluginInstallPath - Absolute path to plugin install dir + * @returns {string} Transformed skill content + */ +function transformSkillForKiro(content, options) { + const { pluginInstallPath } = options; + + content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath); + content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath); + + content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1'); + + return content; +} + +/** + * Transform command content for Kiro prompt files. + * + * Strips existing frontmatter, prepends inclusion: manual frontmatter, + * replaces PLUGIN_ROOT paths, removes require()/Task() calls, strips namespaces. + */ +function transformCommandForKiro(content, options) { + const { pluginInstallPath, name = '', description = '' } = options; + + if (content.startsWith('---')) { + content = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); + } + + const cleanDescription = description.replace(/[\x00-\x1f\x7f]/g, ' '); + const escapedDescription = cleanDescription.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + let frontmatter = '---\n'; + frontmatter += 'inclusion: manual\n'; + if (name) frontmatter += `name: "${name}"\n`; + if (description) frontmatter += `description: "${escapedDescription}"\n`; + frontmatter += '---\n'; + + content = frontmatter + content; + + content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath); + content = content.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath); + content = content.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath); + + content = content.replace(/(?:const|let|var)\s+\{?[^}=\n]+\}?\s*=\s*require\s*\([^)]+\);?/g, ''); + content = content.replace(/require\s*\(['"][^'"]+['"]\)/g, ''); + + // Transform code blocks containing Promise.all + Task() (parallel reviewer spawns). + // The bare Task() regex below can't reach inside fenced code blocks. + // Fence boundaries must be at line start (^```) to avoid matching backtick + // template literals inside the code as false fence endings. + content = content.replace(/^```(?:javascript|js)?\n([\s\S]*?)^```$/gm, (fullBlock, codeContent) => { + if (!codeContent.includes('Promise.all') || !codeContent.includes('Task(')) return fullBlock; + // Use lazy [\s\S]*? for the template literal body: it stops at the first backtick and + // naturally matches any character including standalone $ (e.g. $100, $BUDGET). + const taskMatches = [...codeContent.matchAll(/Task\s*\(\s*\{[\s\S]*?subagent_type:\s*['"](?:[^"':]+:)?([^'"]+)['"][\s\S]*?prompt:\s*`([\s\S]*?)`/gs)]; + if (taskMatches.length < 2) return fullBlock; + + const delegations = taskMatches.map(m => { + const agent = m[1]; + const promptFirstLine = m[2].split('\n').find(l => l.trim()) || ''; + return `Delegate to the \`${agent}\` subagent:\n> ${promptFirstLine.trim()}`; + }); + + let result = delegations.join('\n\n'); + + const hasReviewKeyword = delegations.some(d => + /review|quality|security|performance|test|coverage/i.test(d) + ); + if (delegations.length >= 4 && hasReviewKeyword) { + result = `**Review phase (Kiro - max 4 agents, fallback to 2 sequential):**\n\nTry delegating to these subagents (experimental parallel spawning):\n\n${result}\n\nIf parallel spawning is unavailable, run 2 combined reviewers sequentially:\n1. Delegate to the \`reviewer-quality-security\` subagent (code quality + security)\n2. Then delegate to the \`reviewer-perf-test\` subagent (performance + test coverage)\n\nAggregate all findings from whichever execution path succeeded.`; + } + + return result; + }); + + // Transform bare Task() calls outside code blocks + content = content.replace(/await\s+Task\s*\(\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\s*\);?/g, (match) => { + const agentMatch = match.match(/subagent_type:\s*["'](?:[^"':]+:)?([^"']+)["']/); + const promptMatch = match.match(/prompt:\s*[`"']([\s\S]*?)[`"']/); + if (agentMatch) { + const agentName = agentMatch[1]; + const prompt = promptMatch ? promptMatch[1].replace(/\\n/g, '\n').trim() : ''; + if (prompt) { + return `Delegate to the \`${agentName}\` subagent:\n> ${prompt.split('\n')[0]}`; + } + return `Delegate to the \`${agentName}\` subagent.`; + } + return ''; + }); + + // Transform AskUserQuestion to markdown prompt for Kiro chat + content = content.replace(/(?:await\s+)?AskUserQuestion\s*\(\s*\{[\s\S]*?\}\s*\);?/g, (match) => { + const questionMatch = match.match(/question:\s*["'`]([\s\S]*?)["'`]/); + const question = questionMatch ? questionMatch[1] : 'Please choose:'; + const optionMatches = [...match.matchAll(/label:\s*["'`]([^"'`]+)["'`][\s\S]*?description:\s*["'`]([^"'`]+)["'`]/g)]; + if (optionMatches.length > 0) { + const options = optionMatches.map((m, i) => `${i + 1}. **${m[1]}** - ${m[2]}`).join('\n'); + return `**${question}**\n\n${options}\n\nReply with the number or name of your choice.`; + } + return `**${question}**\n\nReply in chat with your choice.`; + }); + + content = content.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1'); + + // Batch parallel reviewer delegations for Kiro's agent limit. + // Detects 4+ consecutive "Delegate to" lines with review-related names + // and rewrites as try-4-then-fallback-to-2 pattern. + const reviewerBatchPattern = /((?:Delegate to the `[^`]*` subagent[^\n]*\n){4,})/g; + content = content.replace(reviewerBatchPattern, (block) => { + const delegations = block.match(/Delegate to the `([^`]+)` subagent/g) || []; + if (delegations.length < 4) return block; + + const hasReviewKeyword = delegations.some(d => + /review|quality|security|performance|test|coverage/i.test(d) + ); + if (!hasReviewKeyword) return block; + + return `**Review phase (Kiro - max 4 agents, fallback to 2 sequential):** + +Try delegating to these subagents (experimental parallel spawning): +${block} +If parallel spawning is unavailable, run 2 combined reviewers sequentially: +1. Delegate to the \`reviewer-quality-security\` subagent (code quality + security) +2. Then delegate to the \`reviewer-perf-test\` subagent (performance + test coverage) + +Aggregate all findings from whichever execution path succeeded.\n`; + }); + + return content; +} + +/** + * Transform agent markdown+frontmatter to Kiro JSON format. + * + * Parses frontmatter for name/description/model/tools, uses body as prompt. + * Returns a JSON string matching Kiro's agent schema. + */ +function transformAgentForKiro(content, options) { + const { pluginInstallPath } = options || {}; + + const frontmatter = discovery.parseFrontmatter(content); + let body = content; + if (content.startsWith('---')) { + const endIdx = content.indexOf('\n---', 3); + if (endIdx !== -1) { + body = content.substring(endIdx + 4).replace(/^\n/, ''); + } + } + + if (pluginInstallPath) { + body = body.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => pluginInstallPath); + body = body.replace(/\$CLAUDE_PLUGIN_ROOT/g, () => pluginInstallPath); + body = body.replace(/\$\{PLUGIN_ROOT\}/g, () => pluginInstallPath); + body = body.replace(/\$PLUGIN_ROOT/g, () => pluginInstallPath); + } + + body = body.replace(/(?:next-task|deslop|ship|sync-docs|audit-project|enhance|perf|repo-map|drift-detect|consult|debate|learn|web-ctl):([a-z][a-z0-9-]*)/g, '$1'); + + const agent = { + name: frontmatter.name || '', + description: frontmatter.description || '', + prompt: body.trim() + }; + + if (frontmatter.tools) { + // parseFrontmatter returns arrays for YAML list syntax, string for inline + const toolItems = Array.isArray(frontmatter.tools) + ? frontmatter.tools.map(t => t.toLowerCase()) + : [frontmatter.tools.toLowerCase()]; + const toolStr = toolItems.join(' '); + const tools = []; + if (toolStr.includes('read')) tools.push('read'); + if (toolStr.includes('edit') || toolStr.includes('write')) tools.push('write'); + if (toolStr.includes('bash') || toolStr.includes('shell')) tools.push('shell'); + if (toolStr.includes('glob')) tools.push('read'); + if (toolStr.includes('grep')) tools.push('read'); + if (toolStr.includes('task') || toolStr.includes('agent')) tools.push('shell'); + if (toolStr.includes('web') || toolStr.includes('fetch')) tools.push('shell'); + if (toolStr.includes('notebook')) tools.push('write'); + if (toolStr.includes('lsp')) tools.push('read'); + const deduped = [...new Set(tools)]; + agent.tools = deduped.length > 0 ? deduped : ['read']; + } else { + agent.tools = ['read']; + } + + agent.resources = ['file://.kiro/prompts/**/*.md']; + + return JSON.stringify(agent, null, 2); +} + +/** + * Generate a combined reviewer agent JSON for Kiro's 4-agent limit. + * Merges multiple review responsibilities into a single agent. + * + * @param {Array<{name: string, focus: string}>} roles - Review passes to combine + * @param {string} name - Agent name + * @param {string} description - Agent description + * @returns {string} JSON string for .kiro/agents/*.json + */ +function generateCombinedReviewerAgent(roles, name, description) { + const sections = roles.map(r => + `## ${r.name} Review\n\nFocus: ${r.focus}` + ).join('\n\n---\n\n'); + + const agent = { + name, + description, + prompt: `You are a combined code reviewer covering multiple review passes in a single session.\n\n${sections}\n\nFor each file you review, check ALL of the above review dimensions. Return findings as a JSON array with objects containing: pass (which review), file, line, severity (critical/high/medium/low), description, suggestion.`, + tools: ['read'], + resources: ['file://.kiro/prompts/**/*.md'], + }; + + return JSON.stringify(agent, null, 2); +} + module.exports = { transformBodyForOpenCode, transformCommandFrontmatterForOpenCode, transformAgentFrontmatterForOpenCode, transformSkillBodyForOpenCode, - transformForCodex + transformForCodex, + transformRuleForCursor, + transformSkillForCursor, + transformCommandForCursor, + transformForCursor: transformRuleForCursor, + transformSkillForKiro, + transformCommandForKiro, + transformAgentForKiro, + generateCombinedReviewerAgent }; diff --git a/lib/discovery/index.js b/lib/discovery/index.js index 4dd98b0..ce36dfa 100644 --- a/lib/discovery/index.js +++ b/lib/discovery/index.js @@ -337,6 +337,48 @@ function invalidateCache() { _cacheRoot = null; } +/** + * Build Cursor rule mappings from discovered commands. + * Returns [ruleName, pluginName, sourceFile, description, type, globs] tuples. + * Uses cursor-description frontmatter field, falls back to codex-description, then description. + * + * @param {string} [repoRoot] - Repository root path + * @returns {Array<[string, string, string, string, string, string]>} + */ +function getCursorRuleMappings(repoRoot) { + const commands = discoverCommands(repoRoot); + return commands.map(cmd => { + const description = cmd.frontmatter['cursor-description'] || + cmd.frontmatter['codex-description'] || + cmd.frontmatter.description || + ''; + const type = cmd.frontmatter.type || 'command'; + const globs = cmd.frontmatter.globs || ''; + return [`agentsys-${cmd.plugin}-${cmd.name}`, cmd.plugin, cmd.file, description, type, globs]; + }); +} + +/** + * Build Kiro steering mappings from discovered commands. + * Returns [steeringName, pluginName, sourceFile, description] tuples. + * Uses kiro-description frontmatter field, falls back to cursor-description, + * codex-description, then description. + * + * @param {string} [repoRoot] - Repository root path + * @returns {Array<[string, string, string, string]>} + */ +function getKiroSteeringMappings(repoRoot) { + const commands = discoverCommands(repoRoot); + return commands.map(cmd => { + const description = cmd.frontmatter['kiro-description'] || + cmd.frontmatter['cursor-description'] || + cmd.frontmatter['codex-description'] || + cmd.frontmatter.description || + ''; + return [cmd.name, cmd.plugin, cmd.file, description]; + }); +} + module.exports = { parseFrontmatter, isValidPluginName, @@ -347,6 +389,8 @@ module.exports = { discoverAll, getCommandMappings, getCodexSkillMappings, + getCursorRuleMappings, + getKiroSteeringMappings, getPluginPrefixRegex, invalidateCache };