From fb4683317fc044df22ac5f3b69b9d21f554982ae Mon Sep 17 00:00:00 2001 From: minteeqahmed-png Date: Thu, 29 Oct 2026 20:56:00 +0500 Subject: [PATCH 1/2] fix(scripts): harden gemini-adapt-agents for CRLF, unknown flags, and block tool lists --- scripts/gemini-adapt-agents.js | 72 +++++++++++--- tests/scripts/gemini-adapt-agents.test.js | 116 ++++++++++++++++++++++ 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/scripts/gemini-adapt-agents.js b/scripts/gemini-adapt-agents.js index bea91cb10..c62f90c91 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'); } @@ -85,22 +98,47 @@ function formatToolLine(tools) { } function adaptFrontmatter(text) { - const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/); + // Normalize CRLF first. Matching only LF would silently skip a Windows-saved + // agent and report it as already compatible while `tools: Read` and + // `color: ...` stayed in place. The original ending style is restored below. + const usesCrlf = text.includes('\r\n'); + const source = usesCrlf ? text.replace(/\r\n/g, '\n') : text; + + const match = source.match(/^---\n([\s\S]*?)\n---(\n|$)/); if (!match) { return { text, changed: false }; } let changed = false; const updatedLines = []; + const lines = match[1].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) { + let tools = parseToolList(line); + if (tools !== null && tools.length === 0 && /^\s*tools\s*:\s*$/.test(line)) { + 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) { + sequenceItems.push(itemMatch[1]); + } + } + if (sequenceItems.length > 0) { + index = lookahead; + changed = true; + tools = normalizeAgentTools(sequenceItems); + } + } + + if (tools && tools.length > 0) { const adaptedTools = []; const seen = new Set(); @@ -127,8 +165,10 @@ function adaptFrontmatter(text) { return { text, changed: false }; } + const adapted = `---\n${updatedLines.join('\n')}\n---${match[2]}${source.slice(match[0].length)}`; + return { - text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`, + text: usesCrlf ? adapted.replace(/\n/g, '\r\n') : adapted, changed: true, }; } @@ -139,7 +179,8 @@ 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; } @@ -147,7 +188,10 @@ function adaptAgents(dirPath) { const filePath = path.join(dirPath, 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 +214,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..565d919a6 100644 --- a/tests/scripts/gemini-adapt-agents.test.js +++ b/tests/scripts/gemini-adapt-agents.test.js @@ -159,6 +159,122 @@ 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++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From ef82fcb13026d2e2e1c22df57dd4d33461317196 Mon Sep 17 00:00:00 2001 From: minteeqahmed-png Date: Thu, 29 Oct 2026 21:49:53 +0500 Subject: [PATCH 2/2] fix(scripts): strip unquoted YAML comments and preserve mixed body line endings --- scripts/gemini-adapt-agents.js | 80 ++++++++++++++++++----- tests/scripts/gemini-adapt-agents.test.js | 60 +++++++++++++++++ 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/scripts/gemini-adapt-agents.js b/scripts/gemini-adapt-agents.js index c62f90c91..6a0513fa8 100644 --- a/scripts/gemini-adapt-agents.js +++ b/scripts/gemini-adapt-agents.js @@ -67,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) { @@ -98,20 +144,20 @@ function formatToolLine(tools) { } function adaptFrontmatter(text) { - // Normalize CRLF first. Matching only LF would silently skip a Windows-saved - // agent and report it as already compatible while `tools: Read` and - // `color: ...` stayed in place. The original ending style is restored below. - const usesCrlf = text.includes('\r\n'); - const source = usesCrlf ? text.replace(/\r\n/g, '\n') : text; - - const match = source.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[1].split('\n'); + const lines = match[2].replace(/\r\n/g, '\n').split('\n'); for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; @@ -120,15 +166,19 @@ function adaptFrontmatter(text) { continue; } - let tools = parseToolList(line); - if (tools !== null && tools.length === 0 && /^\s*tools\s*:\s*$/.test(line)) { + 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) { - sequenceItems.push(itemMatch[1]); + const cleanedItem = stripUnquotedYamlComment(itemMatch[1]); + if (cleanedItem) { + sequenceItems.push(cleanedItem); + } } } if (sequenceItems.length > 0) { @@ -165,10 +215,10 @@ function adaptFrontmatter(text) { return { text, changed: false }; } - const adapted = `---\n${updatedLines.join('\n')}\n---${match[2]}${source.slice(match[0].length)}`; + const adaptedHeader = `---${headerEol}${updatedLines.join(headerEol)}${headerEol}---${trailingFenceEol}`; return { - text: usesCrlf ? adapted.replace(/\n/g, '\r\n') : adapted, + text: `${adaptedHeader}${originalBody}`, changed: true, }; } @@ -185,7 +235,7 @@ function adaptAgents(dirPath) { 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 }); diff --git a/tests/scripts/gemini-adapt-agents.test.js b/tests/scripts/gemini-adapt-agents.test.js index 565d919a6..74feed994 100644 --- a/tests/scripts/gemini-adapt-agents.test.js +++ b/tests/scripts/gemini-adapt-agents.test.js @@ -275,6 +275,66 @@ function runTests() { } })) 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); }