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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 11:44:42 -04:00
committed by GitHub
parent 8c6a0d65e6
commit 82a0f561e1
2 changed files with 343 additions and 0 deletions

View File

@@ -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 <codex_agent_role> 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 = `<codex_agent_role>
role: ${name}
tools: ${tools}
purpose: ${toSingleLine(description)}
</codex_agent_role>`;
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,
};

View File

@@ -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',
);
});
});