From 82a0f561e1dfcca2c4ff37026cbc64df9d49d541 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 14 Jun 2026 11:44:42 -0400 Subject: [PATCH] fix(#1182): extract agent converters + tool-name tables into conversion module (#1220) Extracts all 9 convertClaudeAgentTo* functions together with their full dependency closure (claudeToCopilotTools, claudeToGeminiTools, convertCopilotToolName, convertGeminiToolName) from bin/install.js into src/runtime-artifact-conversion.cts and adds them to the module's export= block. Adds 6 regression tests in tests/copilot-install.test.cjs including a DEFECT.GENERATIVE-FIX parity guard that asserts claudeToCopilotTools is identical in module and bin/install.js. Inline copies in bin/install.js are retained (#1175 will remove them). This unblocks #1173 (descriptor-driven dispatch). Co-authored-by: Claude Opus 4.8 (1M context) --- src/runtime-artifact-conversion.cts | 285 ++++++++++++++++++++++++++++ tests/copilot-install.test.cjs | 58 ++++++ 2 files changed, 343 insertions(+) diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index ceed69d8f..a3f998897 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1755,6 +1755,277 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { return `---\n${newFrontmatter}\n---${body}`; } +// ── Agent converters — #1182 extraction ───────────────────────────────────── +// These were previously only in bin/install.js. Extracted here so the module +// is self-contained and #1173 descriptor-driven dispatch can call them without +// reaching through the Installer Module. +// NOTE: Do NOT remove the inline copies from bin/install.js in this PR — +// that is #1175. This PR only adds them to the module's export surface. + +// Copilot tool name mapping — Claude Code tools to GitHub Copilot tools +// Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision) +const claudeToCopilotTools = { + Read: 'read', + Write: 'edit', + Edit: 'edit', + Bash: 'execute', + Grep: 'search', + Glob: 'search', + Task: 'agent', + WebSearch: 'web', + WebFetch: 'web', + TodoWrite: 'todo', + AskUserQuestion: 'ask_user', + SlashCommand: 'skill', +}; + +// Tool name mapping from Claude Code to Gemini CLI +// Gemini CLI uses snake_case built-in tool names +const claudeToGeminiTools = { + Read: 'read_file', + Write: 'write_file', + Edit: 'replace', + Bash: 'run_shell_command', + Glob: 'glob', + Grep: 'search_file_content', + WebSearch: 'google_web_search', + WebFetch: 'web_fetch', + TodoWrite: 'write_todos', +}; + +/** + * Convert a Claude Code tool name to Gemini CLI format + * - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.) + * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini + * - Filters out Task/Agent — agents are auto-registered as tools in Gemini + * @returns {string|null} Gemini tool name, or null if tool should be excluded + */ +function convertGeminiToolName(claudeTool) { + // MCP tools: exclude — auto-discovered from mcpServers config at runtime + if (claudeTool.startsWith('mcp__')) { + return null; + } + // Task/Agent: exclude — agents are auto-registered as callable tools. + // AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool; + // emitting it causes frontmatter validation errors (#3362). + if ( + claudeTool === 'Task' || + claudeTool === 'Agent' || + claudeTool === 'AskUserQuestion' || + claudeTool === 'ask_user' + ) { + return null; + } + // Check for explicit mapping + if (claudeToGeminiTools[claudeTool]) { + return claudeToGeminiTools[claudeTool]; + } + // Default: lowercase + return claudeTool.toLowerCase(); +} + +/** + * Convert a Claude Code tool name to GitHub Copilot format. + * - Applies explicit mapping from claudeToCopilotTools + * - Handles mcp__context7__* prefix → io.github.upstash/context7/* + * - Falls back to lowercase for unknown tools + */ +function convertCopilotToolName(claudeTool) { + // mcp__context7__* wildcard → io.github.upstash/context7/* + if (claudeTool.startsWith('mcp__context7__')) { + return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length); + } + // Check explicit mapping + if (claudeToCopilotTools[claudeTool]) { + return claudeToCopilotTools[claudeTool]; + } + // mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl; + // add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up) + // Default: lowercase + return claudeTool.toLowerCase(); +} + +/** + * Convert a Claude agent (.md) to a GitHub Copilot agent. + * CONV-04: JSON array format. CONV-05: Tool name mapping. + */ +function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { + const converted = convertClaudeToCopilotContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const color = extractFrontmatterField(frontmatter, 'color'); + const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || ''; + + // CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array + const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean); + const mappedTools = claudeTools.map(t => convertCopilotToolName(t)); + const uniqueTools = [...new Set(mappedTools)]; + const toolsArray = uniqueTools.length > 0 + ? "['" + uniqueTools.join("', '") + "']" + : '[]'; + + // Reconstruct frontmatter in Copilot format. Quote description (#2876) + // so a leading YAML flow indicator (`[BETA] …`, `{ … }`, etc.) doesn't + // crash the Copilot frontmatter loader. + let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${toolsArray}\n`; + if (color) fm += `color: ${color}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + +/** + * Convert a Claude agent (.md) to an Antigravity agent. + * Uses Gemini tool names since Antigravity runs on Gemini 3 backend. + */ +function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) { + const converted = convertClaudeToAntigravityContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const color = extractFrontmatterField(frontmatter, 'color'); + const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || ''; + + // Map tools to Gemini equivalents (reuse existing convertGeminiToolName) + const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean); + const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean); + + // #2876: quote description for the same reason as the skill variant. + let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${mappedTools.join(', ')}\n`; + if (color) fm += `color: ${color}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + +/** + * Convert Claude Code agent markdown to Cursor agent format. + * Strips frontmatter fields Cursor doesn't support (color, skills), + * converts tool references, and adds a role context header. + */ +function convertClaudeAgentToCursorAgent(content) { + const converted = convertClaudeToCursorMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +/** + * Convert Claude Code agent markdown to Windsurf agent format. + * Strips frontmatter fields Windsurf doesn't support (color, skills), + * converts tool references, and adds a role context header. + */ +function convertClaudeAgentToWindsurfAgent(content) { + const converted = convertClaudeToWindsurfMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +/** + * Convert Claude Code agent markdown to Augment agent format. + * Strips frontmatter fields Augment doesn't support (color, skills), + * converts tool references, and cleans up for Augment agents. + */ +function convertClaudeAgentToAugmentAgent(content) { + const converted = convertClaudeToAugmentMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +function convertClaudeAgentToTraeAgent(content) { + const converted = convertClaudeToTraeMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +function convertClaudeAgentToCodebuddyAgent(content) { + const converted = convertClaudeToCodebuddyMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +function convertClaudeAgentToClineAgent(content) { + const converted = convertClaudeToCliineMarkdown(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + return `${cleanFrontmatter}\n${body}`; +} + +/** + * Convert Claude Code agent markdown to Codex agent format. + * Applies base markdown conversions, then adds a header + * and cleans up frontmatter (removes tools/color fields). + */ +function convertClaudeAgentToCodexAgent(content) { + const converted = convertClaudeToCodexMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const tools = extractFrontmatterField(frontmatter, 'tools') || ''; + + const roleHeader = ` +role: ${name} +tools: ${tools} +purpose: ${toSingleLine(description)} +`; + + const cleanFrontmatter = `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`; +} + +// ── End agent converters #1182 ─────────────────────────────────────────────── + /** * Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo), * which share a config schema (Kilo derives from OpenCode). OpenCode discovers @@ -1857,4 +2128,18 @@ export = { convertClaudeCommandToKiloSkill, readGsdCommandNames, transformContentToHyphen, + // #1182: agent converters + tool-name table dependency closure + claudeToCopilotTools, + convertCopilotToolName, + claudeToGeminiTools, + convertGeminiToolName, + convertClaudeAgentToCopilotAgent, + convertClaudeAgentToAntigravityAgent, + convertClaudeAgentToCursorAgent, + convertClaudeAgentToWindsurfAgent, + convertClaudeAgentToAugmentAgent, + convertClaudeAgentToTraeAgent, + convertClaudeAgentToCodebuddyAgent, + convertClaudeAgentToClineAgent, + convertClaudeAgentToCodexAgent, }; diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 5b9cd5687..de7cfe06b 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -1808,3 +1808,61 @@ describe('Claude uninstall preserves user-generated files (#1423)', () => { assert.ok(!fs.existsSync(cmdDir), 'commands/gsd/ should not exist after clean uninstall'); }); }); + +// ─── #1182 regression: agent converters accessible via module path ──────────── +// These tests require convertClaudeAgentToCopilotAgent and its dependency closure +// (claudeToCopilotTools, convertCopilotToolName) THROUGH the runtime-artifact-conversion +// module export — not via bin/install.js. Before the fix, the module returned +// undefined for all three, causing ReferenceError when called. + +describe('#1182 convertClaudeAgentToCopilotAgent exported from runtime-artifact-conversion module', () => { + const _gsdLibDirModule = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'); + const conversionModule = require(path.join(_gsdLibDirModule, 'runtime-artifact-conversion.cjs')); + + test('module exports claudeToCopilotTools table', () => { + assert.strictEqual(typeof conversionModule.claudeToCopilotTools, 'object', 'claudeToCopilotTools must be exported'); + assert.ok(conversionModule.claudeToCopilotTools !== null, 'not null'); + assert.strictEqual(conversionModule.claudeToCopilotTools['Read'], 'read', 'Read maps to read'); + assert.strictEqual(conversionModule.claudeToCopilotTools['Bash'], 'execute', 'Bash maps to execute'); + }); + + test('module exports convertCopilotToolName function', () => { + assert.strictEqual(typeof conversionModule.convertCopilotToolName, 'function', 'convertCopilotToolName must be exported'); + assert.strictEqual(conversionModule.convertCopilotToolName('Read'), 'read', 'maps Read -> read'); + assert.strictEqual(conversionModule.convertCopilotToolName('Bash'), 'execute', 'maps Bash -> execute'); + assert.strictEqual(conversionModule.convertCopilotToolName('mcp__context7__resolve-library-id'), 'io.github.upstash/context7/resolve-library-id', 'mcp__context7__ prefix mapped'); + }); + + test('module exports convertClaudeAgentToCopilotAgent function', () => { + assert.strictEqual(typeof conversionModule.convertClaudeAgentToCopilotAgent, 'function', 'convertClaudeAgentToCopilotAgent must be exported'); + }); + + test('convertClaudeAgentToCopilotAgent via module produces correct output (local mode)', () => { + const input = `---\nname: gsd-executor\ndescription: Executes GSD plans\ntools: Read, Write, Edit, Bash, Grep, Glob\ncolor: yellow\n---\n\nAgent body.`; + const result = conversionModule.convertClaudeAgentToCopilotAgent(input); + // Tools must be mapped and deduplicated + assert.ok(result.includes("tools: ['read', 'edit', 'execute', 'search']"), `expected mapped tools in: ${result}`); + assert.ok(result.includes('name: gsd-executor'), 'name preserved'); + assert.ok(result.includes('color: yellow'), 'color preserved'); + }); + + test('convertClaudeAgentToCopilotAgent via module applies path/command conversions (global mode)', () => { + const input = `---\nname: gsd-test\ndescription: Test\ntools: Read\n---\n\nCheck ~/.claude/settings and run gsd:health.`; + const result = conversionModule.convertClaudeAgentToCopilotAgent(input, true); + assert.ok(result.includes('~/.copilot/settings'), 'CONV-06 applied in global mode'); + assert.ok(result.includes('gsd-health'), 'CONV-07 applied'); + }); + + // Parity assertion: claudeToCopilotTools in module matches the table in bin/install.js + // Per DEFECT.GENERATIVE-FIX: shared constant across two surfaces needs a parity guard. + test('claudeToCopilotTools parity: module table matches bin/install.js table', () => { + const installJs = require('../bin/install.js'); + const moduleTable = conversionModule.claudeToCopilotTools; + const installTable = installJs.claudeToCopilotTools; + assert.deepStrictEqual( + moduleTable, + installTable, + 'claudeToCopilotTools must be identical in module and bin/install.js', + ); + }); +});