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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user