diff --git a/scripts/gemini-adapt-agents.js b/scripts/gemini-adapt-agents.js index bea91cb10..6a0513fa8 100644 --- a/scripts/gemini-adapt-agents.js +++ b/scripts/gemini-adapt-agents.js @@ -7,8 +7,11 @@ const { normalizeAgentTools } = require('./lib/agent-tools'); const TOOL_NAME_MAP = new Map([ ['Read', 'read_file'], + ['ReadFile', 'read_file'], + ['NotebookRead', 'read_file'], ['Write', 'write_file'], ['Edit', 'replace'], + ['MultiEdit', 'replace'], ['Bash', 'run_shell_command'], ['Grep', 'grep_search'], ['Glob', 'glob'], @@ -33,7 +36,17 @@ function parseArgs(argv) { return { help: true }; } - const positional = argv.filter(arg => !arg.startsWith('-')); + const positional = []; + for (const arg of argv) { + if (arg.startsWith('-')) { + // Reject rather than ignore. This script rewrites files in place, so + // silently discarding an unrecognized flag would let `--dry-run` — or any + // mistyped option — modify every installed agent and still exit 0. + throw new Error(`Unknown option: ${arg}`); + } + positional.push(arg); + } + if (positional.length > 1) { throw new Error('Expected at most one agents directory argument'); } @@ -54,13 +67,59 @@ function ensureDirectory(dirPath) { } } +function stripUnquotedYamlComment(value) { + let quote = null; + let escaped = false; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (quote === '"') { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + quote = null; + } + continue; + } + + if (quote === "'") { + if (character === "'") { + if (value[index + 1] === "'") { + index += 1; + } else { + quote = null; + } + } + continue; + } + + if (character === '"' || character === "'") { + quote = character; + continue; + } + + if (character === '#' && (index === 0 || /\s/.test(value[index - 1]))) { + return value.slice(0, index).trim(); + } + } + + return value.trim(); +} + function parseToolList(line) { const match = line.match(/^\s*tools\s*:\s*(.*)$/); if (!match) { return null; } - return normalizeAgentTools(match[1]); + const rawValue = stripUnquotedYamlComment(match[1]); + return { + isEmptyScalar: rawValue === '', + tools: normalizeAgentTools(rawValue), + }; } function adaptToolName(toolName) { @@ -85,22 +144,51 @@ function formatToolLine(tools) { } function adaptFrontmatter(text) { - const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/); + // Match both LF and CRLF frontmatter delimiters while keeping the body slice + // completely untouched so mixed line endings in the body are preserved verbatim. + const match = text.match(/^---(\r?\n)([\s\S]*?)\r?\n---(\r?\n|$)/); if (!match) { return { text, changed: false }; } + const headerEol = match[1]; + const trailingFenceEol = match[3]; + const originalBody = text.slice(match[0].length); + let changed = false; const updatedLines = []; + const lines = match[2].replace(/\r\n/g, '\n').split('\n'); - for (const line of match[1].split('\n')) { + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; if (/^\s*color\s*:/.test(line)) { changed = true; continue; } - const tools = parseToolList(line); - if (tools) { + const parsedTools = parseToolList(line); + let tools = parsedTools ? parsedTools.tools : null; + if (parsedTools && parsedTools.isEmptyScalar) { + const sequenceItems = []; + let lookahead = index; + while (lookahead + 1 < lines.length && /^(?:\s*#|\s*$|(?:\s*-\s+.*))/.test(lines[lookahead + 1])) { + lookahead += 1; + const itemMatch = lines[lookahead].match(/^\s*-\s+(.*)$/); + if (itemMatch) { + const cleanedItem = stripUnquotedYamlComment(itemMatch[1]); + if (cleanedItem) { + sequenceItems.push(cleanedItem); + } + } + } + if (sequenceItems.length > 0) { + index = lookahead; + changed = true; + tools = normalizeAgentTools(sequenceItems); + } + } + + if (tools && tools.length > 0) { const adaptedTools = []; const seen = new Set(); @@ -127,8 +215,10 @@ function adaptFrontmatter(text) { return { text, changed: false }; } + const adaptedHeader = `---${headerEol}${updatedLines.join(headerEol)}${headerEol}---${trailingFenceEol}`; + return { - text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`, + text: `${adaptedHeader}${originalBody}`, changed: true, }; } @@ -139,15 +229,19 @@ function adaptAgents(dirPath) { let updated = 0; let unchanged = 0; - for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const prepared = []; + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isFile() || !entry.name.endsWith('.md')) { continue; } - const filePath = path.join(dirPath, entry.name); + const filePath = path.join(dirPath, path.basename(entry.name)); const original = fs.readFileSync(filePath, 'utf8'); const adapted = adaptFrontmatter(original); + prepared.push({ filePath, adapted }); + } + for (const { filePath, adapted } of prepared) { if (adapted.changed) { fs.writeFileSync(filePath, adapted.text); updated += 1; @@ -170,9 +264,13 @@ function main() { console.log(`Updated ${result.updated} agent file(s); ${result.unchanged} already compatible`); } -try { - main(); -} catch (error) { - console.error(error.message); - process.exit(1); +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exit(1); + } } + +module.exports = { adaptFrontmatter }; diff --git a/tests/scripts/gemini-adapt-agents.test.js b/tests/scripts/gemini-adapt-agents.test.js index d8db764a9..74feed994 100644 --- a/tests/scripts/gemini-adapt-agents.test.js +++ b/tests/scripts/gemini-adapt-agents.test.js @@ -159,6 +159,182 @@ function runTests() { } })) passed++; else failed++; + if (test('deduplicates MultiEdit/Edit and ReadFile/NotebookRead/Read', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'code-simplifier.md', + [ + '---', + 'name: code-simplifier', + 'description: Refactoring agent', + 'tools: Read, ReadFile, NotebookRead, Edit, MultiEdit, Write', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = fs.readFileSync(path.join(agentsDir, 'code-simplifier.md'), 'utf8'); + assert.ok(updated.includes('tools: ["read_file", "replace", "write_file"]')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('adapts a CRLF agent on Windows and preserves its line endings', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'windows.md', + ['---', 'name: windows', 'tools: Read, Bash', 'color: purple', '---', '', 'Body line'].join('\r\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Updated 1 agent file(s)'), result.stdout); + + const updated = fs.readFileSync(path.join(agentsDir, 'windows.md'), 'utf8'); + assert.ok(updated.includes('tools: ["read_file", "run_shell_command"]')); + assert.ok(!updated.includes('color: purple')); + assert.ok(updated.includes('\r\n'), 'CRLF endings were lost'); + assert.ok(!/[^\r]\n/.test(updated), 'found a bare LF in a CRLF file'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('rejects an unknown flag without modifying any file', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'architect.md', + ['---', 'name: architect', 'tools: Read, Bash', '---', '', 'Body'].join('\n') + ); + const before = fs.readFileSync(path.join(agentsDir, 'architect.md'), 'utf8'); + + const result = run(['--dry-run', agentsDir]); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('Unknown option: --dry-run'), result.stderr); + assert.strictEqual(fs.readFileSync(path.join(agentsDir, 'architect.md'), 'utf8'), before); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('adapts multi-line YAML block list tools and is idempotent on rerun', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'block-tools.md', + [ + '---', + 'name: block-tools', + 'description: Block list tools', + 'tools:', + ' - Read', + ' # comment inside list', + ' - Grep', + 'model: sonnet', + '---', + '', + 'Body' + ].join('\n') + ); + + const first = run([agentsDir]); + assert.strictEqual(first.code, 0, first.stderr); + assert.ok(first.stdout.includes('Updated 1 agent file(s)')); + + const afterFirst = fs.readFileSync(path.join(agentsDir, 'block-tools.md'), 'utf8'); + assert.ok(afterFirst.includes('tools: ["read_file", "grep_search"]')); + assert.ok(!afterFirst.includes('- Read')); + + const second = run([agentsDir]); + assert.strictEqual(second.code, 0, second.stderr); + assert.ok(second.stdout.includes('Updated 0 agent file(s)')); + assert.ok(second.stdout.includes('1 already compatible')); + assert.strictEqual(fs.readFileSync(path.join(agentsDir, 'block-tools.md'), 'utf8'), afterFirst); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('strips unquoted inline YAML comments on block-list items while preserving quoted # literals', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'inline-comments.md', + [ + '---', + 'name: inline-comments', + 'description: Inline comment handling', + 'tools: # allowed tools', + ' - Read # inspect files', + ' - "Read # explanation" # trailing comment', + ' - \'Grep # literal\' # another comment', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = fs.readFileSync(path.join(agentsDir, 'inline-comments.md'), 'utf8'); + assert.ok( + updated.includes('tools: ["read_file", "Read # explanation", "Grep # literal"]'), + updated + ); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('preserves mixed line endings in the body verbatim when adapting frontmatter', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + const mixedBody = '\nFirst LF line\nPasted CRLF line\r\nFinal LF line\n'; + + try { + writeAgent( + agentsDir, + 'mixed-endings.md', + `---\nname: mixed-endings\ntools: Read, Bash\ncolor: purple\n---\n${mixedBody}` + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = fs.readFileSync(path.join(agentsDir, 'mixed-endings.md'), 'utf8'); + assert.strictEqual( + updated, + `---\nname: mixed-endings\ntools: ["read_file", "run_shell_command"]\n---\n${mixedBody}` + ); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }