From 9d663db3f0f627540a2ccf28bba54120537224d2 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sun, 10 May 2026 10:58:13 +0700 Subject: [PATCH] feat(cli): Comprehensive CLI Enhancement Suite - 20+ new commands (#2074) Integrated into release/v3.8.0 --- bin/cli-commands.mjs | 2822 +++++++++++++++++ bin/omniroute.mjs | 79 + .../dashboard/cloud-agents/page.tsx | 478 +++ .../(dashboard)/dashboard/providers/page.tsx | 53 + .../settings/components/ProxyTab.tsx | 1 + src/app/api/v1/agents/tasks/[id]/route.ts | 186 ++ src/app/api/v1/agents/tasks/route.ts | 173 + src/i18n/messages/en.json | 38 + src/lib/cloudAgent/agents/codex.ts | 148 + src/lib/cloudAgent/agents/devin.ts | 137 + src/lib/cloudAgent/agents/jules.ts | 170 + src/lib/cloudAgent/baseAgent.ts | 95 + src/lib/cloudAgent/db.ts | 145 + src/lib/cloudAgent/index.ts | 8 + src/lib/cloudAgent/registry.ts | 25 + src/lib/cloudAgent/types.ts | 111 + src/lib/providers/catalog.ts | 11 +- src/shared/constants/providers.ts | 34 + src/shared/constants/sidebarVisibility.ts | 2 + tests/unit/cloudAgent-types.test.ts | 166 + 20 files changed, 4881 insertions(+), 1 deletion(-) create mode 100644 bin/cli-commands.mjs create mode 100644 src/app/(dashboard)/dashboard/cloud-agents/page.tsx create mode 100644 src/app/api/v1/agents/tasks/[id]/route.ts create mode 100644 src/app/api/v1/agents/tasks/route.ts create mode 100644 src/lib/cloudAgent/agents/codex.ts create mode 100644 src/lib/cloudAgent/agents/devin.ts create mode 100644 src/lib/cloudAgent/agents/jules.ts create mode 100644 src/lib/cloudAgent/baseAgent.ts create mode 100644 src/lib/cloudAgent/db.ts create mode 100644 src/lib/cloudAgent/index.ts create mode 100644 src/lib/cloudAgent/registry.ts create mode 100644 src/lib/cloudAgent/types.ts create mode 100644 tests/unit/cloudAgent-types.test.ts diff --git a/bin/cli-commands.mjs b/bin/cli-commands.mjs new file mode 100644 index 0000000000..bf9560caec --- /dev/null +++ b/bin/cli-commands.mjs @@ -0,0 +1,2822 @@ +#!/usr/bin/env node + +/** + * OmniRoute CLI - Production-grade CLI Integration Suite + * + * Commands: + * setup - Configure CLI tools to use OmniRoute + * doctor - Run health diagnostics + * status - Show comprehensive status + * logs - View application logs + * provider - Add OmniRoute as provider for tools + * config - Show current OmniRoute configuration + * test - Test provider/model connectivity + * update - Check for updates + */ + +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + copyFileSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir, platform, release } from "node:os"; +import { execSync, spawn } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, ".."); + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const DEFAULT_BASE_URL = "http://localhost:20128"; +const API_PORT = 20128; +const DASHBOARD_PORT = 20129; + +const CLI_TOOLS = { + claude: { + id: "claude", + name: "Claude Code", + command: "claude", + configPath: ".claude/settings.json", + type: "json", + }, + codex: { + id: "codex", + name: "Codex CLI", + command: "codex", + configPath: ".codex/config.toml", + type: "toml", + }, + opencode: { + id: "opencode", + name: "OpenCode", + command: "opencode", + configPath: ".config/opencode/opencode.json", + type: "json", + }, + cline: { + id: "cline", + name: "Cline", + command: "cline", + configPath: ".cline/data/globalState.json", + type: "json", + }, + kilo: { + id: "kilo", + name: "Kilo Code", + command: "kilocode", + configPath: ".config/kilocode/settings.json", + type: "json", + }, + continue: { + id: "continue", + name: "Continue", + command: "continue", + configPath: ".continue/config.json", + type: "json", + }, + openclaw: { + id: "openclaw", + name: "OpenClaw", + command: "openclaw", + configPath: ".openclaw/openclaw.json", + type: "json", + }, +}; + +const PROVIDER_HELP = { + opencode: `OpenCode configuration: +1. Add to ~/.config/opencode/opencode.json: +{ + "provider": { + "omniroute": { + "name": "OmniRoute", + "baseURL": "http://localhost:20128/v1" + } + } +} +2. Set environment: export OPENAI_API_KEY=your-key`, + + cursor: `Cursor configuration: +1. Open Cursor Settings +2. Go to Models → Add Model +3. Set Base URL to: http://localhost:20128/v1 +4. Set API Key to your OmniRoute key`, + + cline: `Cline configuration: +1. Open Cline Settings +2. Find "OpenAI Compatible" provider settings +3. Set Base URL: http://localhost:20128/v1 +4. Set API Key: your OmniRoute key`, + + vscode: `VS Code + MCP configuration: +1. Install Cline extension +2. Or use: omniroute --mcp for MCP server`, +}; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +function getHomeDir() { + return homedir(); +} + +function resolveConfigPath(relativePath) { + return join(getHomeDir(), relativePath); +} + +function execCommand(command, timeout = 3000) { + try { + const output = execSync(command, { + encoding: "utf8", + timeout, + stdio: ["pipe", "pipe", "pipe"], + }); + return { success: true, output: output.trim() }; + } catch (error) { + return { + success: false, + error: error.message, + code: error.status || error.signal, + }; + } +} + +function readJsonFile(filePath) { + try { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, "utf8"); + return JSON.parse(content); + } catch (error) { + return null; + } +} + +function writeJsonFile(filePath, data) { + try { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8"); + return true; + } catch (error) { + return false; + } +} + +function createBackup(filePath) { + if (!existsSync(filePath)) return null; + const backupPath = filePath + ".backup." + Date.now(); + try { + writeFileSync(backupPath, readFileSync(filePath), "utf8"); + return backupPath; + } catch { + return null; + } +} + +function colorize(text, color) { + const colors = { + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + reset: "\x1b[0m", + dim: "\x1b[2m", + }; + return colors[color] ? `${colors[color]}${text}${colors.reset}` : text; +} + +function log(message, color = "reset") { + console.log(colorize(message, color)); +} + +function logSection(title) { + console.log("\n" + colorize("┌─ " + title + " ".repeat(50), "cyan")); +} + +function logEndSection() { + console.log(colorize("└" + "─".repeat(51), "cyan")); +} + +// ============================================================================ +// TOOL DETECTION +// ============================================================================ + +function detectInstalledTools() { + const results = []; + + for (const [id, tool] of Object.entries(CLI_TOOLS)) { + const result = execCommand(`which ${tool.command}`, 2000); + const installed = result.success; + let version = null; + + if (installed) { + const versionResult = execCommand(`${tool.command} --version`, 2000); + if (versionResult.success) { + version = versionResult.output.slice(0, 20); + } + } + + results.push({ + id, + name: tool.name, + installed, + version, + configPath: resolveConfigPath(tool.configPath), + configured: checkToolConfigured(id), + }); + } + + return results; +} + +function checkToolConfigured(toolId) { + const tool = CLI_TOOLS[toolId]; + if (!tool) return false; + + const configPath = resolveConfigPath(tool.configPath); + + try { + if (!existsSync(configPath)) return false; + + const content = readFileSync(configPath, "utf8").toLowerCase(); + const hasOmniRoute = + content.includes("omniroute") || + content.includes(`localhost:${API_PORT}`) || + content.includes(`127.0.0.1:${API_PORT}`); + return hasOmniRoute; + } catch { + return false; + } +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +async function checkServerHealth() { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/health`, { + method: "GET", + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } +} + +async function getCliToolsStatusFromApi() { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/cli-tools/status`, { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + return await res.json(); + } + } catch {} + return null; +} + +async function getConsoleLogs(limit = 100, level = null) { + try { + const url = new URL(`${DEFAULT_BASE_URL}/api/logs/console`); + url.searchParams.set("limit", String(limit)); + if (level) url.searchParams.set("level", level); + + const res = await fetch(url.toString(), { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + return await res.json(); + } + } catch {} + return []; +} + +async function testProviderConnection(provider = "claude", model = "claude-sonnet-4-20250514") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-omniroute-cli-test", + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: "Hi" }], + max_tokens: 10, + }), + signal: AbortSignal.timeout(10000), + }); + + if (res.ok) { + const data = await res.json(); + return { success: true, response: data.choices?.[0]?.message?.content || "OK" }; + } else { + const error = await res.text(); + return { success: false, error: `HTTP ${res.status}: ${error.slice(0, 100)}` }; + } + } catch (error) { + return { success: false, error: error.message }; + } +} + +// ============================================================================ +// CONFIG MANAGEMENT +// ============================================================================ + +function configureTool(toolId, baseUrl, apiKey) { + const tool = CLI_TOOLS[toolId]; + if (!tool) { + return { success: false, error: "Unknown tool: " + toolId }; + } + + const configPath = tool.configPath; + const fullPath = resolveConfigPath(configPath); + + // Create backup first + const backupPath = createBackup(fullPath); + + try { + // Ensure directory exists + const dir = dirname(fullPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Read existing config or create new + let config = {}; + if (existsSync(fullPath)) { + const content = readFileSync(fullPath, "utf8"); + if (tool.type === "json") { + config = JSON.parse(content); + } + } + + // Apply tool-specific configuration + switch (toolId) { + case "claude": + config.api = config.api || {}; + config.api.omniroute = { + baseUrl: `${baseUrl}/v1`, + apiKey: apiKey, + model: "claude-sonnet-4-20250514", + }; + break; + + case "codex": + // For TOML, we need to append/modify + const tomlContent = `[openai] +base_url = "${baseUrl}/v1" +api_key = "${apiKey}" +model = "gpt-4o" +`; + writeFileSync(fullPath, tomlContent, "utf8"); + return { success: true, configPath: fullPath, backupPath }; + + case "opencode": + config.provider = config.provider || {}; + config.provider.omniroute = { + name: "OmniRoute", + baseURL: `${baseUrl}/v1`, + apiKey: apiKey, + }; + break; + + case "cline": + config.openAiBaseUrl = `${baseUrl}/v1`; + config.openAiApiKey = apiKey; + config.actModeApiProvider = "openai"; + config.planModeApiProvider = "openai"; + break; + + case "kilo": + config.apiUrl = `${baseUrl}/v1`; + config.apiKey = apiKey; + break; + + case "continue": + config.models = config.models || []; + config.models.push({ + name: "OmniRoute", + provider: "openai-compatible", + apiKey: apiKey, + baseUrl: `${baseUrl}/v1`, + }); + break; + + case "openclaw": + config.OPENAI_BASE_URL = `${baseUrl}/v1`; + config.OPENAI_API_KEY = apiKey; + break; + } + + // Write JSON configs + if (tool.type === "json") { + writeFileSync(fullPath, JSON.stringify(config, null, 2), "utf8"); + } + + return { success: true, configPath: fullPath, backupPath }; + } catch (error) { + // Restore backup on failure + if (backupPath && existsSync(backupPath)) { + try { + writeFileSync(fullPath, readFileSync(backupPath), "utf8"); + } catch {} + } + return { success: false, error: error.message }; + } +} + +// ============================================================================ +// CONFIG SHOW +// ============================================================================ + +function getOmniRouteConfig() { + const config = { + port: API_PORT, + dashboardPort: DASHBOARD_PORT, + baseUrl: `http://localhost:${API_PORT}`, + dataDir: resolveConfigPath(".omniroute"), + requireApiKey: process.env.REQUIRE_API_KEY === "true", + logLevel: process.env.LOG_LEVEL || "info", + }; + + // Check for existing providers + try { + const dbPath = join(config.dataDir, "storage.sqlite"); + config.hasDatabase = existsSync(dbPath); + } catch { + config.hasDatabase = false; + } + + // Node version + config.nodeVersion = process.version; + config.platform = platform(); + config.osRelease = release(); + + return config; +} + +// ============================================================================ +// COMMAND IMPLEMENTATIONS +// ============================================================================ + +export async function runSubcommand(cmd, args) { + switch (cmd) { + case "setup": + await runSetup(args); + break; + case "doctor": + await runDoctor(args); + break; + case "status": + await runStatus(args); + break; + case "logs": + await runLogs(args); + break; + case "provider": + await runProvider(args); + break; + case "config": + await runConfig(args); + break; + case "test": + await runTest(args); + break; + case "update": + await runUpdate(args); + break; + case "serve": + await runServe(args); + break; + case "stop": + await runStop(args); + break; + case "restart": + await runRestart(args); + break; + case "keys": + await runKeys(args); + break; + case "models": + await runModels(args); + break; + case "combo": + await runCombo(args); + break; + case "completion": + await runCompletion(args); + break; + case "dashboard": + await runDashboard(args); + break; + case "backup": + await runBackup(args); + break; + case "restore": + await runRestore(args); + break; + case "quota": + await runQuota(args); + break; + case "health": + await runHealth(args); + break; + case "cache": + await runCache(args); + break; + case "mcp": + await runMcp(args); + break; + case "a2a": + await runA2a(args); + break; + case "tunnel": + await runTunnel(args); + break; + case "env": + await runEnv(args); + break; + case "open": + await runDashboard(args); + break; + default: + log(`Unknown subcommand: ${cmd}`, "red"); + log("Run 'omniroute --help' for available commands", "dim"); + process.exit(1); + } +} + +async function runSetup(args) { + const toolsArg = args.find((a) => a.startsWith("--tools="))?.split("=")[1]; + const urlArg = args.find((a) => a.startsWith("--url="))?.split("=")[1] || DEFAULT_BASE_URL; + const keyArg = + args.find((a) => a.startsWith("--key="))?.split("=")[1] || "sk-omniroute-cli-configured"; + const listArg = args.includes("--list"); + + const baseUrl = urlArg.endsWith("/") ? urlArg.slice(0, -1) : urlArg; + + if (listArg) { + logSection("Available CLI Tools"); + const tools = detectInstalledTools(); + for (const tool of tools) { + const status = tool.installed + ? tool.configured + ? colorize("✓ configured", "green") + : colorize("✗ not configured", "yellow") + : colorize("✗ not installed", "red"); + log(` ${tool.name.padEnd(14)} ${status}`); + } + logEndSection(); + return; + } + + logSection("OmniRoute CLI Setup"); + + // Detect installed tools + const installed = detectInstalledTools().filter((t) => t.installed); + + if (installed.length === 0) { + log("No CLI tools detected. Install Claude Code, Codex, OpenCode, etc.", "yellow"); + return; + } + + log(`Found ${installed.length} installed tools:`, "dim"); + for (const tool of installed) { + log(` - ${tool.name}`); + } + console.log(); + + // Determine which tools to configure + const toolsToConfigure = toolsArg + ? toolsArg.split(",").filter((t) => CLI_TOOLS[t]) + : installed.map((t) => t.id); + + // Configure each tool + log(`Configuring ${toolsToConfigure.length} tool(s)...\n`, "cyan"); + + let successCount = 0; + let failCount = 0; + + for (const toolId of toolsToConfigure) { + const tool = CLI_TOOLS[toolId]; + log(`Configuring ${tool.name}...`, "dim"); + + const result = configureTool(toolId, baseUrl, keyArg); + + if (result.success) { + log(` ✓ Configured: ${result.configPath}`, "green"); + if (result.backupPath) { + log(` Backup: ${result.backupPath}`, "dim"); + } + successCount++; + } else { + log(` ✗ Failed: ${result.error}`, "red"); + failCount++; + } + } + + logEndSection(); + + console.log(); + if (successCount > 0) { + log(`✓ Successfully configured ${successCount} tool(s)`, "green"); + } + if (failCount > 0) { + log(`✗ Failed to configure ${failCount} tool(s)`, "red"); + } + + console.log(); + log("Next steps:", "cyan"); + log(" 1. Test: omniroute test", "dim"); + log(" 2. Status: omniroute status", "dim"); + log(" 3. Start server: omniroute", "dim"); +} + +async function runDoctor(args) { + const verbose = args.includes("--verbose"); + const serverRunning = await checkServerHealth(); + + logSection("OmniRoute Doctor"); + + // Server status + if (serverRunning) { + log("Server: " + colorize("✓ Running", "green")); + log(`API: http://localhost:${API_PORT}/v1`); + log(`Dashboard: http://localhost:${DASHBOARD_PORT}`); + } else { + log("Server: " + colorize("✗ Not running", "red")); + log("Run 'omniroute' to start the server", "dim"); + } + logEndSection(); + + // CLI Tools status + logSection("CLI Tools Status"); + + let tools; + let dataSource = "local"; + + if (serverRunning) { + const apiStatus = await getCliToolsStatusFromApi(); + if (apiStatus) { + dataSource = "api"; + tools = Object.entries(apiStatus).map(([id, data]) => ({ + id, + name: CLI_TOOLS[id]?.name || id, + installed: data.installed, + configured: data.configStatus === "configured", + runnable: data.runnable, + })); + } + } + + if (!tools) { + tools = detectInstalledTools(); + } + + // Sort: configured first, then installed, then not installed + tools.sort((a, b) => { + if (a.configured && !b.configured) return -1; + if (!a.configured && b.configured) return 1; + if (a.installed && !b.installed) return -1; + if (!a.installed && b.installed) return 1; + return 0; + }); + + for (const tool of tools) { + let status; + if (tool.configured) { + status = colorize("✓ configured", "green"); + } else if (tool.installed) { + status = colorize("○ not configured", "yellow"); + } else { + status = colorize("✗ not installed", "red"); + } + log(` ${tool.name.padEnd(12)} ${status}`); + } + + console.log( + `\n${colorize("Data source:", "dim")} ${dataSource === "api" ? "API (accurate)" : "Local detection"}` + ); + logEndSection(); + + // Recommendations + console.log(); + const notConfigured = tools.filter((t) => t.installed && !t.configured); + if (notConfigured.length > 0) { + log("Recommendations:", "cyan"); + log(` Run 'omniroute setup --tools=${notConfigured.map((t) => t.id).join(",")}' to configure`); + } + + if (!serverRunning) { + log(" Run 'omniroute' to start the server for full diagnostics", "dim"); + } + + if (verbose && serverRunning) { + console.log(); + logSection("System Info"); + log(`Node: ${process.version}`); + log(`Platform: ${platform()} ${release()}`); + log(`Home: ${getHomeDir()}`); + log(`Data Dir: ${resolveConfigPath(".omniroute")}`); + logEndSection(); + } +} + +async function runStatus(args) { + const json = args.includes("--json"); + const serverRunning = await checkServerHealth(); + + const config = getOmniRouteConfig(); + const tools = detectInstalledTools(); + const configuredCount = tools.filter((t) => t.configured).length; + const installedCount = tools.filter((t) => t.installed).length; + + if (json) { + console.log( + JSON.stringify( + { + server: { + running: serverRunning, + port: config.port, + url: config.baseUrl, + }, + dashboard: `http://localhost:${config.dashboardPort}`, + config: { + dataDir: config.dataDir, + requireApiKey: config.requireApiKey, + logLevel: config.logLevel, + }, + tools: { + total: Object.keys(CLI_TOOLS).length, + installed: installedCount, + configured: configuredCount, + }, + }, + null, + 2 + ) + ); + return; + } + + logSection("OmniRoute Status"); + log( + `Server: ${serverRunning ? colorize("✓ Running", "green") : colorize("✗ Stopped", "red")}` + ); + log(`API URL: ${config.baseUrl}/v1`); + log(`Dashboard: http://localhost:${config.dashboardPort}`); + log(`Data Dir: ${config.dataDir}`); + logEndSection(); + + logSection("CLI Tools"); + log(`Installed: ${installedCount}`); + log(`Configured: ${configuredCount}`); + console.log(); + + for (const tool of tools) { + const icon = tool.configured + ? colorize("●", "green") + : tool.installed + ? colorize("○", "yellow") + : colorize("×", "red"); + log(` ${icon} ${tool.name}`); + } + logEndSection(); +} + +async function runLogs(args) { + const linesArg = args.find((a) => a.startsWith("--lines="))?.split("=")[1] || "100"; + const levelArg = args.find((a) => a.startsWith("--level="))?.split("=")[1]; + const followArg = args.includes("--follow"); + const jsonArg = args.includes("--json"); + const searchArg = args.find((a) => a.startsWith("--search="))?.split("=")[1]; + + const limit = Math.min(Math.max(parseInt(linesArg) || 100, 10), 1000); + const level = levelArg || null; + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute'", "red"); + return; + } + + if (jsonArg) { + const logs = await getConsoleLogs(limit, level); + console.log(JSON.stringify(logs, null, 2)); + return; + } + + logSection("Console Logs"); + log(`Fetching last ${limit} lines...`, "dim"); + if (level) log(`Filter: ${level}`, "dim"); + if (searchArg) log(`Search: ${searchArg}`, "dim"); + logEndSection(); + + let logs = await getConsoleLogs(limit, level); + + // Search filter + if (searchArg) { + const search = searchArg.toLowerCase(); + logs = logs.filter( + (l) => + (l.msg || l.message || "").toLowerCase().includes(search) || + (l.level || "").toLowerCase().includes(search) + ); + } + + if (logs.length === 0) { + log("No logs found", "yellow"); + return; + } + + for (const entry of logs) { + const timestamp = entry.time || entry.timestamp || ""; + const lvl = entry.level || entry.severity || "info"; + const msg = entry.msg || entry.message || ""; + + let color = "dim"; + if (lvl === "error" || lvl === "fatal") color = "red"; + else if (lvl === "warn") color = "yellow"; + else if (lvl === "debug") color = "dim"; + else color = "reset"; + + console.log(`${colorize(timestamp.slice(0, 24), "dim")} [${lvl.slice(0, 5).padEnd(5)}] ${msg}`); + } + + if (followArg) { + log("\nFollowing logs (Ctrl+C to exit)...", "cyan"); + // Simple polling implementation + let lastTime = logs[logs.length - 1]?.time || ""; + + const interval = setInterval(async () => { + const newLogs = await getConsoleLogs(50, level); + const filtered = newLogs.filter((l) => l.time > lastTime); + for (const entry of filtered) { + const timestamp = entry.time || ""; + const lvl = entry.level || "info"; + const msg = entry.msg || ""; + let color = lvl === "error" ? "red" : lvl === "warn" ? "yellow" : "dim"; + console.log( + `${colorize(timestamp.slice(0, 24), "dim")} [${lvl.slice(0, 5).padEnd(5)}] ${msg}` + ); + lastTime = entry.time; + } + }, 2000); + + // Handle interrupt + process.on("SIGINT", () => { + clearInterval(interval); + log("\nStopped following", "yellow"); + process.exit(0); + }); + } +} + +async function runProvider(args) { + const action = args[0] || "list"; + + if (action === "list") { + logSection("Available Provider Integrations"); + for (const [id, name] of Object.entries({ + opencode: "OpenCode", + cursor: "Cursor", + cline: "Cline", + vscode: "VS Code", + })) { + log(` ${name}`); + } + logEndSection(); + log("\nUsage: omniroute provider add ", "dim"); + return; + } + + if (action === "add") { + const provider = args[1]; + if (!provider || !PROVIDER_HELP[provider]) { + log(`Unknown provider: ${provider}`, "red"); + log("Available: " + Object.keys(PROVIDER_HELP).join(", "), "dim"); + return; + } + + logSection(`Configure ${provider}`); + console.log(PROVIDER_HELP[provider]); + logEndSection(); + return; + } + + log(`Unknown action: ${action}`, "red"); + log("Usage: omniroute provider [list|add ]", "dim"); +} + +async function runConfig(args) { + const action = args[0] || "show"; + + if (action === "show") { + const config = getOmniRouteConfig(); + + logSection("OmniRoute Configuration"); + log(`API Port: ${config.port}`); + log(`Dashboard Port: ${config.dashboardPort}`); + log(`Base URL: ${config.baseUrl}`); + log(`Data Directory: ${config.dataDir}`); + log(`Require API Key: ${config.requireApiKey ? "Yes" : "No"}`); + log(`Log Level: ${config.logLevel}`); + log(`Node Version: ${config.nodeVersion}`); + log(`Platform: ${config.platform} ${config.osRelease}`); + logEndSection(); + return; + } + + log(`Unknown action: ${action}`, "red"); + log("Usage: omniroute config show", "dim"); +} + +async function runTest(args) { + const providerArg = args.find((a) => a.startsWith("--provider="))?.split("=")[1]; + const modelArg = args.find((a) => a.startsWith("--model="))?.split("=")[1]; + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute'", "red"); + return; + } + + const provider = providerArg || "claude"; + const model = modelArg || "claude-sonnet-4-20250514"; + + logSection("Testing Provider Connection"); + log(`Provider: ${provider}`); + log(`Model: ${model}`); + log("Connecting...", "dim"); + console.log(); + + const result = await testProviderConnection(provider, model); + + if (result.success) { + log("✓ Connection successful!", "green"); + log(`Response: ${result.response}`, "dim"); + } else { + log("✗ Connection failed!", "red"); + log(`Error: ${result.error}`, "yellow"); + } + + logEndSection(); +} + +async function runUpdate(args) { + logSection("Checking for Updates"); + + // Get current version + try { + const pkgPath = join(ROOT, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + log(`Current version: ${colorize(pkg.version, "cyan")}`); + } catch { + log("Current version: unknown", "yellow"); + } + + // Get latest version from npm + log("Checking npm...", "dim"); + const npmResult = execCommand("npm view omniroute version", 10000); + + if (npmResult.success) { + const latest = npmResult.output.trim(); + // Try to get current version again for comparison + const pkgPath = join(ROOT, "package.json"); + const current = JSON.parse(readFileSync(pkgPath, "utf8")).version; + + console.log(); + if (latest !== current) { + log(`Latest version: ${colorize(latest, "green")}`); + log(`Update available! Run:`, "yellow"); + log(` npm install -g omniroute@latest`, "dim"); + } else { + log(`Latest version: ${colorize(latest, "green")}`); + log("Already on the latest version!", "green"); + } + } else { + log("Could not check for updates (npm not available)", "yellow"); + } + + logEndSection(); +} + +// ============================================================================ +// PID FILE MANAGEMENT +// ============================================================================ + +function getPidFilePath() { + return join(resolveConfigPath(".omniroute"), "server.pid"); +} + +function writePidFile(pid) { + try { + const dir = dirname(getPidFilePath()); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(getPidFilePath(), String(pid), "utf8"); + return true; + } catch { + return false; + } +} + +function readPidFile() { + try { + const path = getPidFilePath(); + if (!existsSync(path)) return null; + const content = readFileSync(path, "utf8").trim(); + return content ? parseInt(content, 10) : null; + } catch { + return null; + } +} + +function isPidRunning(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function cleanupPidFile() { + try { + const path = getPidFilePath(); + if (existsSync(path)) { + const fs = require("node:fs"); + fs.unlinkSync(path); + } + } catch {} +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForServer(port, timeout = 15000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const res = await fetch(`http://localhost:${port}/api/health`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return true; + } catch {} + await sleep(500); + } + return false; +} + +// ============================================================================ +// SERVER MANAGEMENT COMMANDS +// ============================================================================ + +async function runServe(args) { + const portArg = args.find((a) => a.startsWith("--port="))?.split("=")[1]; + const port = portArg ? parseInt(portArg) : API_PORT; + const daemonArg = args.includes("--daemon"); + + logSection("Starting OmniRoute Server"); + + // Check if already running via PID file + const existingPid = readPidFile(); + if (existingPid && isPidRunning(existingPid)) { + log(`Server already running (PID: ${existingPid})`, "red"); + log(`API: http://localhost:${port}/v1`); + log(`Dashboard: http://localhost:${port + 1}`); + logEndSection(); + return; + } + + // Check if port is in use + const portCheck = execCommand(`lsof -ti:${port} 2>/dev/null || true`, 2000); + if (portCheck.success && portCheck.output.trim()) { + log(`Port ${port} is already in use`, "red"); + log("Stop the existing server or use a different port", "dim"); + logEndSection(); + return; + } + + const appDir = join(ROOT, "app"); + const serverWsJs = join(appDir, "server-ws.mjs"); + const serverJs = existsSync(serverWsJs) ? serverWsJs : join(appDir, "server.js"); + + if (!existsSync(serverJs)) { + log("Server not found. Run 'npm run build' first.", "red"); + logEndSection(); + return; + } + + log(`Starting server on port ${port}...`, "dim"); + + const env = { + ...process.env, + OMNIROUTE_PORT: String(port), + PORT: String(port + 1), + DASHBOARD_PORT: String(port + 1), + API_PORT: String(port), + HOSTNAME: "0.0.0.0", + NODE_ENV: "production", + }; + + const server = spawn("node", [serverJs], { + cwd: appDir, + env, + stdio: daemonArg ? "ignore" : "pipe", + }); + + // Write PID file + writePidFile(server.pid); + + if (daemonArg) { + log(`Server started in background (PID: ${server.pid})`, "green"); + log(`API: http://localhost:${port}/v1`); + log(`Dashboard: http://localhost:${port + 1}`); + } else { + // Wait for server to be ready + const ready = await waitForServer(port); + if (ready) { + log(`Server running!`, "green"); + log(`API: http://localhost:${port}/v1`); + log(`Dashboard: http://localhost:${port + 1}`); + log(""); + log("Press Ctrl+C to stop", "dim"); + } else { + log("Server may not have started properly", "yellow"); + } + } + + logEndSection(); + + if (!daemonArg) { + // Keep process alive, handle shutdown + process.on("SIGINT", () => { + log("\nShutting down...", "yellow"); + server.kill("SIGTERM"); + cleanupPidFile(); + process.exit(0); + }); + + server.on("exit", (code) => { + cleanupPidFile(); + if (code !== 0) { + log(`Server exited with code ${code}`, "red"); + } + process.exit(code || 0); + }); + } +} + +async function runStop(args) { + logSection("Stopping OmniRoute Server"); + + const pid = readPidFile(); + + if (pid && isPidRunning(pid)) { + log(`Sending SIGTERM to PID ${pid}...`, "dim"); + + try { + process.kill(pid, "SIGTERM"); + + // Wait for graceful shutdown + let waited = 0; + while (waited < 5000 && isPidRunning(pid)) { + await sleep(100); + waited += 100; + } + + if (isPidRunning(pid)) { + log("Force killing...", "yellow"); + process.kill(pid, "SIGKILL"); + await sleep(500); + } + + cleanupPidFile(); + log("Server stopped", "green"); + } catch (err) { + log(`Error stopping server: ${err.message}`, "red"); + } + } else { + // Fallback: try to kill by port + log("No PID file, trying port-based cleanup...", "dim"); + + try { + // Try multiple methods + execCommand("lsof -ti:20128 | xargs kill -9 2>/dev/null || true", 2000); + execCommand("lsof -ti:20129 | xargs kill -9 2>/dev/null || true", 2000); + cleanupPidFile(); + log("Server stopped (port-based)", "green"); + } catch { + log("No server running", "yellow"); + } + } + + logEndSection(); +} + +async function runRestart(args) { + logSection("Restarting OmniRoute Server"); + + const portArg = args.find((a) => a.startsWith("--port="))?.split("=")[1] || String(API_PORT); + + // Stop first + await runStop([]); + + // Small delay + await sleep(1000); + + // Start with same port + log("Starting server...", "dim"); + await runServe(["--port=" + portArg]); + + logEndSection(); +} + +// ============================================================================ +// API KEY MANAGEMENT +// ============================================================================ + +const VALID_PROVIDERS = [ + "openai", + "anthropic", + "google", + "deepseek", + "groq", + "mistral", + "xai", + "cohere", + "google-generativeai", + "azure", + "aws", + "bedrock", + "perplexity", + "together", + "fireworks", + "huggingface", + "nvidia", + "cerebras", + "siliconflow", + "nebius", + "openrouter", + "ollama", +]; + +async function runKeys(args) { + const action = args[0]; + + if (!action) { + logSection("API Key Management"); + log("Usage:"); + log(" omniroute keys add "); + log(" omniroute keys list"); + log(" omniroute keys remove "); + log(""); + log(`Valid providers: ${VALID_PROVIDERS.join(", ")}`, "dim"); + logEndSection(); + return; + } + + switch (action) { + case "add": + await runKeysAdd(args.slice(1)); + break; + case "list": + await runKeysList(args.slice(1)); + break; + case "remove": + await runKeysRemove(args.slice(1)); + break; + default: + log(`Unknown action: ${action}`, "red"); + log("Valid actions: add, list, remove", "dim"); + } +} + +async function runKeysAdd(args) { + const provider = args[0]; + const apiKey = args[1]; + + if (!provider || !apiKey) { + log("Usage: omniroute keys add ", "red"); + return; + } + + const providerLower = provider.toLowerCase(); + if (!VALID_PROVIDERS.includes(providerLower)) { + log(`Invalid provider. Valid: ${VALID_PROVIDERS.join(", ")}`, "red"); + return; + } + + logSection(`Adding API Key for ${provider}`); + + // Try API first + const serverRunning = await checkServerHealth(); + + if (serverRunning) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/v1/providers/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerLower, apiKey }), + }); + + if (res.ok) { + log(`API key for ${provider} added successfully via API`, "green"); + logEndSection(); + return; + } + } catch {} + } + + // Direct DB fallback + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + // Check if connection exists + const existing = db + .prepare("SELECT id FROM provider_connections WHERE provider_id = ?") + .get(providerLower); + + if (existing) { + db.prepare("UPDATE provider_connections SET api_key = ? WHERE provider_id = ?").run( + apiKey, + providerLower + ); + log(`API key for ${provider} updated`, "green"); + } else { + db.prepare( + "INSERT INTO provider_connections (provider_id, api_key, name, enabled) VALUES (?, ?, ?, 1)" + ).run(providerLower, apiKey, provider); + log(`API key for ${provider} added`, "green"); + } + + db.close(); + } catch (err) { + log(`Failed to save key: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runKeysList(args) { + logSection("Configured API Keys"); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "yellow"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const keys = db + .prepare( + ` + SELECT provider_id, api_key, enabled + FROM provider_connections + WHERE api_key IS NOT NULL AND api_key != '' + ` + ) + .all(); + + db.close(); + + if (keys.length === 0) { + log("No API keys configured", "yellow"); + } else { + for (const key of keys) { + const masked = + key.api_key && key.api_key.length > 8 + ? key.api_key.slice(0, 6) + "***" + key.api_key.slice(-4) + : "***"; + const status = key.enabled + ? colorize("● enabled", "green") + : colorize("○ disabled", "yellow"); + log(` ${key.provider_id.padEnd(20)} ${masked.padEnd(20)} ${status}`); + } + } + } catch (err) { + log(`Error reading keys: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runKeysRemove(args) { + const provider = args[0]; + + if (!provider) { + log("Usage: omniroute keys remove ", "red"); + return; + } + + logSection(`Removing API Key for ${provider}`); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const result = db + .prepare("DELETE FROM provider_connections WHERE provider_id = ?") + .run(provider.toLowerCase()); + + if (result.changes > 0) { + log(`API key for ${provider} removed`, "green"); + } else { + log(`No API key found for ${provider}`, "yellow"); + } + + db.close(); + } catch (err) { + log(`Failed to remove key: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// MODEL BROWSER +// ============================================================================ + +async function runModels(args) { + const providerFilter = args[0] && !args[0].startsWith("--") ? args[0] : null; + const jsonOutput = args.includes("--json"); + const searchQuery = args.find((a) => a.startsWith("--search="))?.split("=")[1]; + + logSection("Available Models"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve' or 'omniroute'", "red"); + logEndSection(); + return; + } + + try { + // Try various endpoints + let models = []; + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/models`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const data = await res.json(); + models = data.models || data || []; + } + } catch {} + + // Fallback: try provider registry + if (models.length === 0) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/v1/models`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + models = await res.json(); + } + } catch {} + } + + // Filter by provider if specified + if (providerFilter) { + const filter = providerFilter.toLowerCase(); + models = models.filter( + (m) => + (m.provider && m.provider.toLowerCase().includes(filter)) || + (m.id && m.id.toLowerCase().startsWith(filter)) || + (m.name && m.name.toLowerCase().includes(filter)) + ); + } + + // Search filter + if (searchQuery) { + const search = searchQuery.toLowerCase(); + models = models.filter( + (m) => + (m.id && m.id.toLowerCase().includes(search)) || + (m.name && m.name.toLowerCase().includes(search)) || + (m.provider && m.provider.toLowerCase().includes(search)) || + (m.description && m.description.toLowerCase().includes(search)) + ); + } + + if (jsonOutput) { + console.log(JSON.stringify(models, null, 2)); + logEndSection(); + return; + } + + if (models.length === 0) { + log("No models found", "yellow"); + logEndSection(); + return; + } + + // Display as formatted table + console.log(); + console.log(colorize(" Model".padEnd(45) + "Provider".padEnd(20) + "Context", "cyan")); + console.log( + colorize(" " + "─".repeat(44) + " " + "─".repeat(19) + " " + "─".repeat(10), "dim") + ); + + const displayModels = models.slice(0, 50); + for (const model of displayModels) { + const name = (model.id || model.name || "unknown").slice(0, 43); + const provider = (model.provider || "unknown").slice(0, 18); + const context = model.context_length || model.max_tokens || model.contextWindow || "-"; + console.log(` ${name.padEnd(45)}${provider.padEnd(20)}${String(context).padEnd(10)}`); + } + + console.log(); + if (models.length > 50) { + log(`... and ${models.length - 50} more models. Use --json for full list.`, "dim"); + } + + log(`Total: ${models.length} models`, "green"); + } catch (err) { + log(`Failed to fetch models: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// COMBO MANAGEMENT +// ============================================================================ + +async function runCombo(args) { + const action = args[0]; + + if (!action) { + logSection("Combo Management"); + log("Usage:"); + log(" omniroute combo list"); + log(" omniroute combo switch "); + log(" omniroute combo create "); + log(" omniroute combo delete "); + log(""); + log("Strategies: priority, weighted, round-robin, p2c, random, auto, lkgp", "dim"); + logEndSection(); + return; + } + + switch (action) { + case "list": + await runComboList(args.slice(1)); + break; + case "switch": + await runComboSwitch(args.slice(1)); + break; + case "create": + await runComboCreate(args.slice(1)); + break; + case "delete": + await runComboDelete(args.slice(1)); + break; + default: + log(`Unknown action: ${action}`, "red"); + log("Valid actions: list, switch, create, delete", "dim"); + } +} + +async function runComboList(args) { + const jsonOutput = args.includes("--json"); + + logSection("Routing Combos"); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "yellow"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const combos = db + .prepare( + ` + SELECT id, name, strategy, enabled, target_count + FROM combos + ORDER BY name + ` + ) + .all(); + + // Get active combo + let activeCombo = null; + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/combos/active`, { + signal: AbortSignal.timeout(3000), + }); + if (res.ok) { + const data = await res.json(); + activeCombo = data.active || data.name || data.combo; + } + } catch {} + + db.close(); + + if (jsonOutput) { + console.log(JSON.stringify({ combos, active: activeCombo }, null, 2)); + logEndSection(); + return; + } + + if (combos.length === 0) { + log("No combos configured", "yellow"); + } else { + for (const combo of combos) { + const isActive = activeCombo && (combo.name === activeCombo || combo.id === activeCombo); + const icon = isActive ? colorize("●", "green") : colorize("○", "dim"); + const status = combo.enabled ? colorize("enabled", "green") : colorize("disabled", "red"); + const strategy = combo.strategy || "priority"; + console.log(` ${icon} ${combo.name.padEnd(25)} [${strategy.padEnd(12)}] ${status}`); + } + } + } catch (err) { + log(`Error reading combos: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runComboSwitch(args) { + const name = args[0]; + + if (!name) { + log("Usage: omniroute combo switch ", "red"); + return; + } + + logSection(`Switching to Combo: ${name}`); + + // Try API first + const serverRunning = await checkServerHealth(); + + if (serverRunning) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/combos/switch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + if (res.ok) { + log(`Switched to combo '${name}'`, "green"); + logEndSection(); + return; + } + } catch {} + } + + // Direct DB fallback + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + // Check combo exists + const combo = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); + + if (!combo) { + log(`Combo '${name}' not found`, "red"); + db.close(); + logEndSection(); + return; + } + + // Update settings to set active combo + const settingsPath = join(dataDir, "settings.json"); + let settings = {}; + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(readFileSync(settingsPath, "utf8")); + } catch {} + } + + settings.activeCombo = name; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8"); + + log(`Switched to combo '${name}'`, "green"); + db.close(); + } catch (err) { + log(`Failed to switch combo: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runComboCreate(args) { + const name = args[0]; + const strategy = args[1] || "priority"; + + if (!name) { + log("Usage: omniroute combo create [strategy]", "red"); + return; + } + + const validStrategies = [ + "priority", + "weighted", + "round-robin", + "p2c", + "random", + "auto", + "lkgp", + "context-optimized", + "context-relay", + ]; + if (!validStrategies.includes(strategy)) { + log(`Invalid strategy. Valid: ${validStrategies.join(", ")}`, "red"); + return; + } + + logSection(`Creating Combo: ${name}`); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + // Check if combo already exists + const existing = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); + + if (existing) { + log(`Combo '${name}' already exists. Use 'combo delete' first.`, "red"); + db.close(); + logEndSection(); + return; + } + + // Insert new combo + db.prepare( + ` + INSERT INTO combos (name, strategy, enabled, target_count) + VALUES (?, ?, 1, 0) + ` + ).run(name, strategy); + + log(`Combo '${name}' created with strategy '${strategy}'`, "green"); + log("Use 'omniroute combo switch " + name + "' to activate", "dim"); + db.close(); + } catch (err) { + log(`Failed to create combo: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runComboDelete(args) { + const name = args[0]; + + if (!name) { + log("Usage: omniroute combo delete ", "red"); + return; + } + + logSection(`Deleting Combo: ${name}`); + + const dataDir = resolveConfigPath(".omniroute"); + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + log("Database not found. Start server first.", "red"); + logEndSection(); + return; + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const result = db.prepare("DELETE FROM combos WHERE name = ?").run(name); + + if (result.changes > 0) { + log(`Combo '${name}' deleted`, "green"); + } else { + log(`Combo '${name}' not found`, "yellow"); + } + + db.close(); + } catch (err) { + log(`Failed to delete combo: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// SHELL COMPLETION +// ============================================================================ + +const VALID_SHELLS = ["bash", "zsh", "fish"]; + +async function runCompletion(args) { + const shell = args[0]; + + if (!shell) { + logSection("Shell Completion"); + log("Usage:"); + log(" omniroute completion bash"); + log(" omniroute completion zsh"); + log(" omniroute completion fish"); + log(""); + log("To install:"); + log(" bash: omniroute completion bash > ~/.bash_completion"); + log(" zsh: omniroute completion zsh > ~/.zsh/completions/_omniroute", "dim"); + logEndSection(); + return; + } + + if (!VALID_SHELLS.includes(shell)) { + log(`Invalid shell. Valid: ${VALID_SHELLS.join(", ")}`, "red"); + return; + } + + switch (shell) { + case "bash": + console.log(generateBashCompletion()); + break; + case "zsh": + console.log(generateZshCompletion()); + break; + case "fish": + console.log(generateFishCompletion()); + break; + } +} + +function generateBashCompletion() { + const script = `#!/bin/bash +# OmniRoute CLI Bash Completion + +_omniroute() { + local cur prev opts cmds + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + + opts="--help --version" + cmds="setup doctor status logs provider config test update serve stop restart keys models combo completion dashboard" + + # Command-specific options + case "\${prev}" in + setup) + COMPREPLY=($(compgen -W "--tools --url --key --list" -- \${cur})) + return 0 + ;; + logs) + COMPREPLY=($(compgen -W "--lines --level --follow" -- \${cur})) + return 0 + ;; + doctor) + COMPREPLY=($(compgen -W "--verbose" -- \${cur})) + return 0 + ;; + status) + COMPREPLY=($(compgen -W "--json" -- \${cur})) + return 0 + ;; + keys) + COMPREPLY=($(compgen -W "add list remove" -- \${cur})) + return 0 + ;; + keys add) + COMPREPLY=($(compgen -W "openai anthropic google deepseek groq mistral xai cohere" -- \${cur})) + return 0 + ;; + models) + COMPREPLY=($(compgen -W "--json openai anthropic google deepseek groq" -- \${cur})) + return 0 + ;; + combo) + COMPREPLY=($(compgen -W "list switch create delete" -- \${cur})) + return 0 + ;; + provider) + COMPREPLY=($(compgen -W "list add" -- \${cur})) + return 0 + ;; + completion) + COMPREPLY=($(compgen -W "bash zsh fish" -- \${cur})) + return 0 + ;; + serve) + COMPREPLY=($(compgen -W "--port --daemon" -- \${cur})) + return 0 + ;; + test) + COMPREPLY=($(compgen -W "--provider --model" -- \${cur})) + return 0 + ;; + dashboard) + COMPREPLY=($(compgen -W "--url" -- \${cur})) + return 0 + ;; + config) + COMPREPLY=($(compgen -W "show" -- \${cur})) + return 0 + ;; + *) + COMPREPLY=($(compgen -W "\${cmds} \${opts}" -- \${cur})) + return 0 + ;; + esac +} + +complete -F _omniroute omniroute +`; + return script; +} + +function generateZshCompletion() { + return `#compdef omniroute + +local -a commands +commands=( + 'setup:Configure CLI tools to use OmniRoute' + 'doctor:Run health diagnostics' + 'status:Show server and tools status' + 'logs:View application logs' + 'provider:Add OmniRoute as provider' + 'config:Show configuration' + 'test:Test provider connectivity' + 'update:Check for updates' + 'serve:Start the server' + 'stop:Stop the server' + 'restart:Restart the server' + 'keys:Manage API keys' + 'models:Browse available models' + 'combo:Manage routing combos' + 'completion:Generate shell completion' + 'dashboard:Open dashboard' +) + +_arguments -C \\ + '1: :->command' \\ + '*:: :->arg' \\ + && return 0 + +case $state in + command) + _describe 'command' commands + ;; + arg) + case $words[1] in + setup) + _arguments '--tools[Tools to configure]:tools:(claude codex opencode cline kilo continue openclaw)' '--url[Base URL]:url:' '--key[API Key]:key:' '--list[List available tools' + ;; + keys) + case $words[2] in + add) + _arguments '2:provider:(openai anthropic google deepseek groq mistral xai cohere)' '3:api-key:' + ;; + remove) + _arguments '2:provider:(openai anthropic google deepseek groq mistral xai cohere)' + ;; + *) + _describe 'subcommand' 'add:Add API key' 'list:List keys' 'remove:Remove key' + ;; + esac + ;; + combo) + _describe 'subcommand' 'list:List combos' 'switch:Switch combo' 'create:Create combo' 'delete:Delete combo' + ;; + completion) + _arguments '2:shell:(bash zsh fish)' + ;; + serve) + _arguments '--port[Port number]:port:' '--daemon[Run in background' + ;; + models) + _arguments '--json[JSON output]' '2:provider:(openai anthropic google deepseek groq)' + ;; + logs) + _arguments '--lines[Number of lines]:lines:' '--level[Log level]:level:(debug info warn error)' '--follow[Follow logs]' + ;; + esac + ;; +esac +`; +} + +function generateFishCompletion() { + return `# OmniRoute CLI Fish Completion + +complete -c omniroute -f + +# Main commands +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'setup' -d 'Configure CLI tools' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'doctor' -d 'Run health diagnostics' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'status' -d 'Show status' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'logs' -d 'View logs' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'provider' -d 'Provider management' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'config' -d 'Show config' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'test' -d 'Test connectivity' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'update' -d 'Check updates' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'serve' -d 'Start server' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'stop' -d 'Stop server' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'restart' -d 'Restart server' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'keys' -d 'API key management' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'models' -d 'Browse models' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'combo' -d 'Combo management' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'completion' -d 'Shell completion' +complete -c omniroute -n '__fish_is_nth_arg 1' -a 'dashboard' -d 'Open dashboard' + +# Subcommands +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add' -d 'Add key' +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'list' -d 'List keys' +complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'remove' -d 'Remove key' + +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list' -d 'List combos' +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'switch' -d 'Switch combo' +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'create' -d 'Create combo' +complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'delete' -d 'Delete combo' + +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'bash' -d 'Bash completion' +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh' -d 'Zsh completion' +complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'fish' -d 'Fish completion' +`; +} + +// ============================================================================ +// DASHBOARD COMMAND +// ============================================================================ + +async function runDashboard(args) { + const urlOnly = args.includes("--url"); + + const dashboardUrl = `http://localhost:${DASHBOARD_PORT}`; + + if (urlOnly) { + console.log(dashboardUrl); + return; + } + + logSection("Opening Dashboard"); + + try { + const { execSync } = require("node:child_process"); + const platform = process.platform; + + let command; + if (platform === "darwin") { + command = `open "${dashboardUrl}"`; + } else if (platform === "win32") { + command = `start "" "${dashboardUrl}"`; + } else { + command = `xdg-open "${dashboardUrl}" 2>/dev/null || sensible-browser "${dashboardUrl}" 2>/dev/null || echo "Cannot open browser. Go to: ${dashboardUrl}"`; + } + + execSync(command, { stdio: "ignore" }); + log(`Opening: ${dashboardUrl}`, "green"); + } catch { + log(`Open in browser: ${dashboardUrl}`, "yellow"); + } + + logEndSection(); +} + +// ============================================================================ +// BACKUP & RESTORE +// ============================================================================ + +async function runBackup(args) { + const dataDir = resolveConfigPath(".omniroute"); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const backupDir = join(dataDir, "backups"); + const backupName = `omniroute-backup-${timestamp}`; + const backupPath = join(backupDir, backupName); + + logSection("Creating Backup"); + + try { + // Ensure backup directory exists + if (!existsSync(backupDir)) { + mkdirSync(backupDir, { recursive: true }); + } + + // Files to backup + const filesToBackup = [ + { name: "storage.sqlite", dest: "storage.sqlite" }, + { name: "settings.json", dest: "settings.json" }, + { name: "combos.json", dest: "combos.json" }, + { name: "providers.json", dest: "providers.json" }, + ]; + + let backedUp = 0; + let skipped = 0; + + for (const file of filesToBackup) { + const sourcePath = join(dataDir, file.name); + if (existsSync(sourcePath)) { + const destPath = join(backupPath, file.dest); + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(sourcePath, destPath); + backedUp++; + } else { + skipped++; + } + } + + if (backedUp > 0) { + // Create backup info + const info = { + timestamp: new Date().toISOString(), + version: "omniroute-cli-v1", + files: filesToBackup.filter((f) => existsSync(join(dataDir, f.name))).map((f) => f.name), + }; + writeFileSync(join(backupPath, "backup-info.json"), JSON.stringify(info, null, 2), "utf8"); + + log(`Backup created: ${backupName}`, "green"); + log(`Files: ${backedUp} backed up, ${skipped} skipped`, "dim"); + log(`Location: ${backupPath}`, "dim"); + } else { + log("No files to backup (database not initialized)", "yellow"); + } + } catch (err) { + log(`Backup failed: ${err.message}`, "red"); + } + + logEndSection(); +} + +async function runRestore(args) { + const backupName = args[0]; + const dataDir = resolveConfigPath(".omniroute"); + const backupDir = join(dataDir, "backups"); + + if (!backupName) { + logSection("Available Backups"); + if (!existsSync(backupDir)) { + log("No backups found", "yellow"); + logEndSection(); + return; + } + + try { + const dirs = readdirSync(backupDir).filter((f) => f.startsWith("omniroute-backup-")); + if (dirs.length === 0) { + log("No backups found", "yellow"); + } else { + for (const dir of dirs.sort().reverse()) { + const infoPath = join(backupDir, dir, "backup-info.json"); + if (existsSync(infoPath)) { + const info = JSON.parse(readFileSync(infoPath, "utf8")); + log(` ${dir.replace("omniroute-backup-", "")}`); + log( + ` ${new Date(info.timestamp).toLocaleString()} - ${info.files?.length || 0} files`, + "dim" + ); + } else { + log(` ${dir.replace("omniroute-backup-", "")}`, "dim"); + } + } + } + } catch (err) { + log(`Error listing backups: ${err.message}`, "red"); + } + logEndSection(); + console.log("Usage: omniroute restore "); + return; + } + + logSection(`Restoring from: ${backupName}`); + + const backupPath = join(backupDir, `omniroute-backup-${backupName}`); + + if (!existsSync(backupPath)) { + log(`Backup not found: ${backupName}`, "red"); + logEndSection(); + return; + } + + try { + // Restore files + const filesToRestore = [ + { name: "storage.sqlite", dest: "storage.sqlite" }, + { name: "settings.json", dest: "settings.json" }, + { name: "combos.json", dest: "combos.json" }, + { name: "providers.json", dest: "providers.json" }, + ]; + + for (const file of filesToRestore) { + const sourcePath = join(backupPath, file.dest); + if (existsSync(sourcePath)) { + const destPath = join(dataDir, file.name); + copyFileSync(sourcePath, destPath); + log(`Restored: ${file.name}`, "dim"); + } + } + + log("Backup restored successfully!", "green"); + log("Restart OmniRoute to load restored data", "dim"); + } catch (err) { + log(`Restore failed: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// QUOTA MANAGEMENT +// ============================================================================ + +async function runQuota(args) { + const jsonOutput = args.includes("--json"); + + logSection("Provider Quota Usage"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + try { + // Try quota endpoint + let quotaData = null; + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/quota`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + quotaData = await res.json(); + } + } catch {} + + // Fallback: get from providers + if (!quotaData) { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/v1/providers`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const providers = await res.json(); + quotaData = { + providers: providers.map((p) => ({ + provider: p.name || p.id, + quota: p.quota || p.remaining || "N/A", + used: p.used || 0, + reset: p.resetAt || "N/A", + })), + }; + } + } catch {} + } + + if (jsonOutput) { + console.log(JSON.stringify(quotaData || { error: "No quota data" }, null, 2)); + logEndSection(); + return; + } + + if (!quotaData?.providers) { + log("No quota information available", "yellow"); + logEndSection(); + return; + } + + console.log(); + console.log( + colorize( + " Provider".padEnd(25) + "Used".padEnd(15) + "Remaining".padEnd(20) + "Reset", + "cyan" + ) + ); + console.log( + colorize( + " " + "─".repeat(24) + " " + "─".repeat(14) + " " + "─".repeat(19) + " " + "─".repeat(15), + "dim" + ) + ); + + for (const p of quotaData.providers) { + const provider = (p.provider || "unknown").slice(0, 23); + const used = String(p.used || 0).padEnd(14); + const remaining = (p.quota || p.remaining || "N/A").toString().slice(0, 18); + const reset = p.reset || "N/A"; + console.log(` ${provider.padEnd(25)}${used.padEnd(15)}${remaining.padEnd(20)}${reset}`); + } + + log(`Total: ${quotaData.providers.length} providers`, "green"); + } catch (err) { + log(`Failed to fetch quota: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// HEALTH STATUS +// ============================================================================ + +async function runHealth(args) { + const verbose = args.includes("--verbose"); + const jsonOutput = args.includes("--json"); + + logSection("OmniRoute Health"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/health`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const health = await res.json(); + + if (jsonOutput) { + console.log(JSON.stringify(health, null, 2)); + logEndSection(); + return; + } + + // Display health info + log(`Status: ${colorize("healthy", "green")}`); + log(`Uptime: ${health.uptime || "N/A"}`); + log(`Version: ${health.version || "N/A"}`); + + if (health.breakers) { + console.log(); + logSection("Circuit Breakers"); + for (const [name, status] of Object.entries(health.breakers)) { + const state = + status.state === "closed" + ? colorize("● closed", "green") + : colorize("○ open", "yellow"); + log(` ${name.padEnd(20)} ${state}`); + } + } + + if (health.cache) { + console.log(); + logSection("Cache Status"); + log(` Semantic: ${health.cache.semanticHits || 0} hits`); + log(` Signature: ${health.cache.signatureHits || 0} hits`); + } + + if (verbose && health.memory) { + console.log(); + logSection("Memory"); + log(` RSS: ${health.memory.rss || "N/A"}`); + log(` Heap Used: ${health.memory.heapUsed || "N/A"}`); + } + } + } catch (err) { + log(`Failed to get health: ${err.message}`, "red"); + } + + logEndSection(); +} + +// ============================================================================ +// CACHE MANAGEMENT +// ============================================================================ + +async function runCache(args) { + const action = args[0]; + + if (!action || action === "status") { + logSection("Cache Status"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "yellow"); + logEndSection(); + return; + } + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/cache/stats`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const stats = await res.json(); + log(`Semantic Cache: ${stats.semanticHits || 0} hits`); + log(`Signature Cache: ${stats.signatureHits || 0} hits`); + } else { + log("Cache stats not available", "yellow"); + } + } catch { + log("Cache stats not available", "yellow"); + } + logEndSection(); + return; + } + + if (action === "clear") { + logSection("Clearing Cache"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Cannot clear cache.", "red"); + logEndSection(); + return; + } + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/cache/clear`, { + method: "POST", + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + log("Cache cleared successfully!", "green"); + } else { + log("Failed to clear cache", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + logEndSection(); + return; + } + + log(`Unknown cache action: ${action}`, "red"); + log("Valid actions: status, clear", "dim"); +} + +// ============================================================================ +// MCP SERVER STATUS +// ============================================================================ + +async function runMcp(args) { + const action = args[0] || "status"; + + logSection("MCP Server"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + if (action === "status" || action === "list") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/mcp/status`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const status = await res.json(); + log( + `Status: ${status.running ? colorize("running", "green") : colorize("stopped", "red")}` + ); + log(`Tools: ${status.toolsCount || 0}`); + log(`Transport: ${status.transport || "stdio"}`); + + if (status.scopes) { + console.log(); + log("Scopes:"); + for (const scope of status.scopes) { + log(` - ${scope}`); + } + } + } else { + log("MCP status not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "restart") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/mcp/restart`, { + method: "POST", + signal: AbortSignal.timeout(10000), + }); + + if (res.ok) { + log("MCP server restarted", "green"); + } else { + log("Failed to restart MCP", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else { + log(`Unknown action: ${action}`, "red"); + log("Valid actions: status, list, restart", "dim"); + } + + logEndSection(); +} + +// ============================================================================ +// A2A SERVER STATUS +// ============================================================================ + +async function runA2a(args) { + const action = args[0] || "status"; + + logSection("A2A Server"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + if (action === "status" || action === "list") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/a2a/status`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const status = await res.json(); + log( + `Status: ${status.running ? colorize("running", "green") : colorize("stopped", "red")}` + ); + log(`Protocol: ${status.protocol || "JSON-RPC 2.0"}`); + log(`Tasks: ${status.activeTasks || 0} active`); + + if (status.skills) { + console.log(); + log("Skills:"); + for (const skill of status.skills) { + log(` - ${skill.name}: ${skill.description || "N/A"}`, "dim"); + } + } + } else { + log("A2A status not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "card") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/.well-known/agent.json`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const card = await res.json(); + console.log(JSON.stringify(card, null, 2)); + } else { + log("Agent card not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else { + log(`Unknown action: ${action}`, "red"); + log("Valid actions: status, list, card", "dim"); + } + + logEndSection(); +} + +// ============================================================================ +// TUNNEL MANAGEMENT +// ============================================================================ + +async function runTunnel(args) { + const action = args[0]; + + logSection("Tunnel Management"); + + const serverRunning = await checkServerHealth(); + + if (!serverRunning) { + log("Server not running. Start with 'omniroute serve'", "red"); + logEndSection(); + return; + } + + if (!action || action === "list") { + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/tunnels`, { + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const tunnels = await res.json(); + + if (tunnels.length === 0) { + log("No active tunnels", "yellow"); + } else { + for (const t of tunnels) { + const status = t.active ? colorize("● active", "green") : colorize("○ inactive", "dim"); + log(` ${t.type || "unknown"}: ${t.url || "N/A"} ${status}`); + } + } + } else { + log("Tunnel info not available", "yellow"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "create" || action === "add") { + const tunnelType = args[1] || "cloudflare"; + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/tunnels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: tunnelType }), + signal: AbortSignal.timeout(15000), + }); + + if (res.ok) { + const result = await res.json(); + log(`Tunnel created: ${result.url}`, "green"); + } else { + log("Failed to create tunnel", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else if (action === "stop" || action === "delete") { + const tunnelType = args[1]; + + try { + const res = await fetch(`${DEFAULT_BASE_URL}/api/tunnels/${tunnelType}`, { + method: "DELETE", + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + log(`Tunnel ${tunnelType} stopped`, "green"); + } else { + log("Failed to stop tunnel", "red"); + } + } catch (err) { + log(`Error: ${err.message}`, "red"); + } + } else { + log("Valid actions: list, create , stop "); + log("Types: cloudflare, tailscale, ngrok", "dim"); + } + + logEndSection(); +} + +// ============================================================================ +// ENVIRONMENT VARIABLES +// ============================================================================ + +async function runEnv(args) { + const action = args[0]; + + if (!action || action === "show" || action === "list") { + logSection("Environment Variables"); + + const importantVars = [ + "PORT", + "API_PORT", + "DASHBOARD_PORT", + "DATA_DIR", + "REQUIRE_API_KEY", + "LOG_LEVEL", + "NODE_ENV", + "REQUEST_TIMEOUT_MS", + "ENABLE_SOCKS5_PROXY", + ]; + + log("Current configuration:"); + console.log(); + + for (const key of importantVars) { + const value = process.env[key]; + if (value !== undefined) { + log(` ${key.padEnd(25)} ${value}`, "dim"); + } + } + + console.log(); + log("Defaults:", "dim"); + log(" PORT 20128"); + log(" DASHBOARD_PORT 20129"); + log(" DATA_DIR ~/.omniroute"); + logEndSection(); + return; + } + + if (action === "get") { + const key = args[1]; + if (!key) { + log("Usage: omniroute env get ", "red"); + return; + } + console.log(process.env[key] || ""); + return; + } + + if (action === "set") { + const key = args[1]; + const value = args[2]; + + if (!key || value === undefined) { + log("Usage: omniroute env set ", "red"); + return; + } + + log(`Setting ${key}=${value} (temporary - only affects current session)`, "yellow"); + process.env[key] = value; + log("Set successfully (note: this is temporary)", "green"); + return; + } + + log("Valid actions: show, get , set ", "dim"); +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index 8202818956..1ce5ca86f7 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -106,6 +106,67 @@ if (args.includes("--help") || args.includes("-h")) { omniroute --no-open Don't open browser automatically omniroute --mcp Start MCP server (stdio transport for IDEs) omniroute reset-encrypted-columns Reset encrypted credentials (recovery) + + \x1b[1mServer Management:\x1b[0m + omniroute serve Start the OmniRoute server + omniroute stop Stop the running server + omniroute restart Restart the server + omniroute dashboard Open dashboard in browser + omniroute open Alias for dashboard (same as dashboard) + + \x1b[1mCLI Integration Suite:\x1b[0m + omniroute setup Interactive wizard to configure CLI tools + omniroute doctor Run health diagnostics + omniroute status Show comprehensive status + omniroute logs Stream request logs (--json, --search, --follow) + omniroute config show Display current configuration + + \x1b[1mProvider & Keys:\x1b[0m + omniroute provider list List available providers + omniroute provider add Add OmniRoute as provider + omniroute keys add Add API key for provider + omniroute keys list List configured API keys + omniroute keys remove Remove API key + + \x1b[1mModels & Combos:\x1b[0m + omniroute models List available models (--json, --search) + omniroute models Filter models by provider + omniroute combo list List routing combos + omniroute combo switch Switch active combo + omniroute combo create Create new combo + omniroute combo delete Delete a combo + + \x1b[1mBackup & Restore:\x1b[0m + omniroute backup Create backup of config & DB + omniroute restore Restore from backup (list or specify timestamp) + + \x1b[1mMonitoring:\x1b[0m + omniroute health Detailed health (breakers, cache, memory) + omniroute quota Show provider quota usage + omniroute cache Show cache status + omniroute cache clear Clear semantic/signature cache + + \x1b[1mProtocols:\x1b[0m + omniroute mcp status MCP server status + omniroute mcp restart Restart MCP server + omniroute a2a status A2A server status + omniroute a2a card Show A2A agent card + + \x1b[1mTunnels & Network:\x1b[0m + omniroute tunnel list List active tunnels + omniroute tunnel create Create tunnel (cloudflare/tailscale/ngrok) + omniroute tunnel stop Stop a tunnel + + \x1b[1mEnvironment:\x1b[0m + omniroute env show Show environment variables + omniroute env get Get specific env var + omniroute env set Set env var (temporary) + + \x1b[1mTools & Utils:\x1b[0m + omniroute test Test provider connectivity + omniroute update Check for updates + omniroute completion Generate shell completion + omniroute --help Show this help omniroute --version Show version @@ -160,6 +221,24 @@ if (args.includes("--version") || args.includes("-v")) { process.exit(0); } +// ── CLI Integration Suite subcommands ─────────────────────────────────────── +const subcommands = [ + "setup", "doctor", "status", "logs", "provider", "config", "test", "update", + "serve", "stop", "restart", + "keys", "models", "combo", + "completion", "dashboard", + "backup", "restore", "quota", "health", + "cache", "mcp", "a2a", "tunnel", + "env", "open" +]; +const subcommand = args[0]; + +if (subcommands.includes(subcommand)) { + const { runSubcommand } = await import("./cli-commands.mjs"); + await runSubcommand(subcommand, args.slice(1)); + process.exit(0); +} + // ── reset-encrypted-columns subcommand ────────────────────────────────────── // Recovery tool for users who lost STORAGE_ENCRYPTION_KEY after upgrade (#1622) if (args.includes("reset-encrypted-columns")) { diff --git a/src/app/(dashboard)/dashboard/cloud-agents/page.tsx b/src/app/(dashboard)/dashboard/cloud-agents/page.tsx new file mode 100644 index 0000000000..ff803e4a0a --- /dev/null +++ b/src/app/(dashboard)/dashboard/cloud-agents/page.tsx @@ -0,0 +1,478 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, Button, Input, Badge } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface CloudAgentTask { + id: string; + provider: string; + status: "pending" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled"; + description: string; + createdAt: string; + updatedAt: string; + result?: string; + error?: string; + plan?: string; + messages: Array<{ role: string; content: string; timestamp: string }>; +} + +const CLOUD_AGENTS = [ + { + id: "jules", + name: "Jules", + provider: "Google", + description: "Google's autonomous coding agent", + icon: "🟡", + color: "bg-yellow-500/10 text-yellow-600", + }, + { + id: "devin", + name: "Devin", + provider: "Cognition", + description: "Cognition's AI software engineer", + icon: "🔵", + color: "bg-blue-500/10 text-blue-600", + }, + { + id: "codex-cloud", + name: "Codex Cloud", + provider: "OpenAI", + description: "OpenAI's cloud-based coding agent", + icon: "⚡", + color: "bg-emerald-500/10 text-emerald-600", + }, +]; + +export default function CloudAgentsPage() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); + const [newTask, setNewTask] = useState({ + provider: "jules", + description: "", + }); + const [messageInput, setMessageInput] = useState(""); + const t = useTranslations("cloudAgents"); + + const fetchTasks = useCallback(async () => { + try { + const res = await fetch("/api/v1/agents/tasks"); + if (res.ok) { + const data = await res.json(); + setTasks(data.tasks || []); + } + } catch (err) { + console.error("Failed to fetch tasks:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + const handleCreateTask = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); + try { + const res = await fetch("/api/v1/agents/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: newTask.provider, + description: newTask.description, + }), + }); + if (res.ok) { + const data = await res.json(); + setTasks((prev) => [data.task, ...prev]); + setNewTask({ provider: "jules", description: "" }); + } + } catch (err) { + console.error("Failed to create task:", err); + } finally { + setCreating(false); + } + }; + + const handleSendMessage = async () => { + if (!selectedTask || !messageInput.trim()) return; + try { + const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "message", + message: messageInput, + }), + }); + if (res.ok) { + const data = await res.json(); + setSelectedTask(data.task); + setTasks((prev) => prev.map((task) => (task.id === selectedTask.id ? data.task : task))); + setMessageInput(""); + } + } catch (err) { + console.error("Failed to send message:", err); + } + }; + + const handleApprovePlan = async () => { + if (!selectedTask) return; + try { + const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "approve" }), + }); + if (res.ok) { + const data = await res.json(); + setSelectedTask(data.task); + setTasks((prev) => prev.map((t) => (t.id === selectedTask.id ? data.task : t))); + } + } catch (err) { + console.error("Failed to approve plan:", err); + } + }; + + const handleCancelTask = async (taskId: string) => { + try { + const res = await fetch(`/api/v1/agents/tasks/${taskId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "cancel" }), + }); + if (res.ok) { + const data = await res.json(); + setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t))); + if (selectedTask?.id === taskId) { + setSelectedTask(data.task); + } + } + } catch (err) { + console.error("Failed to cancel task:", err); + } + }; + + const handleDeleteTask = async (taskId: string) => { + try { + const res = await fetch(`/api/v1/agents/tasks/${taskId}`, { + method: "DELETE", + }); + if (res.ok) { + setTasks((prev) => prev.filter((t) => t.id !== taskId)); + if (selectedTask?.id === taskId) { + setSelectedTask(null); + } + } + } catch (err) { + console.error("Failed to delete task:", err); + } + }; + + const getStatusBadge = (status: string) => { + const statusMap: Record = { + pending: { color: "bg-zinc-500/10 text-zinc-500", label: t("statusPending") }, + running: { color: "bg-blue-500/10 text-blue-500", label: t("statusRunning") }, + waiting_approval: { + color: "bg-amber-500/10 text-amber-600", + label: t("statusWaitingApproval"), + }, + completed: { color: "bg-emerald-500/10 text-emerald-600", label: t("statusCompleted") }, + failed: { color: "bg-red-500/10 text-red-500", label: t("statusFailed") }, + cancelled: { color: "bg-zinc-500/10 text-zinc-400", label: t("statusCancelled") }, + }; + const s = statusMap[status] || statusMap.pending; + return ( + + {status === "running" && } + {s.label} + + ); + }; + + const getAgentInfo = (providerId: string) => { + return CLOUD_AGENTS.find((a) => a.id === providerId) || CLOUD_AGENTS[0]; + }; + + if (loading) { + return ( +
+
+

{t("loading")}

+
+ ); + } + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+
+ + +
+
+
+

{t("aboutTitle")}

+

{t("aboutDescription")}

+
+
+
+ {CLOUD_AGENTS.map((agent) => ( +
+
+ {agent.icon} +

{agent.name}

+
+

{agent.description}

+

{agent.provider}

+
+ ))} +
+
+ {t("howItWorksTitle")} + {t("howItWorksDesc")} +
+
+
+ + +
+
+ add_task +
+
+

{t("newTaskTitle")}

+

{t("newTaskDescription")}

+
+
+
+
+
+ + +
+
+
+ setNewTask({ ...newTask, description: e.target.value })} + required + /> +
+
+ +
+
+
+ +
+
+

{t("tasks")}

+ {tasks.length === 0 ? ( +
+ assignment +

{t("noTasks")}

+
+ ) : ( + tasks.map((task) => { + const agent = getAgentInfo(task.provider); + return ( + setSelectedTask(task)} + > +
+
+ {agent.icon} +
+

+ {task.description || t("untitledTask")} +

+

+ {agent.name} • {new Date(task.createdAt).toLocaleString()} +

+
+
+ {getStatusBadge(task.status)} +
+
+ ); + }) + )} +
+ +
+

{t("taskDetail")}

+ {selectedTask ? ( + +
+
+ {getAgentInfo(selectedTask.provider).icon} +
+

{getAgentInfo(selectedTask.provider).name}

+

+ {t("created")}: {new Date(selectedTask.createdAt).toLocaleString()} +

+
+
+ {getStatusBadge(selectedTask.status)} +
+ + {selectedTask.status === "waiting_approval" && selectedTask.plan && ( +
+
+ + description + + + {t("planReady")} + +
+
+                    {selectedTask.plan}
+                  
+
+ + +
+
+ )} + + {selectedTask.messages.length > 0 && ( +
+

{t("conversation")}

+
+ {selectedTask.messages.map((msg, i) => ( +
+ {msg.role}: + {msg.content} +
+ ))} +
+
+ )} + + {selectedTask.result && ( +
+
+ + check_circle + + + {t("result")} + +
+
+                    {selectedTask.result}
+                  
+
+ )} + + {selectedTask.error && ( +
+
+ + error + + {t("error")} +
+

{selectedTask.error}

+
+ )} + + {selectedTask.status === "running" && ( +
+ setMessageInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSendMessage()} + className="flex-1" + /> + +
+ )} + +
+ + +
+
+ ) : ( +
+ touch_app +

{t("selectTaskPrompt")}

+
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 08f9a981ca..ab3a1c583b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -11,6 +11,7 @@ import { IMAGE_ONLY_PROVIDER_IDS, VIDEO_PROVIDER_IDS, isClaudeCodeCompatibleProvider, + CLOUD_AGENT_PROVIDERS, } from "@/shared/constants/providers"; import { useRouter } from "next/navigation"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; @@ -488,6 +489,13 @@ export default function ProvidersPage() { searchQuery ); + const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats); + const cloudAgentProviderEntries = filterConfiguredProviderEntries( + cloudAgentProviderEntriesAll, + showConfiguredOnly, + searchQuery + ); + const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats); const upstreamProxyEntries = filterConfiguredProviderEntries( upstreamProxyEntriesAll, @@ -970,6 +978,51 @@ export default function ProvidersPage() {
)} + {/* Cloud Agent Providers */} + {cloudAgentProviderEntries.length > 0 && ( +
+
+

+ {t("cloudAgentProviders")}{" "} + + +

+ +
+
+ {cloudAgentProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + {/* Local / Self-Hosted Providers */} {localProviderEntries.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx index fe43aa2f2c..7f9ca598b4 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx @@ -22,6 +22,7 @@ export default function ProxyTab() { } catch {} }; + useEffect(() => { mountedRef.current = true; async function init() { diff --git a/src/app/api/v1/agents/tasks/[id]/route.ts b/src/app/api/v1/agents/tasks/[id]/route.ts new file mode 100644 index 0000000000..031bd51a29 --- /dev/null +++ b/src/app/api/v1/agents/tasks/[id]/route.ts @@ -0,0 +1,186 @@ +import { NextRequest, NextResponse } from "next/server"; +import { extractApiKey } from "@/sse/services/auth"; +import { getAgent } from "@/lib/cloudAgent/registry"; +import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db"; +import { z } from "zod"; +import pino from "pino"; + +const logger = pino({ name: "cloud-agents-api" }); + +function getCorsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +export async function OPTIONS() { + return new NextResponse(null, { headers: getCorsHeaders() }); +} + +const ApproveSchema = z.object({ + action: z.literal("approve"), +}); + +const MessageSchema = z.object({ + action: z.literal("message"), + message: z.string().min(1), +}); + +const CancelSchema = z.object({ + action: z.literal("cancel"), +}); + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const task = getCloudAgentTaskById(id); + + if (!task) { + return NextResponse.json( + { error: "Task not found" }, + { status: 404, headers: getCorsHeaders() } + ); + } + + const apiKey = extractApiKey(request); + if (!apiKey) { + return NextResponse.json( + { error: "API key required" }, + { status: 401, headers: getCorsHeaders() } + ); + } + + const agent = getAgent(task.provider_id); + if (agent && task.external_id) { + try { + const statusResult = await agent.getStatus(task.external_id, { apiKey }); + + updateCloudAgentTask(id, { + status: statusResult.status, + result: statusResult.result ? JSON.stringify(statusResult.result) : null, + activities: JSON.stringify(statusResult.activities), + error: statusResult.error || null, + completed_at: + statusResult.status === "completed" || statusResult.status === "failed" + ? new Date().toISOString() + : null, + }); + } catch (err) { + console.error("Failed to sync task status:", err); + } + } + + const updatedTask = getCloudAgentTaskById(id); + + return NextResponse.json( + { + data: { + id: updatedTask!.id, + providerId: updatedTask!.provider_id, + externalId: updatedTask!.external_id, + status: updatedTask!.status, + prompt: updatedTask!.prompt, + source: JSON.parse(updatedTask!.source), + options: JSON.parse(updatedTask!.options), + result: updatedTask!.result ? JSON.parse(updatedTask!.result) : null, + activities: JSON.parse(updatedTask!.activities), + error: updatedTask!.error, + createdAt: updatedTask!.created_at, + updatedAt: updatedTask!.updated_at, + completedAt: updatedTask!.completed_at, + }, + }, + { headers: getCorsHeaders() } + ); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500, headers: getCorsHeaders() } + ); + } +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = await request.json(); + + const task = getCloudAgentTaskById(id); + if (!task) { + return NextResponse.json( + { error: "Task not found" }, + { status: 404, headers: getCorsHeaders() } + ); + } + + const apiKey = extractApiKey(request); + if (!apiKey) { + return NextResponse.json( + { error: "API key required" }, + { status: 401, headers: getCorsHeaders() } + ); + } + + let validated; + if (body.action === "approve") { + validated = ApproveSchema.parse(body); + } else if (body.action === "message") { + validated = MessageSchema.parse(body); + } else if (body.action === "cancel") { + validated = CancelSchema.parse(body); + } else { + return NextResponse.json( + { error: "Invalid action" }, + { status: 400, headers: getCorsHeaders() } + ); + } + + const agent = getAgent(task.provider_id); + if (!agent) { + return NextResponse.json( + { error: "Agent not found" }, + { status: 500, headers: getCorsHeaders() } + ); + } + + if (validated.action === "approve") { + if (!task.external_id) { + return NextResponse.json( + { error: "No external task to approve" }, + { status: 400, headers: getCorsHeaders() } + ); + } + await agent.approvePlan(task.external_id, { apiKey }); + updateCloudAgentTask(id, { status: "running" }); + } else if (validated.action === "message") { + if (!task.external_id) { + return NextResponse.json( + { error: "No external task to message" }, + { status: 400, headers: getCorsHeaders() } + ); + } + const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey }); + const activities = JSON.parse(task.activities); + activities.push(activity); + updateCloudAgentTask(id, { activities: JSON.stringify(activities) }); + } else if (validated.action === "cancel") { + updateCloudAgentTask(id, { status: "cancelled" }); + } + + return NextResponse.json({ success: true }, { headers: getCorsHeaders() }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", details: error.errors }, + { status: 400, headers: getCorsHeaders() } + ); + } + logger.error({ err: error }, "Failed to process task action"); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500, headers: getCorsHeaders() } + ); + } +} diff --git a/src/app/api/v1/agents/tasks/route.ts b/src/app/api/v1/agents/tasks/route.ts new file mode 100644 index 0000000000..600817d107 --- /dev/null +++ b/src/app/api/v1/agents/tasks/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from "next/server"; +import { extractApiKey } from "@/sse/services/auth"; +import { getAgent } from "@/lib/cloudAgent/registry"; +import { + insertCloudAgentTask, + getCloudAgentTaskById, + getAllCloudAgentTasks, + getCloudAgentTasksByProvider, + getCloudAgentTasksByStatus, + updateCloudAgentTask, + deleteCloudAgentTask, +} from "@/lib/cloudAgent/db"; +import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types"; +import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers"; +import { z } from "zod"; +import pino from "pino"; + +const logger = pino({ name: "cloud-agents-api" }); + +function getCorsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +export async function OPTIONS() { + return new NextResponse(null, { headers: getCorsHeaders() }); +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const providerId = searchParams.get("provider"); + const status = searchParams.get("status"); + const limit = parseInt(searchParams.get("limit") || "50", 10); + + let tasks; + if (providerId) { + tasks = getCloudAgentTasksByProvider(providerId, limit); + } else if (status) { + tasks = getCloudAgentTasksByStatus(status, limit); + } else { + tasks = getAllCloudAgentTasks(limit); + } + + return NextResponse.json( + { + data: tasks.map((t) => ({ + id: t.id, + providerId: t.provider_id, + externalId: t.external_id, + status: t.status, + prompt: t.prompt, + source: JSON.parse(t.source), + options: JSON.parse(t.options), + result: t.result ? JSON.parse(t.result) : null, + activities: JSON.parse(t.activities), + error: t.error, + createdAt: t.created_at, + updatedAt: t.updated_at, + completedAt: t.completed_at, + })), + }, + { headers: getCorsHeaders() } + ); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500, headers: getCorsHeaders() } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const validated = CreateCloudAgentTaskSchema.parse(body); + + const apiKey = extractApiKey(request); + if (!apiKey) { + return NextResponse.json( + { error: "API key required" }, + { status: 401, headers: getCorsHeaders() } + ); + } + + const agent = getAgent(validated.providerId); + if (!agent) { + return NextResponse.json( + { error: `Unknown provider: ${validated.providerId}` }, + { status: 400, headers: getCorsHeaders() } + ); + } + + const task = await agent.createTask( + { + prompt: validated.prompt, + source: validated.source, + options: validated.options || {}, + }, + { apiKey } + ); + + insertCloudAgentTask({ + id: task.id, + provider_id: task.providerId, + external_id: task.externalId || null, + status: task.status, + prompt: task.prompt, + source: JSON.stringify(task.source), + options: JSON.stringify(task.options), + result: null, + activities: JSON.stringify(task.activities), + error: null, + created_at: task.createdAt, + updated_at: task.updatedAt, + completed_at: null, + }); + + return NextResponse.json( + { + data: { + id: task.id, + providerId: task.providerId, + externalId: task.externalId, + status: task.status, + prompt: task.prompt, + source: task.source, + options: task.options, + createdAt: task.createdAt, + }, + }, + { status: 201, headers: getCorsHeaders() } + ); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", details: error.errors }, + { status: 400, headers: getCorsHeaders() } + ); + } + logger.error({ err: error }, "Failed to create cloud agent task"); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500, headers: getCorsHeaders() } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const taskId = searchParams.get("id"); + + if (!taskId) { + return NextResponse.json( + { error: "Task ID required" }, + { status: 400, headers: getCorsHeaders() } + ); + } + + deleteCloudAgentTask(taskId); + + return NextResponse.json({ success: true }, { headers: getCorsHeaders() }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500, headers: getCorsHeaders() } + ); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 07b6ef1647..6d47235d8c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -158,6 +158,7 @@ "noLockouts": "No Lockouts", "webSearchDesc": "Web Search Desc", "audioProvidersHeading": "Audio Providers Heading", + "cloudAgentProviders": "Cloud Agent Providers", "minutesAgo": "Minutes Ago", "a": "A", "liveAutoRefreshing": "Live Auto Refreshing", @@ -668,6 +669,7 @@ "playground": "Playground", "searchTools": "Search Tools", "agents": "Agents", + "cloudAgents": "Cloud Agents", "memory": "Memory", "skills": "Skills", "docs": "Docs", @@ -4851,6 +4853,42 @@ "settingsRoutingLink": "Settings/Routing", "openSettings": "Settings" }, + "cloudAgents": { + "title": "Cloud Agents", + "description": "Manage autonomous coding agents (Jules, Devin, Codex Cloud)", + "loading": "Loading tasks...", + "aboutTitle": "About Cloud Agents", + "aboutDescription": "Cloud agents are remote AI coding assistants that can execute tasks autonomously. They work differently from local CLI agents - you interact with them through OmniRoute's API.", + "howItWorksTitle": "How it works:", + "howItWorksDesc": "Create a task → Agent analyzes and proposes a plan → You approve → Agent executes → Results returned", + "newTaskTitle": "Create New Task", + "newTaskDescription": "Start a new task with a cloud agent", + "selectAgent": "Select Agent", + "taskDescription": "Task Description", + "taskDescriptionPlaceholder": "Describe what you want the agent to do...", + "startTask": "Start Task", + "tasks": "Tasks", + "taskDetail": "Task Detail", + "noTasks": "No tasks yet. Create one to get started.", + "untitledTask": "Untitled Task", + "created": "Created", + "conversation": "Conversation", + "result": "Result", + "error": "Error", + "planReady": "Plan Ready for Approval", + "approvePlan": "Approve Plan", + "rejectPlan": "Reject & Cancel", + "sendMessagePlaceholder": "Send a message to the agent...", + "cancel": "Cancel", + "delete": "Delete", + "selectTaskPrompt": "Select a task to view details", + "statusPending": "Pending", + "statusRunning": "Running", + "statusWaitingApproval": "Waiting Approval", + "statusCompleted": "Completed", + "statusFailed": "Failed", + "statusCancelled": "Cancelled" + }, "templateNames": { "simple-chat": "Simple Chat", "streaming": "Streaming", diff --git a/src/lib/cloudAgent/agents/codex.ts b/src/lib/cloudAgent/agents/codex.ts new file mode 100644 index 0000000000..f069ac9109 --- /dev/null +++ b/src/lib/cloudAgent/agents/codex.ts @@ -0,0 +1,148 @@ +import { + CloudAgentBase, + type AgentCredentials, + type CreateTaskParams, + type GetStatusResult, +} from "../baseAgent.ts"; +import type { CloudAgentTask, CloudAgentActivity } from "../types.ts"; +import { CLOUD_AGENT_STATUS } from "../types.ts"; + +export class CodexCloudAgent extends CloudAgentBase { + readonly providerId = "codex-cloud"; + readonly baseUrl = "https://api.openai.com/v1"; + + async createTask( + params: CreateTaskParams, + credentials: AgentCredentials + ): Promise { + const taskId = this.generateTaskId(); + + const body: Record = { + prompt: params.prompt, + repository_context: params.source.repoUrl, + }; + + if (params.source.branch) { + body.branch = params.source.branch; + } + + if (params.options.environment) { + body.environment = { + setup: params.options.environment, + }; + } + + const response = await fetch(`${this.baseUrl}/codex/cloud/tasks`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Codex Cloud create task failed: ${response.status} ${error}`); + } + + const data = await response.json(); + + return { + id: taskId, + providerId: this.providerId, + externalId: data.id, + status: this.mapStatus(data.status || "pending"), + prompt: params.prompt, + source: params.source, + options: params.options, + activities: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + async getStatus(externalId: string, credentials: AgentCredentials): Promise { + const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}`, { + headers: { + Authorization: `Bearer ${credentials.apiKey}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Codex Cloud get status failed: ${response.status} ${error}`); + } + + const data = await response.json(); + const status = this.mapStatus(data.status || "pending"); + + const activities: CloudAgentActivity[] = []; + + if (data.subagents) { + for (const subagent of data.subagents) { + activities.push({ + id: this.generateActivityId(), + type: "command", + content: `Subagent: ${subagent.name} - ${subagent.status}`, + timestamp: new Date().toISOString(), + }); + } + } + + let result; + if (status === CLOUD_AGENT_STATUS.COMPLETED && (data.result || data.pr_url)) { + result = { + prUrl: data.pr_url || data.result?.pr_url, + commitMessage: data.result?.commit_message, + summary: data.result?.summary, + duration: data.elapsed_time, + }; + } + + return { + status, + externalId, + result, + activities, + error: data.error || data.error_message, + }; + } + + async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise { + throw new Error("Codex Cloud does not support plan approval - it auto-plans"); + } + + async sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise { + const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}/followup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify({ message }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Codex Cloud send message failed: ${response.status} ${error}`); + } + + return { + id: this.generateActivityId(), + type: "message", + content: message, + timestamp: new Date().toISOString(), + }; + } + + async listSources( + _credentials: AgentCredentials + ): Promise<{ name: string; url: string; branch?: string }[]> { + return []; + } +} diff --git a/src/lib/cloudAgent/agents/devin.ts b/src/lib/cloudAgent/agents/devin.ts new file mode 100644 index 0000000000..c409c92349 --- /dev/null +++ b/src/lib/cloudAgent/agents/devin.ts @@ -0,0 +1,137 @@ +import { + CloudAgentBase, + type AgentCredentials, + type CreateTaskParams, + type GetStatusResult, +} from "../baseAgent.ts"; +import type { CloudAgentTask, CloudAgentActivity } from "../types.ts"; +import { CLOUD_AGENT_STATUS } from "../types.ts"; + +export class DevinAgent extends CloudAgentBase { + readonly providerId = "devin"; + readonly baseUrl = "https://api.devin.ai/v1"; + + async createTask( + params: CreateTaskParams, + credentials: AgentCredentials + ): Promise { + const taskId = this.generateTaskId(); + + const body: Record = { + prompt: params.prompt, + repo_url: params.source.repoUrl, + }; + + if (params.source.branch) { + body.branch = params.source.branch; + } + + const response = await fetch(`${this.baseUrl}/sessions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Devin create task failed: ${response.status} ${error}`); + } + + const data = await response.json(); + + return { + id: taskId, + providerId: this.providerId, + externalId: data.id, + status: this.mapStatus(data.status || "created"), + prompt: params.prompt, + source: params.source, + options: params.options, + activities: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + async getStatus(externalId: string, credentials: AgentCredentials): Promise { + const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, { + headers: { + Authorization: `Bearer ${credentials.apiKey}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Devin get status failed: ${response.status} ${error}`); + } + + const data = await response.json(); + const status = this.mapStatus(data.status || "created"); + + const activities: CloudAgentActivity[] = (data.messages || []).map( + (msg: Record) => ({ + id: this.generateActivityId(), + type: "message" as const, + content: (msg.content as string) || "", + timestamp: (msg.created_at as string) || new Date().toISOString(), + }) + ); + + let result; + if (status === CLOUD_AGENT_STATUS.COMPLETED && data.output) { + result = { + prUrl: data.pr_url, + summary: data.output, + duration: data.duration, + }; + } + + return { + status, + externalId, + result, + activities, + error: data.error, + }; + } + + async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise { + throw new Error("Devin does not support plan approval - it auto-plans"); + } + + async sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise { + const response = await fetch(`${this.baseUrl}/sessions/${externalId}/message`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify({ content: message }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Devin send message failed: ${response.status} ${error}`); + } + + return { + id: this.generateActivityId(), + type: "message", + content: message, + timestamp: new Date().toISOString(), + }; + } + + async listSources( + _credentials: AgentCredentials + ): Promise<{ name: string; url: string; branch?: string }[]> { + return []; + } +} diff --git a/src/lib/cloudAgent/agents/jules.ts b/src/lib/cloudAgent/agents/jules.ts new file mode 100644 index 0000000000..932eb4dfaa --- /dev/null +++ b/src/lib/cloudAgent/agents/jules.ts @@ -0,0 +1,170 @@ +import { + CloudAgentBase, + type AgentCredentials, + type CreateTaskParams, + type GetStatusResult, +} from "../baseAgent.ts"; +import type { CloudAgentTask, CloudAgentActivity } from "../types.ts"; +import { CLOUD_AGENT_STATUS } from "../types.ts"; + +export class JulesAgent extends CloudAgentBase { + readonly providerId = "jules"; + readonly baseUrl = "https://jules.googleapis.com/v1alpha"; + + async createTask( + params: CreateTaskParams, + credentials: AgentCredentials + ): Promise { + const taskId = this.generateTaskId(); + + const body: Record = { + prompt: params.prompt, + source: { + repository: { + owner: params.source.repoUrl.split("/").filter(Boolean).slice(-2, -1)[0] || "", + name: params.source.repoName, + }, + branch: params.source.branch || "main", + }, + }; + + if (params.options.autoCreatePr) { + body.automationMode = "AUTO_CREATE_PR"; + } + + const response = await fetch(`${this.baseUrl}/sessions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": credentials.apiKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Jules create task failed: ${response.status} ${error}`); + } + + const data = await response.json(); + + return { + id: taskId, + providerId: this.providerId, + externalId: data.name?.split("/").pop() || taskId, + status: this.mapStatus(data.state || "pending"), + prompt: params.prompt, + source: params.source, + options: params.options, + activities: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + async getStatus(externalId: string, _credentials: AgentCredentials): Promise { + const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, { + headers: { + "X-Goog-Api-Key": _credentials.apiKey, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Jules get status failed: ${response.status} ${error}`); + } + + const data = await response.json(); + const status = this.mapStatus(data.state || "pending"); + + const activities: CloudAgentActivity[] = (data.activities || []).map( + (act: Record) => ({ + id: this.generateActivityId(), + type: act.type as CloudAgentActivity["type"], + content: (act.description as string) || "", + timestamp: (act.timestamp as string) || new Date().toISOString(), + }) + ); + + let result; + if (status === CLOUD_AGENT_STATUS.COMPLETED && data.outputs) { + result = { + prUrl: data.outputs.prUrl, + commitMessage: data.outputs.commitMessage, + summary: data.outputs.summary, + }; + } + + return { + status, + externalId, + result, + activities, + error: data.error, + }; + } + + async approvePlan(externalId: string, credentials: AgentCredentials): Promise { + const response = await fetch(`${this.baseUrl}/sessions/${externalId}:approvePlan`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": credentials.apiKey, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Jules approve plan failed: ${response.status} ${error}`); + } + } + + async sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise { + const response = await fetch(`${this.baseUrl}/sessions/${externalId}:sendMessage`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": credentials.apiKey, + }, + body: JSON.stringify({ message }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Jules send message failed: ${response.status} ${error}`); + } + + return { + id: this.generateActivityId(), + type: "message", + content: message, + timestamp: new Date().toISOString(), + }; + } + + async listSources( + credentials: AgentCredentials + ): Promise<{ name: string; url: string; branch?: string }[]> { + const response = await fetch(`${this.baseUrl}/sources`, { + headers: { + "X-Goog-Api-Key": credentials.apiKey, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Jules list sources failed: ${response.status} ${error}`); + } + + const data = await response.json(); + return (data.sources || []).map((source: Record) => ({ + name: source.name as string, + url: `https://github.com/${source.repoOwner}/${source.repoName}`, + branch: source.defaultBranch as string | undefined, + })); + } +} diff --git a/src/lib/cloudAgent/baseAgent.ts b/src/lib/cloudAgent/baseAgent.ts new file mode 100644 index 0000000000..9f5689b90b --- /dev/null +++ b/src/lib/cloudAgent/baseAgent.ts @@ -0,0 +1,95 @@ +import type { + CloudAgentTask, + CloudAgentStatus, + CloudAgentSource, + CloudAgentResult, + CloudAgentActivity, +} from "./types.ts"; + +export interface AgentCredentials { + apiKey: string; + baseUrl?: string; +} + +export interface CreateTaskParams { + prompt: string; + source: CloudAgentSource; + options: { + autoCreatePr?: boolean; + planApprovalRequired?: boolean; + environment?: Record; + }; +} + +export interface GetStatusResult { + status: CloudAgentStatus; + externalId?: string; + result?: CloudAgentResult; + activities: CloudAgentActivity[]; + error?: string; +} + +export abstract class CloudAgentBase { + abstract readonly providerId: string; + abstract readonly baseUrl: string; + + abstract createTask( + params: CreateTaskParams, + credentials: AgentCredentials + ): Promise; + + abstract getStatus(externalId: string, credentials: AgentCredentials): Promise; + + abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise; + + abstract sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise; + + abstract listSources( + credentials: AgentCredentials + ): Promise<{ name: string; url: string; branch?: string }[]>; + + protected mapStatus(status: string): CloudAgentStatus { + const statusLower = status.toLowerCase(); + + if (statusLower.includes("completed") || statusLower.includes("done")) { + return "completed"; + } + if (statusLower.includes("failed") || statusLower.includes("error")) { + return "failed"; + } + if (statusLower.includes("cancelled") || statusLower.includes("canceled")) { + return "cancelled"; + } + if ( + statusLower.includes("running") || + statusLower.includes("active") || + statusLower.includes("executing") + ) { + return "running"; + } + if ( + statusLower.includes("pending") || + statusLower.includes("queued") || + statusLower.includes("waiting") + ) { + return "queued"; + } + if (statusLower.includes("approval") || statusLower.includes("plan")) { + return "awaiting_approval"; + } + + return "queued"; + } + + protected generateTaskId(): string { + return `task_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } + + protected generateActivityId(): string { + return `act_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } +} diff --git a/src/lib/cloudAgent/db.ts b/src/lib/cloudAgent/db.ts new file mode 100644 index 0000000000..91667f5422 --- /dev/null +++ b/src/lib/cloudAgent/db.ts @@ -0,0 +1,145 @@ +import { getDbInstance } from "@/lib/db/core.ts"; + +export interface CloudAgentTaskRow { + id: string; + provider_id: string; + external_id: string | null; + status: string; + prompt: string; + source: string; + options: string; + result: string | null; + activities: string; + error: string | null; + created_at: string; + updated_at: string; + completed_at: string | null; +} + +export function createCloudAgentTaskTable(): void { + const db = getDbInstance(); + + db.exec(` + CREATE TABLE IF NOT EXISTS cloud_agent_tasks ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + external_id TEXT, + status TEXT NOT NULL DEFAULT 'queued', + prompt TEXT NOT NULL, + source TEXT NOT NULL, + options TEXT DEFAULT '{}', + result TEXT, + activities TEXT DEFAULT '[]', + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_provider + ON cloud_agent_tasks(provider_id) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_status + ON cloud_agent_tasks(status) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_created + ON cloud_agent_tasks(created_at DESC) + `); +} + +export function insertCloudAgentTask(task: CloudAgentTaskRow): void { + const db = getDbInstance(); + db.prepare( + ` + INSERT INTO cloud_agent_tasks ( + id, provider_id, external_id, status, prompt, source, + options, result, activities, error, created_at, updated_at, completed_at + ) VALUES ( + @id, @provider_id, @external_id, @status, @prompt, @source, + @options, @result, @activities, @error, @created_at, @updated_at, @completed_at + ) + ` + ).run(task); +} + +// Whitelist of allowed columns for update operations +const ALLOWED_UPDATE_COLUMNS = new Set([ + "status", + "prompt", + "source", + "options", + "result", + "activities", + "error", + "completed_at", +]); + +export function updateCloudAgentTask( + id: string, + updates: Partial> +): void { + const db = getDbInstance(); + + // Validate keys against whitelist to prevent SQL injection + const validUpdates: Partial> = {}; + for (const [key, value] of Object.entries(updates)) { + if (ALLOWED_UPDATE_COLUMNS.has(key)) { + (validUpdates as Record)[key] = value; + } + } + + const fields = Object.keys(validUpdates) + .map((key) => `${key} = @${key}`) + .join(", "); + + if (!fields) return; // No valid updates + + db.prepare( + ` + UPDATE cloud_agent_tasks + SET ${fields}, updated_at = datetime('now') + WHERE id = @id + ` + ).run({ id, ...validUpdates }); +} + +export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null { + const db = getDbInstance(); + return db + .prepare("SELECT * FROM cloud_agent_tasks WHERE id = ?") + .get(id) as CloudAgentTaskRow | null; +} + +export function getCloudAgentTasksByProvider(providerId: string, limit = 50): CloudAgentTaskRow[] { + const db = getDbInstance(); + return db + .prepare( + "SELECT * FROM cloud_agent_tasks WHERE provider_id = ? ORDER BY created_at DESC LIMIT ?" + ) + .all(providerId, limit) as CloudAgentTaskRow[]; +} + +export function getCloudAgentTasksByStatus(status: string, limit = 50): CloudAgentTaskRow[] { + const db = getDbInstance(); + return db + .prepare("SELECT * FROM cloud_agent_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?") + .all(status, limit) as CloudAgentTaskRow[]; +} + +export function getAllCloudAgentTasks(limit = 100): CloudAgentTaskRow[] { + const db = getDbInstance(); + return db + .prepare("SELECT * FROM cloud_agent_tasks ORDER BY created_at DESC LIMIT ?") + .all(limit) as CloudAgentTaskRow[]; +} + +export function deleteCloudAgentTask(id: string): void { + const db = getDbInstance(); + db.prepare("DELETE FROM cloud_agent_tasks WHERE id = ?").run(id); +} diff --git a/src/lib/cloudAgent/index.ts b/src/lib/cloudAgent/index.ts new file mode 100644 index 0000000000..c8a23bf314 --- /dev/null +++ b/src/lib/cloudAgent/index.ts @@ -0,0 +1,8 @@ +export * from "./types.ts"; +export * from "./baseAgent.ts"; +export * from "./registry.ts"; +export * from "./db.ts"; + +import { createCloudAgentTaskTable } from "./db.ts"; + +createCloudAgentTaskTable(); diff --git a/src/lib/cloudAgent/registry.ts b/src/lib/cloudAgent/registry.ts new file mode 100644 index 0000000000..091359a400 --- /dev/null +++ b/src/lib/cloudAgent/registry.ts @@ -0,0 +1,25 @@ +import type { CloudAgentBase } from "./baseAgent.ts"; +import { JulesAgent } from "./agents/jules.ts"; +import { DevinAgent } from "./agents/devin.ts"; +import { CodexCloudAgent } from "./agents/codex.ts"; + +const AGENTS: Record = { + jules: new JulesAgent(), + devin: new DevinAgent(), + "codex-cloud": new CodexCloudAgent(), +}; + +export function getAgent(providerId: string): CloudAgentBase | null { + return AGENTS[providerId] || null; +} + +export function getAvailableAgents(): string[] { + return Object.keys(AGENTS); +} + +export function isCloudAgentProvider(providerId: string): boolean { + return providerId in AGENTS; +} + +export { JulesAgent, DevinAgent, CodexCloudAgent }; +export type { CloudAgentBase } from "./baseAgent.ts"; diff --git a/src/lib/cloudAgent/types.ts b/src/lib/cloudAgent/types.ts new file mode 100644 index 0000000000..13d7d398b0 --- /dev/null +++ b/src/lib/cloudAgent/types.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +export const CLOUD_AGENT_STATUS = { + QUEUED: "queued", + RUNNING: "running", + AWAITING_APPROVAL: "awaiting_approval", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", +} as const; + +export type CloudAgentStatus = (typeof CLOUD_AGENT_STATUS)[keyof typeof CLOUD_AGENT_STATUS]; + +export const CloudAgentStatusSchema = z.enum([ + "queued", + "running", + "awaiting_approval", + "completed", + "failed", + "cancelled", +]); + +export interface CloudAgentSource { + repoName: string; + repoUrl: string; + branch?: string; +} + +export interface CloudAgentResult { + prUrl?: string; + prNumber?: number; + commitMessage?: string; + diffUrl?: string; + summary?: string; + duration?: number; + cost?: number; +} + +export interface CloudAgentActivity { + id: string; + type: "plan" | "command" | "code_change" | "message" | "error" | "completion"; + content: string; + timestamp: string; + metadata?: Record; +} + +export interface CloudAgentTask { + id: string; + providerId: "jules" | "devin" | "codex-cloud"; + externalId?: string; + status: CloudAgentStatus; + prompt: string; + source: CloudAgentSource; + options: { + autoCreatePr?: boolean; + planApprovalRequired?: boolean; + environment?: Record; + }; + result?: CloudAgentResult; + activities: CloudAgentActivity[]; + error?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; +} + +export const CloudAgentSourceSchema = z.object({ + repoName: z.string().min(1), + repoUrl: z.string().url(), + branch: z.string().optional(), +}); + +export const CloudAgentResultSchema = z.object({ + prUrl: z.string().url().optional(), + prNumber: z.number().int().positive().optional(), + commitMessage: z.string().optional(), + diffUrl: z.string().url().optional(), + summary: z.string().optional(), + duration: z.number().int().positive().optional(), + cost: z.number().positive().optional(), +}); + +export const CloudAgentActivitySchema = z.object({ + id: z.string(), + type: z.enum(["plan", "command", "code_change", "message", "error", "completion"]), + content: z.string(), + timestamp: z.string().datetime(), + metadata: z.record(z.unknown()).optional(), +}); + +export const CloudAgentTaskOptionsSchema = z.object({ + autoCreatePr: z.boolean().optional(), + planApprovalRequired: z.boolean().optional(), + environment: z.record(z.string()).optional(), +}); + +export const CreateCloudAgentTaskSchema = z.object({ + providerId: z.enum(["jules", "devin", "codex-cloud"]), + prompt: z.string().min(1).max(10000), + source: CloudAgentSourceSchema, + options: CloudAgentTaskOptionsSchema.optional(), +}); + +export const UpdateCloudAgentTaskSchema = z.object({ + id: z.string().min(1), + action: z.enum(["approve", "reject", "cancel", "message"]), + message: z.string().optional(), +}); + +export type CreateCloudAgentTaskInput = z.infer; +export type UpdateCloudAgentTaskInput = z.infer; diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index d50f21475a..16bd18723c 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -1,6 +1,7 @@ import { APIKEY_PROVIDERS, AUDIO_ONLY_PROVIDERS, + CLOUD_AGENT_PROVIDERS, FREE_PROVIDERS, LOCAL_PROVIDERS, OAUTH_PROVIDERS, @@ -21,7 +22,8 @@ export type StaticProviderCatalogCategory = | "search" | "audio" | "upstream-proxy" - | "apikey"; + | "apikey" + | "cloud-agent"; export interface ProviderCatalogMetadata { id: string; @@ -133,6 +135,12 @@ export const STATIC_PROVIDER_CATALOG_GROUPS: Record< displayAuthType: "apikey", toggleAuthType: "apikey", }, + "cloud-agent": { + category: "cloud-agent", + providers: CLOUD_AGENT_PROVIDERS as ProviderRecord, + displayAuthType: "apikey", + toggleAuthType: "apikey", + }, }; export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCategory[] = [ @@ -143,6 +151,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate "search", "audio", "upstream-proxy", + "cloud-agent", "apikey", ]; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 05dbcb5d46..72064f737b 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1880,6 +1880,38 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean { return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId); } +// Cloud Agent Providers +export const CLOUD_AGENT_PROVIDERS = { + jules: { + id: "jules", + alias: "jl", + name: "Jules (Google)", + icon: "smart_toy", + color: "#4285F4", + website: "https://jules.google", + authHint: "Get your API key from Jules Settings → API. Free during alpha.", + }, + devin: { + id: "devin", + alias: "dv", + name: "Devin (Cognition)", + icon: "precision_manufacturing", + color: "#8B5CF6", + website: "https://devin.ai", + authHint: "Get your API token from Devin Settings → API.", + }, + "codex-cloud": { + id: "codex-cloud", + alias: "cx-cloud", + name: "Codex Cloud (OpenAI)", + icon: "cloud", + color: "#10A37F", + website: "https://platform.openai.com/docs/codex", + authHint: + "Use your OpenAI API key or ChatGPT subscription. Codex Cloud included with Plus/Pro/Business.", + }, +}; + // All providers (combined) export const AI_PROVIDERS = { ...FREE_PROVIDERS, @@ -1890,6 +1922,7 @@ export const AI_PROVIDERS = { ...SEARCH_PROVIDERS, ...AUDIO_ONLY_PROVIDERS, ...UPSTREAM_PROXY_PROVIDERS, + ...CLOUD_AGENT_PROVIDERS, }; export type AiProviderId = keyof typeof AI_PROVIDERS; @@ -1968,3 +2001,4 @@ validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS"); validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS"); validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS"); validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS"); +validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS"); diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 300a75d86c..35805f7e60 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -14,6 +14,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "limits", "cli-tools", "agents", + "cloud-agents", "memory", "skills", "translator", @@ -69,6 +70,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" }, { id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" }, + { id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" }, { id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" }, { id: "skills", href: "/dashboard/skills", i18nKey: "skills", icon: "auto_fix_high" }, ]; diff --git a/tests/unit/cloudAgent-types.test.ts b/tests/unit/cloudAgent-types.test.ts new file mode 100644 index 0000000000..f1d8dcb620 --- /dev/null +++ b/tests/unit/cloudAgent-types.test.ts @@ -0,0 +1,166 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { CLOUD_AGENT_STATUS, CloudAgentStatusSchema, CloudAgentTask } = + await import("../../src/lib/cloudAgent/types.ts"); +const { CreateCloudAgentTaskSchema, UpdateCloudAgentTaskSchema } = + await import("../../src/lib/cloudAgent/types.ts"); +const { isCloudAgentProvider, getAgent, getAvailableAgents } = + await import("../../src/lib/cloudAgent/registry.ts"); + +test("CLOUD_AGENT_STATUS has correct values", () => { + assert.strictEqual(CLOUD_AGENT_STATUS.QUEUED, "queued"); + assert.strictEqual(CLOUD_AGENT_STATUS.RUNNING, "running"); + assert.strictEqual(CLOUD_AGENT_STATUS.AWAITING_APPROVAL, "awaiting_approval"); + assert.strictEqual(CLOUD_AGENT_STATUS.COMPLETED, "completed"); + assert.strictEqual(CLOUD_AGENT_STATUS.FAILED, "failed"); + assert.strictEqual(CLOUD_AGENT_STATUS.CANCELLED, "cancelled"); +}); + +test("CloudAgentStatusSchema validates valid statuses", () => { + const validStatuses = [ + "queued", + "running", + "awaiting_approval", + "completed", + "failed", + "cancelled", + ]; + for (const status of validStatuses) { + const result = CloudAgentStatusSchema.safeParse(status); + assert.strictEqual(result.success, true, `Status ${status} should be valid`); + } +}); + +test("CloudAgentStatusSchema rejects invalid status", () => { + const result = CloudAgentStatusSchema.safeParse("invalid"); + assert.strictEqual(result.success, false); +}); + +test("CreateCloudAgentTaskSchema validates valid input", () => { + const validInput = { + providerId: "jules", + prompt: "Fix the bug in auth.ts", + source: { + repoName: "my-repo", + repoUrl: "https://github.com/user/my-repo", + branch: "main", + }, + options: { + autoCreatePr: true, + planApprovalRequired: true, + }, + }; + const result = CreateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("CreateCloudAgentTaskSchema rejects missing required fields", () => { + const invalidInput = { + providerId: "jules", + }; + const result = CreateCloudAgentTaskSchema.safeParse(invalidInput); + assert.strictEqual(result.success, false); +}); + +test("CreateCloudAgentTaskSchema rejects invalid providerId", () => { + const invalidInput = { + providerId: "invalid-provider", + prompt: "Test", + source: { + repoName: "test", + repoUrl: "https://github.com/test/test", + }, + }; + const result = CreateCloudAgentTaskSchema.safeParse(invalidInput); + assert.strictEqual(result.success, false); +}); + +test("CreateCloudAgentTaskSchema rejects invalid repoUrl", () => { + const invalidInput = { + providerId: "jules", + prompt: "Test", + source: { + repoName: "test", + repoUrl: "not-a-url", + }, + }; + const result = CreateCloudAgentTaskSchema.safeParse(invalidInput); + assert.strictEqual(result.success, false); +}); + +test("UpdateCloudAgentTaskSchema validates approve action", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "approve", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("UpdateCloudAgentTaskSchema validates cancel action", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "cancel", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("UpdateCloudAgentTaskSchema validates message action with content", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "message", + message: "Please continue with the next step", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("UpdateCloudAgentTaskSchema allows message without content (optional)", () => { + const validInput = { + id: "123e4567-e89b-12d3-a456-426614174000", + action: "message", + }; + const result = UpdateCloudAgentTaskSchema.safeParse(validInput); + assert.strictEqual(result.success, true); +}); + +test("getAvailableAgents returns all registered agents", () => { + const agents = getAvailableAgents(); + assert.strictEqual(agents.includes("jules"), true); + assert.strictEqual(agents.includes("devin"), true); + assert.strictEqual(agents.includes("codex-cloud"), true); + assert.strictEqual(agents.length, 3); +}); + +test("getAgent returns correct agent for valid providerId", () => { + const jules = getAgent("jules"); + assert.notStrictEqual(jules, null); + assert.strictEqual(jules?.providerId, "jules"); + + const devin = getAgent("devin"); + assert.notStrictEqual(devin, null); + assert.strictEqual(devin?.providerId, "devin"); + + const codex = getAgent("codex-cloud"); + assert.notStrictEqual(codex, null); + assert.strictEqual(codex?.providerId, "codex-cloud"); +}); + +test("getAgent returns null for invalid providerId", () => { + const result = getAgent("invalid-provider"); + assert.strictEqual(result, null); +}); + +test("isCloudAgentProvider returns true for valid providers", () => { + assert.strictEqual(isCloudAgentProvider("jules"), true); + assert.strictEqual(isCloudAgentProvider("devin"), true); + assert.strictEqual(isCloudAgentProvider("codex-cloud"), true); +}); + +test("isCloudAgentProvider returns false for invalid providers", () => { + assert.strictEqual(isCloudAgentProvider("openai"), false); + assert.strictEqual(isCloudAgentProvider("anthropic"), false); + assert.strictEqual(isCloudAgentProvider("invalid"), false); +});