From e226772a725ff4b102c803940c45b7360222a095 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:38:49 -0700 Subject: [PATCH] feat: add gemini agent adapter --- package.json | 1 + scripts/gemini-adapt-agents.js | 189 ++++++++++++++++++++++ tests/scripts/gemini-adapt-agents.test.js | 136 ++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 scripts/gemini-adapt-agents.js create mode 100644 tests/scripts/gemini-adapt-agents.test.js diff --git a/package.json b/package.json index c51155b6..1a569589 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "schemas/", "scripts/ci/", "scripts/ecc.js", + "scripts/gemini-adapt-agents.js", "scripts/hooks/", "scripts/lib/", "scripts/claw.js", diff --git a/scripts/gemini-adapt-agents.js b/scripts/gemini-adapt-agents.js new file mode 100644 index 00000000..45faabe3 --- /dev/null +++ b/scripts/gemini-adapt-agents.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const TOOL_NAME_MAP = new Map([ + ['Read', 'read_file'], + ['Write', 'write_file'], + ['Edit', 'replace'], + ['Bash', 'run_shell_command'], + ['Grep', 'grep_search'], + ['Glob', 'glob'], + ['WebSearch', 'google_web_search'], + ['WebFetch', 'web_fetch'], +]); + +function usage() { + return [ + 'Adapt ECC agent frontmatter for Gemini CLI.', + '', + 'Usage:', + ' node scripts/gemini-adapt-agents.js [agents-dir]', + '', + 'Defaults to .gemini/agents under the current working directory.', + 'Rewrites tools: to Gemini-compatible tool names and removes unsupported color: metadata.' + ].join('\n'); +} + +function parseArgs(argv) { + if (argv.includes('--help') || argv.includes('-h')) { + return { help: true }; + } + + const positional = argv.filter(arg => !arg.startsWith('-')); + if (positional.length > 1) { + throw new Error('Expected at most one agents directory argument'); + } + + return { + help: false, + agentsDir: path.resolve(positional[0] || path.join(process.cwd(), '.gemini', 'agents')), + }; +} + +function ensureDirectory(dirPath) { + if (!fs.existsSync(dirPath)) { + throw new Error(`Agents directory not found: ${dirPath}`); + } + + if (!fs.statSync(dirPath).isDirectory()) { + throw new Error(`Expected a directory: ${dirPath}`); + } +} + +function stripQuotes(value) { + return value.trim().replace(/^['"]|['"]$/g, ''); +} + +function parseToolList(line) { + const match = line.match(/^(\s*tools\s*:\s*)\[(.*)\]\s*$/); + if (!match) { + return null; + } + + const rawItems = match[2].trim(); + if (!rawItems) { + return []; + } + + return rawItems + .split(',') + .map(part => stripQuotes(part)) + .filter(Boolean); +} + +function adaptToolName(toolName) { + const mapped = TOOL_NAME_MAP.get(toolName); + if (mapped) { + return mapped; + } + + if (toolName.startsWith('mcp__')) { + return toolName + .replace(/^mcp__/, 'mcp_') + .replace(/__/g, '_') + .replace(/[^A-Za-z0-9_]/g, '_') + .toLowerCase(); + } + + return toolName; +} + +function formatToolLine(tools) { + return `tools: [${tools.map(tool => JSON.stringify(tool)).join(', ')}]`; +} + +function adaptFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/); + if (!match) { + return { text, changed: false }; + } + + let changed = false; + const updatedLines = []; + + for (const line of match[1].split('\n')) { + if (/^\s*color\s*:/.test(line)) { + changed = true; + continue; + } + + const tools = parseToolList(line); + if (tools) { + const adaptedTools = []; + const seen = new Set(); + + for (const tool of tools.map(adaptToolName)) { + if (seen.has(tool)) { + continue; + } + seen.add(tool); + adaptedTools.push(tool); + } + + const updatedLine = formatToolLine(adaptedTools); + if (updatedLine !== line) { + changed = true; + } + updatedLines.push(updatedLine); + continue; + } + + updatedLines.push(line); + } + + if (!changed) { + return { text, changed: false }; + } + + return { + text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`, + changed: true, + }; +} + +function adaptAgents(dirPath) { + ensureDirectory(dirPath); + + let updated = 0; + let unchanged = 0; + + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) { + continue; + } + + const filePath = path.join(dirPath, entry.name); + const original = fs.readFileSync(filePath, 'utf8'); + const adapted = adaptFrontmatter(original); + + if (adapted.changed) { + fs.writeFileSync(filePath, adapted.text); + updated += 1; + } else { + unchanged += 1; + } + } + + return { updated, unchanged }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const result = adaptAgents(options.agentsDir); + console.log(`Updated ${result.updated} agent file(s); ${result.unchanged} already compatible`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/tests/scripts/gemini-adapt-agents.test.js b/tests/scripts/gemini-adapt-agents.test.js new file mode 100644 index 00000000..4afc419d --- /dev/null +++ b/tests/scripts/gemini-adapt-agents.test.js @@ -0,0 +1,136 @@ +/** + * Tests for scripts/gemini-adapt-agents.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'gemini-adapt-agents.js'); + +function run(args = [], options = {}) { + try { + const stdout = execFileSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: options.cwd, + timeout: 10000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { + code: error.status || 1, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-gemini-adapt-')); +} + +function cleanupTempDir(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeAgent(dirPath, name, body) { + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, name), body); +} + +function runTests() { + console.log('\n=== Testing gemini-adapt-agents.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('shows help with an explicit help flag', () => { + const result = run(['--help']); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Adapt ECC agent frontmatter for Gemini CLI')); + assert.ok(result.stdout.includes('Usage:')); + })) passed++; else failed++; + + if (test('adapts Claude Code tool names and strips unsupported color metadata', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'gan-planner.md', + [ + '---', + 'name: gan-planner', + 'description: Planner agent', + 'tools: [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__resolve-library-id]', + 'model: opus', + 'color: purple', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Updated 1 agent file(s)')); + + const updated = fs.readFileSync(path.join(agentsDir, 'gan-planner.md'), 'utf8'); + assert.ok(updated.includes('tools: ["read_file", "write_file", "replace", "run_shell_command", "grep_search", "glob", "google_web_search", "web_fetch", "mcp_context7_resolve_library_id"]')); + assert.ok(!updated.includes('color: purple')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('defaults to the cwd .gemini/agents directory', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'architect.md', + [ + '---', + 'name: architect', + 'description: Architect agent', + 'tools: ["Read", "Grep", "Glob"]', + 'model: opus', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([], { cwd: tempDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = fs.readFileSync(path.join(agentsDir, 'architect.md'), 'utf8'); + assert.ok(updated.includes('tools: ["read_file", "grep_search", "glob"]')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();