mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Hide admin password entry during setup, make doctor degrade to warnings when source-only runtime checks are unavailable, and improve stop behavior by attempting graceful shutdown before force killing ports. Also use SQLite's backup API for safer snapshots under WAL, align CLI key writes with the current provider_connections schema, and include follow-on compatibility fixes for GLM provider detection, stream error sanitization, and auth-aware test coverage.
2843 lines
76 KiB
JavaScript
2843 lines
76 KiB
JavaScript
#!/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";
|
||
import { randomUUID } from "node:crypto";
|
||
|
||
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 <name>", "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 <name>]", "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 {
|
||
// Send SIGTERM first for graceful shutdown, then SIGKILL if still running
|
||
execCommand("lsof -ti:20128 | xargs -r kill -15 2>/dev/null || true", 2000);
|
||
execCommand("lsof -ti:20129 | xargs -r kill -15 2>/dev/null || true", 2000);
|
||
await sleep(1000);
|
||
execCommand("lsof -ti:20128 | xargs -r kill -9 2>/dev/null || true", 2000);
|
||
execCommand("lsof -ti:20129 | xargs -r 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 <provider> <api-key>");
|
||
log(" omniroute keys list");
|
||
log(" omniroute keys remove <provider>");
|
||
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 <provider> <api-key>", "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 = ?")
|
||
.get(providerLower);
|
||
|
||
if (existing) {
|
||
db.prepare("UPDATE provider_connections SET api_key = ? WHERE provider = ?").run(
|
||
apiKey,
|
||
providerLower
|
||
);
|
||
log(`API key for ${provider} updated`, "green");
|
||
} else {
|
||
db.prepare(
|
||
"INSERT INTO provider_connections (id, provider, api_key, name, is_active) VALUES (?, ?, ?, ?, 1)"
|
||
).run(randomUUID(), 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 <provider>", "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 <name>");
|
||
log(" omniroute combo create <name> <strategy>");
|
||
log(" omniroute combo delete <name>");
|
||
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 <name>", "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 <name> [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 <name>", "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;
|
||
|
||
const { createRequire } = await import("node:module");
|
||
const require = createRequire(import.meta.url);
|
||
let Database;
|
||
try {
|
||
Database = require("better-sqlite3");
|
||
} catch {
|
||
Database = null;
|
||
}
|
||
|
||
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 });
|
||
if (file.name.endsWith(".sqlite") && Database) {
|
||
// Use better-sqlite3 backup API for a consistent snapshot (safe with WAL)
|
||
const db = new Database(sourcePath, { readonly: true });
|
||
await db.backup(destPath);
|
||
db.close();
|
||
} else {
|
||
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 <backup-timestamp>");
|
||
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 <type>, stop <type>");
|
||
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 <key>", "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 <key> <value>", "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 <key>, set <key> <value>", "dim");
|
||
}
|