#!/usr/bin/env node /** * switch-agent.mjs — Switch the active cc-connect agent. * * Usage: * node scripts/switch-agent.mjs # Switch to agent (codex, claudecode, gemini) * node scripts/switch-agent.mjs --status # Show current active agent * * Strategy: * 1. Try HTTP API (dev server has write permissions to ~/.cc-connect/) * 2. Fall back to direct file manipulation if HTTP fails */ import fs from 'fs'; import path from 'path'; import os from 'os'; import { execSync } from 'child_process'; const SUPPORTED_AGENTS = ['codex', 'claudecode', 'gemini']; const ALIASES = { claude: 'claudecode', cc: 'claudecode', gem: 'gemini', cx: 'codex' }; // Read dev server info to get the port function getDevServerPort() { const candidates = [ path.join(process.cwd(), '.axhub', 'make', '.dev-server-info.json'), path.resolve(import.meta.dirname, '..', '.axhub', 'make', '.dev-server-info.json'), ]; for (const p of candidates) { try { const info = JSON.parse(fs.readFileSync(p, 'utf8')); if (info?.port) return info.port; } catch { /* ignore */ } } return 51720; // default } // --------------------------------------------------------------------------- // HTTP-based switching (preferred — dev server has write permissions) // --------------------------------------------------------------------------- async function httpSwitch(target) { const port = getDevServerPort(); const url = `http://127.0.0.1:${port}/api/cc-connect/switch-agent`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: target }), signal: AbortSignal.timeout(15000), }); const data = await res.json(); if (data.success) { return { ok: true, detail: data.message || `已切换到 ${target}` }; } return { ok: false, detail: data.error || '切换失败' }; } async function httpStatus() { const port = getDevServerPort(); const url = `http://127.0.0.1:${port}/api/cc-connect/active-agent`; const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); return await res.json(); } // --------------------------------------------------------------------------- // File-based switching (fallback — requires write permissions to ~/.cc-connect/) // --------------------------------------------------------------------------- const CC_CONNECT_DIR = path.join(os.homedir(), '.cc-connect'); const STATE_PATH = path.join(CC_CONNECT_DIR, 'axhub-weixin-state.json'); const CONFIG_PATH = path.join(CC_CONNECT_DIR, 'config.toml'); function readState() { if (!fs.existsSync(STATE_PATH)) return null; try { const parsed = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); if (parsed?.version === 1 && parsed?.activeAgent && parsed?.weixinOptions?.token) { return parsed; } } catch { /* ignore */ } return null; } function escapeToml(value) { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); } function fileSwitch(target) { const state = readState(); if (!state) { return { ok: false, detail: '未找到微信绑定状态,请先在管理后台扫码绑定' }; } if (state.activeAgent === target) { return { ok: true, detail: `当前已是 ${target},无需切换` }; } state.activeAgent = target; fs.mkdirSync(CC_CONNECT_DIR, { recursive: true }); fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8'); const optionLines = Object.entries(state.weixinOptions) .map(([k, v]) => `${k} = "${escapeToml(String(v))}"`) .join('\n'); const config = `# Auto-generated by Axhub Make # language = "zh" [log] level = "info" [[projects]] name = "axhub-${escapeToml(target)}" [projects.agent] type = "${escapeToml(target)}" [projects.agent.options] work_dir = "${escapeToml(state.workDir)}" mode = "yolo" [[projects.platforms]] type = "weixin" [projects.platforms.options] ${optionLines} `; fs.writeFileSync(CONFIG_PATH, config, 'utf8'); const ccCommand = process.platform === 'win32' ? 'cc-connect.cmd' : 'cc-connect'; try { execSync(`${ccCommand} daemon stop`, { stdio: 'ignore', timeout: 10000 }); } catch { /* */ } try { execSync(`${ccCommand} daemon uninstall`, { stdio: 'ignore', timeout: 10000 }); } catch { /* */ } try { execSync(`${ccCommand} daemon install --config "${CONFIG_PATH}"`, { stdio: 'inherit', timeout: 20000 }); return { ok: true, detail: `已切换到 ${target}` }; } catch (error) { return { ok: false, detail: `配置已更新,但 daemon 重启失败: ${error.message}` }; } } function fileStatus() { const state = readState(); if (state) { return { activeAgent: state.activeAgent, configuredAgents: state.configuredAgents }; } return null; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help') { console.log('用法: node scripts/switch-agent.mjs '); console.log(`可用: ${SUPPORTED_AGENTS.join(', ')}`); console.log('别名: claude→claudecode, gem→gemini, cx→codex'); console.log(' node scripts/switch-agent.mjs --status'); process.exit(0); } if (args[0] === '--status') { const data = fileStatus(); if (data) { console.log(JSON.stringify(data, null, 2)); } else { try { const httpData = await httpStatus(); console.log(JSON.stringify(httpData, null, 2)); } catch { console.log('未找到微信绑定状态'); } } process.exit(0); } let target = args[0].toLowerCase(); if (ALIASES[target]) target = ALIASES[target]; if (!SUPPORTED_AGENTS.includes(target)) { console.error(`❌ 不支持的 agent: ${target}`); console.error(`可用: ${SUPPORTED_AGENTS.join(', ')}`); process.exit(1); } // Strategy: try file-based first, fall back to HTTP API try { const result = fileSwitch(target); if (result.ok) { console.log(`✅ ${result.detail}`); process.exit(0); } else { console.error(`❌ 本地切换失败: ${result.detail}`); console.log('尝试 HTTP API 切换...'); } } catch (fileError) { console.log(`本地文件操作受限 (${fileError.message}),尝试 HTTP API 切换...`); } // Fallback to HTTP API try { const result = await httpSwitch(target); if (result.ok) { console.log(`✅ ${result.detail}`); } else { console.error(`❌ ${result.detail}`); process.exit(1); } } catch (httpError) { console.error(`❌ HTTP API 也不可达: ${httpError.message}`); console.error('请在管理后台的微信连接对话框中进行切换'); process.exit(1); }