diff --git a/scripts/qwen-adapt-agents.js b/scripts/qwen-adapt-agents.js new file mode 100644 index 000000000..1b47a8f90 --- /dev/null +++ b/scripts/qwen-adapt-agents.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { normalizeAgentTools } = require('./lib/agent-tools'); + +// Qwen Code is a Gemini CLI descendant, so most tool ids match Gemini's. Three +// entries deliberately differ from gemini-adapt-agents.js: +// +// Edit -> edit (Gemini uses `replace`) +// WebSearch -> dropped (Gemini has `google_web_search`; Qwen has no +// web search tool, only `web_fetch`) +// mcp__* -> unchanged (Gemini lowercases to mcp_server_tool; Qwen +// keeps the double-underscore form) +const TOOL_NAME_MAP = new Map([ + ['Read', 'read_file'], + ['ReadFile', 'read_file'], + ['NotebookRead', 'read_file'], + ['Write', 'write_file'], + ['Edit', 'edit'], + ['MultiEdit', 'edit'], + ['Bash', 'run_shell_command'], + ['Grep', 'grep_search'], + ['Glob', 'glob'], + ['WebFetch', 'web_fetch'], + ['TodoWrite', 'todo_write'], + ['Task', 'agent'], +]); + +// Tools with no Qwen Code equivalent. Silently keeping them would leave an agent +// definition advertising a capability it cannot use. +const TOOL_NAME_DROP = new Set(['WebSearch']); + +// Qwen Code supports `color:`, but only this palette. An unlisted value is +// dropped at load time with a SUBAGENT_MANAGER warning, so remap where the +// intent is clear and drop the rest rather than shipping a field that warns. +const VALID_COLORS = new Set([ + 'red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan', 'auto', +]); +const COLOR_MAP = new Map([['teal', 'cyan']]); + +function usage() { + return [ + 'Adapt ECC agent frontmatter for Qwen Code.', + '', + 'Usage:', + ' node scripts/qwen-adapt-agents.js [agents-dir]', + '', + "Defaults to .qwen/agents under the current working directory.", + 'Rewrites tools: to Qwen Code tool ids, drops tools Qwen Code does not have,', + 'and remaps color: values outside the Qwen Code palette.', + ].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(), '.qwen', '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 parseToolList(line) { + const match = line.match(/^\s*tools\s*:\s*(.*)$/); + if (!match) { + return null; + } + + return normalizeAgentTools(match[1]); +} + +function adaptToolName(toolName) { + const mapped = TOOL_NAME_MAP.get(toolName); + if (mapped) { + return mapped; + } + + if (TOOL_NAME_DROP.has(toolName)) { + return null; + } + + // Qwen Code keeps the mcp__server__tool shape, so MCP entries pass through. + return toolName; +} + +function formatToolLine(tools) { + // Comma-separated scalar rather than the JSON flow sequence + // gemini-adapt-agents.js emits: Qwen Code parses both, and the scalar form is + // what its own agent examples use. + return `tools: ${tools.join(', ')}`; +} + +function adaptColor(line) { + const match = line.match(/^(\s*)color\s*:\s*(.+?)\s*$/); + if (!match) { + return null; + } + + const value = match[2].replace(/^["']|["']$/g, ''); + if (VALID_COLORS.has(value)) { + return { line, changed: false }; + } + + const remapped = COLOR_MAP.get(value); + if (remapped) { + return { line: `${match[1]}color: ${remapped}`, changed: true }; + } + + return { line: null, changed: true }; +} + +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')) { + const color = adaptColor(line); + if (color) { + if (color.changed) { + changed = true; + } + if (color.line !== null) { + updatedLines.push(color.line); + } + continue; + } + + const tools = parseToolList(line); + if (tools) { + const adaptedTools = []; + const seen = new Set(); + + for (const tool of tools.map(adaptToolName)) { + if (!tool || 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/qwen-adapt-agents.test.js b/tests/scripts/qwen-adapt-agents.test.js new file mode 100644 index 000000000..f4273002e --- /dev/null +++ b/tests/scripts/qwen-adapt-agents.test.js @@ -0,0 +1,321 @@ +/** + * Tests for scripts/qwen-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', 'qwen-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-qwen-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 readAgent(dirPath, name) { + return fs.readFileSync(path.join(dirPath, name), 'utf8'); +} + +function runTests() { + console.log('\n=== Testing qwen-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 Qwen Code')); + assert.ok(result.stdout.includes('Usage:')); + })) passed++; else failed++; + + if (test('maps Claude Code tool names to Qwen Code ids and drops WebSearch', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'gan-planner.md', + [ + '---', + 'name: gan-planner', + 'description: Planner agent', + 'tools: [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch]', + 'model: opus', + '---', + '', + '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 = readAgent(agentsDir, 'gan-planner.md'); + // Edit maps to `edit`, not Gemini's `replace`. + assert.ok(updated.includes('tools: read_file, write_file, edit, run_shell_command, grep_search, glob, web_fetch')); + // Qwen Code has no web search tool, so the entry is dropped rather than + // left advertising a capability the agent cannot use. + assert.ok(!updated.includes('WebSearch')); + assert.ok(!updated.includes('web_search')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('preserves the mcp__server__tool shape', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'docs-lookup.md', + [ + '---', + 'name: docs-lookup', + 'description: Documentation lookup agent', + 'tools: Read, Grep, mcp__context7__resolve-library-id, mcp__context7__query-docs', + 'model: sonnet', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = readAgent(agentsDir, 'docs-lookup.md'); + // Unlike Gemini, which lowercases these to mcp_context7_resolve_library_id, + // Qwen Code keeps the double-underscore form. + assert.ok(updated.includes('tools: read_file, grep_search, mcp__context7__resolve-library-id, mcp__context7__query-docs')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('deduplicates MultiEdit and Edit onto a single edit entry', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'code-simplifier.md', + [ + '---', + 'name: code-simplifier', + 'description: Refactoring agent', + 'tools: Read, Edit, MultiEdit, Write', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = readAgent(agentsDir, 'code-simplifier.md'); + assert.ok(updated.includes('tools: read_file, edit, write_file')); + assert.strictEqual(updated.split('edit').length - 1, 1); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('remaps an out-of-palette color instead of dropping it', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'harness-optimizer.md', + [ + '---', + 'name: harness-optimizer', + 'description: Harness tuning agent', + 'tools: Read, Grep', + 'color: teal', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + // Qwen Code supports color:, but only red/blue/green/yellow/purple/orange/ + // pink/cyan/auto. An unlisted value is dropped at load time with a + // warning, so teal is remapped to its nearest valid equivalent. + const updated = readAgent(agentsDir, 'harness-optimizer.md'); + assert.ok(updated.includes('color: cyan')); + assert.ok(!updated.includes('color: teal')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('leaves an already-compatible agent untouched', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'security-reviewer.md', + [ + '---', + 'name: security-reviewer', + 'description: Security agent', + 'tools: read_file, grep_search', + 'color: red', + '---', + '', + 'Body' + ].join('\n') + ); + + const before = readAgent(agentsDir, 'security-reviewer.md'); + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Updated 0 agent file(s)')); + assert.ok(result.stdout.includes('1 already compatible')); + // A color inside the palette and Qwen-native tool ids need no rewrite. + assert.strictEqual(readAgent(agentsDir, 'security-reviewer.md'), before); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('leaves the agent body untouched', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + const body = ['', 'Read the codebase, then Write a summary.', 'Use Bash only when needed.'].join('\n'); + + try { + writeAgent( + agentsDir, + 'code-explorer.md', + ['---', 'name: code-explorer', 'tools: Read, Bash', '---', body].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = readAgent(agentsDir, 'code-explorer.md'); + // Prose that names Claude tools must survive: only frontmatter is in scope. + assert.ok(updated.endsWith(body)); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('is idempotent on a second run', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'architect.md', + ['---', 'name: architect', 'tools: Read, Grep, Glob', 'color: teal', '---', '', '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 = readAgent(agentsDir, 'architect.md'); + + 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(readAgent(agentsDir, 'architect.md'), afterFirst); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('defaults to the cwd .qwen/agents directory', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.qwen', 'agents'); + + try { + writeAgent( + agentsDir, + 'planner.md', + ['---', 'name: planner', 'tools: ["Read", "Grep", "Glob"]', '---', '', 'Body'].join('\n') + ); + + const result = run([], { cwd: tempDir }); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(readAgent(agentsDir, 'planner.md').includes('tools: read_file, grep_search, glob')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('fails with a clear error when the agents directory is missing', () => { + const tempDir = createTempDir(); + + try { + const result = run([path.join(tempDir, 'nope')]); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('Agents directory not found')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();