TUI Source

This commit is contained in:
decolua
2026-05-12 20:26:08 +07:00
parent 42f0736d28
commit 58788a0d31
57 changed files with 5887 additions and 1871 deletions

509
cli/src/cli/api/client.js Normal file
View File

@@ -0,0 +1,509 @@
const http = require("http");
const https = require("https");
const crypto = require("crypto");
const { machineIdSync } = require("node-machine-id");
// Default configuration
const DEFAULT_CONFIG = {
host: "localhost",
port: 20128,
protocol: "http:",
};
const CLI_TOKEN_HEADER = "x-9r-cli-token";
const CLI_TOKEN_SALT = "9r-cli-auth";
let config = { ...DEFAULT_CONFIG };
let cachedCliToken = null;
function getCliToken() {
if (cachedCliToken !== null) return cachedCliToken;
try {
const mid = machineIdSync();
cachedCliToken = crypto.createHash("sha256").update(mid + CLI_TOKEN_SALT).digest("hex").substring(0, 16);
} catch {
cachedCliToken = "";
}
return cachedCliToken;
}
/**
* Configure API client
* @param {Object} options - Configuration options
* @param {string} options.host - API host
* @param {number} options.port - API port
* @param {string} options.protocol - Protocol (http: or https:)
*/
function configure(options = {}) {
config = { ...config, ...options };
}
/**
* Make HTTP request to API
* @param {string} method - HTTP method
* @param {string} path - API path
* @param {Object} body - Request body (optional)
* @returns {Promise<Object>} Response with { success, data/error }
*/
function makeRequest(method, path, body = null) {
return new Promise((resolve) => {
const httpModule = config.protocol === "https:" ? https : http;
const options = {
hostname: config.host,
port: config.port,
path: path,
method: method,
headers: {
"Content-Type": "application/json",
[CLI_TOKEN_HEADER]: getCliToken(),
},
};
// Add Content-Length for POST/PUT requests
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
const bodyString = JSON.stringify(body);
options.headers["Content-Length"] = Buffer.byteLength(bodyString);
}
const req = httpModule.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
try {
const parsed = data ? JSON.parse(data) : {};
// Check if response indicates error
if (res.statusCode >= 400 || parsed.error) {
resolve({
success: false,
error: parsed.error || `HTTP ${res.statusCode}`,
statusCode: res.statusCode,
});
} else {
resolve({
success: true,
data: parsed,
statusCode: res.statusCode,
});
}
} catch (err) {
resolve({
success: false,
error: `Failed to parse response: ${err.message}`,
});
}
});
});
req.on("error", (err) => {
resolve({
success: false,
error: `Network error: ${err.message}`,
});
});
req.on("timeout", () => {
req.destroy();
resolve({
success: false,
error: "Request timeout",
});
});
// Set timeout (30 seconds)
req.setTimeout(30000);
// Write body if present
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
req.write(JSON.stringify(body));
}
req.end();
});
}
// ============================================================================
// PROVIDERS API
// ============================================================================
/**
* Get all providers
* @returns {Promise<Object>} { success, data: { connections } }
*/
async function getProviders() {
return makeRequest("GET", "/api/providers");
}
/**
* Get provider by ID
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { connection } }
*/
async function getProviderById(id) {
return makeRequest("GET", `/api/providers/${id}`);
}
/**
* Test provider connection
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { valid, error } }
*/
async function testProvider(id) {
return makeRequest("POST", `/api/providers/${id}/test`);
}
/**
* Delete provider
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { message } }
*/
async function deleteProvider(id) {
return makeRequest("DELETE", `/api/providers/${id}`);
}
/**
* Get provider models
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { provider, connectionId, models } }
*/
async function getProviderModels(id) {
return makeRequest("GET", `/api/providers/${id}/models`);
}
// ============================================================================
// OAUTH API
// ============================================================================
/**
* Get OAuth authorization URL
* @param {string} provider - Provider ID
* @returns {Promise<Object>} { success, data: { authUrl, codeVerifier, state, redirectUri } }
*/
async function getOAuthAuthUrl(provider) {
// Codex requires fixed port 1455 and path /auth/callback
const redirectUri = provider === "codex"
? "http://localhost:1455/auth/callback"
: "http://localhost:20128/callback";
return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
}
/**
* Exchange OAuth authorization code for token
* @param {string} provider - Provider ID
* @param {Object} data - { code, redirectUri, codeVerifier, state }
* @returns {Promise<Object>} { success, data }
*/
async function exchangeOAuthCode(provider, data) {
return makeRequest("POST", `/api/oauth/${provider}/exchange`, data);
}
/**
* Get OAuth device code
* @param {string} provider - Provider ID
* @returns {Promise<Object>} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } }
*/
async function getOAuthDeviceCode(provider) {
return makeRequest("GET", `/api/oauth/${provider}/device-code`);
}
/**
* Poll OAuth token using device code
* @param {string} provider - Provider ID
* @param {Object} data - { deviceCode, codeVerifier, extraData }
* @returns {Promise<Object>} { success, data: { pending } }
*/
async function pollOAuthToken(provider, data) {
return makeRequest("POST", `/api/oauth/${provider}/poll`, data);
}
/**
* Create API key provider connection
* @param {Object} data - { provider, name, apiKey }
* @returns {Promise<Object>} { success, data }
*/
async function createApiKeyProvider(data) {
return makeRequest("POST", "/api/providers", data);
}
/**
* Update provider connection
* @param {string} id - Connection ID
* @param {Object} data - { name, priority, defaultModel, isActive }
* @returns {Promise<Object>} { success, data: { connection } }
*/
async function updateConnection(id, data) {
return makeRequest("PUT", `/api/providers/${id}`, data);
}
// ============================================================================
// API KEYS API
// ============================================================================
/**
* Get all API keys
* @returns {Promise<Object>} { success, data: { keys } }
*/
async function getApiKeys() {
return makeRequest("GET", "/api/keys");
}
/**
* Create new API key
* @param {string} name - Key name
* @returns {Promise<Object>} { success, data: { key, name, id, machineId } }
*/
async function createApiKey(name) {
return makeRequest("POST", "/api/keys", { name });
}
/**
* Delete API key
* @param {string} id - Key ID
* @returns {Promise<Object>} { success, data: { success } }
*/
async function deleteApiKey(id) {
return makeRequest("DELETE", `/api/keys/${id}`);
}
// ============================================================================
// COMBOS API
// ============================================================================
/**
* Get all combos
* @returns {Promise<Object>} { success, data: { combos } }
*/
async function getCombos() {
return makeRequest("GET", "/api/combos");
}
/**
* Get combo by ID
* @param {string} id - Combo ID
* @returns {Promise<Object>} { success, data: combo }
*/
async function getComboById(id) {
return makeRequest("GET", `/api/combos/${id}`);
}
/**
* Create new combo
* @param {Object} data - Combo data { name, models }
* @returns {Promise<Object>} { success, data: combo }
*/
async function createCombo(data) {
return makeRequest("POST", "/api/combos", data);
}
/**
* Update combo
* @param {string} id - Combo ID
* @param {Object} data - Update data { name?, models? }
* @returns {Promise<Object>} { success, data: combo }
*/
async function updateCombo(id, data) {
return makeRequest("PUT", `/api/combos/${id}`, data);
}
/**
* Delete combo
* @param {string} id - Combo ID
* @returns {Promise<Object>} { success, data: { success } }
*/
async function deleteCombo(id) {
return makeRequest("DELETE", `/api/combos/${id}`);
}
// ============================================================================
// CLI TOOLS API
// ============================================================================
/**
* Get CLI tool settings
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @returns {Promise<Object>} { success, data: { installed, has9Router, ... } }
*/
async function getCliToolSettings(tool) {
return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
}
/**
* Apply CLI tool settings (POST)
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @param {Object} body - Payload depends on tool
* @returns {Promise<Object>} { success, data }
*/
async function applyCliToolSettings(tool, body) {
return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
}
/**
* Reset CLI tool settings (DELETE)
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @returns {Promise<Object>} { success, data }
*/
async function resetCliToolSettings(tool) {
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
}
// ============================================================================
// SETTINGS API
// ============================================================================
/**
* Get settings
* @returns {Promise<Object>} { success, data: settings }
*/
async function getSettings() {
return makeRequest("GET", "/api/settings");
}
/**
* Update settings
* @param {Object} data - Settings data
* @returns {Promise<Object>} { success, data: settings }
*/
async function updateSettings(data) {
return makeRequest("PATCH", "/api/settings", data);
}
// ============================================================================
// MODELS API
// ============================================================================
/**
* Get all models (internal API)
* @returns {Promise<Object>} { success, data: { models } }
*/
async function getModels() {
return makeRequest("GET", "/api/models");
}
/**
* Get available models from active providers + combos (OpenAI compatible)
* @returns {Promise<Object>} { success, data: { object, data: [...models] } }
*/
async function getAvailableModels() {
return makeRequest("GET", "/v1/models");
}
// ============================================================================
// PROVIDER NODES API (custom providers)
// ============================================================================
async function getProviderNodes() {
return makeRequest("GET", "/api/provider-nodes");
}
async function createProviderNode(data) {
return makeRequest("POST", "/api/provider-nodes", data);
}
async function updateProviderNode(id, data) {
return makeRequest("PUT", `/api/provider-nodes/${id}`, data);
}
async function deleteProviderNode(id) {
return makeRequest("DELETE", `/api/provider-nodes/${id}`);
}
async function validateProviderNode(data) {
return makeRequest("POST", "/api/provider-nodes/validate", data);
}
// ============================================================================
// TUNNEL API
// ============================================================================
/**
* Get tunnel status
* @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
*/
async function getTunnelStatus() {
return makeRequest("GET", "/api/tunnel/status");
}
/**
* Enable tunnel
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
*/
async function enableTunnel() {
return makeRequest("POST", "/api/tunnel/enable");
}
/**
* Disable tunnel
* @returns {Promise<Object>} { success, data: { success } }
*/
async function disableTunnel() {
return makeRequest("POST", "/api/tunnel/disable");
}
// ============================================================================
// EXPORTS
// ============================================================================
module.exports = {
configure,
// Providers
getProviders,
getProviderById,
testProvider,
deleteProvider,
getProviderModels,
// Connection aliases
testConnection: testProvider,
deleteConnection: deleteProvider,
updateConnection,
// OAuth
getOAuthAuthUrl,
exchangeOAuthCode,
getOAuthDeviceCode,
pollOAuthToken,
createApiKeyProvider,
// API Keys
getApiKeys,
createApiKey,
deleteApiKey,
// Combos
getCombos,
getComboById,
createCombo,
updateCombo,
deleteCombo,
// CLI Tools
getCliToolSettings,
applyCliToolSettings,
resetCliToolSettings,
// Settings
getSettings,
updateSettings,
// Tunnel
getTunnelStatus,
enableTunnel,
disableTunnel,
// Models
getModels,
getAvailableModels,
// Provider Nodes (custom providers)
getProviderNodes,
createProviderNode,
updateProviderNode,
deleteProviderNode,
validateProviderNode,
};

View File

@@ -0,0 +1,233 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { maskKey, formatDate, getRelativeTime } = require("../utils/format");
const { showMenuWithBack } = require("../utils/menuHelper");
const { copyToClipboard } = require("../utils/clipboard");
const { getEndpoint } = require("../utils/endpoint");
/**
* Display API keys list with formatted output
* @param {Array} keys - Array of API key objects
* @param {number} port - Server port
*/
function displayApiKeys(keys, port) {
console.log("┌─────────────────────────────────────────────────────────┐");
console.log("│ 🔑 API Keys Management │");
console.log("├─────────────────────────────────────────────────────────┤");
// Note: This function is legacy, endpoint shown in menu header instead
console.log("│ │");
if (keys.length === 0) {
console.log("│ No API keys found. │");
} else {
console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}`);
keys.forEach((key, index) => {
console.log("│ │");
console.log(`${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}`);
const maskedKey = maskKey(key.key);
console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}`);
const created = formatDate(key.createdAt);
console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}`);
if (key.lastUsedAt) {
const lastUsed = getRelativeTime(key.lastUsedAt);
console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}`);
} else {
console.log("│ Last used: Never │");
}
});
}
console.log("│ │");
console.log("│ Actions: │");
console.log("│ 1. Create New API Key │");
console.log("│ 2. View Full Key (by number) │");
console.log("│ 3. Copy Key to Clipboard (by number) │");
console.log("│ 4. Delete Key (by number) │");
console.log("│ 0. ← Back to Main Menu │");
console.log("└─────────────────────────────────────────────────────────┘");
}
/**
* Handle creating new API key
* @returns {Promise<boolean>} Success status
*/
async function handleCreateKey() {
console.log("\n📝 Create New API Key");
console.log("─".repeat(30));
const name = await prompt("Enter key name: ");
if (!name) {
showStatus("Key name cannot be empty", "error");
await pause();
return false;
}
const result = await api.createApiKey(name);
if (!result.success) {
showStatus(`Failed to create key: ${result.error}`, "error");
await pause();
return false;
}
console.log("\n✅ API Key created successfully!");
console.log("\n⚠ IMPORTANT: Save this key now. You won't be able to see it again!");
console.log(`\nKey: ${result.data.key}`);
console.log(`Name: ${result.data.name}`);
console.log(`ID: ${result.data.id}`);
const shouldCopy = await confirm("\nCopy key to clipboard?");
if (shouldCopy) {
if (copyToClipboard(result.data.key)) {
showStatus("Key copied to clipboard!", "success");
} else {
showStatus("Failed to copy to clipboard", "error");
}
}
await pause();
return true;
}
/**
* Handle viewing full API key
* @param {Object} key - API key object
*/
async function handleViewFullKey(key) {
console.log("\n🔍 Full API Key");
console.log("─".repeat(30));
console.log(`Name: ${key.name}`);
console.log(`Key: ${key.key}`);
console.log(`ID: ${key.id}`);
console.log(`Created: ${formatDate(key.createdAt)}`);
if (key.lastUsedAt) {
console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`);
} else {
console.log("Last used: Never");
}
await pause();
}
/**
* Handle copying API key to clipboard
* @param {Object} key - API key object
*/
async function handleCopyKey(key) {
if (copyToClipboard(key.key)) {
showStatus(`Key "${key.name}" copied to clipboard!`, "success");
} else {
showStatus("Failed to copy to clipboard", "error");
}
await pause();
}
/**
* Handle deleting API key
* @param {Object} key - API key object
* @returns {Promise<boolean>} Success status
*/
async function handleDeleteKey(key) {
console.log(`\n⚠️ Delete API Key: ${key.name}`);
console.log("─".repeat(30));
console.log(`Key: ${maskKey(key.key)}`);
console.log(`Created: ${formatDate(key.createdAt)}`);
const confirmed = await confirm("\nAre you sure you want to delete this key?");
if (!confirmed) {
showStatus("Deletion cancelled", "info");
await pause();
return false;
}
const result = await api.deleteApiKey(key.id);
if (!result.success) {
showStatus(`Failed to delete key: ${result.error}`, "error");
await pause();
return false;
}
showStatus("API key deleted successfully", "success");
await pause();
return true;
}
/**
* Show actions for a specific key
* @param {Object} key - API key object
* @param {number} port - Server port
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showKeyActions(key, port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: `🔑 ${key.name}`,
breadcrumb: [...breadcrumb, key.name],
headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`,
items: [
{
label: "Copy to Clipboard",
action: async () => {
await handleCopyKey(key);
return true;
}
},
{
label: "Delete Key",
action: async () => {
await handleDeleteKey(key);
return false; // Exit after delete
}
}
]
});
}
/**
* Main API Keys menu
* @param {number} port - Server port number
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showApiKeysMenu(port, breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
const { endpoint } = await getEndpoint(port);
await showListMenu({
title: "🔑 API Keys Management",
breadcrumb,
headerContent: `Endpoint: ${endpoint}`,
fetchItems: async () => {
const result = await api.getApiKeys();
if (!result.success) {
clearScreen();
showStatus(`Failed to fetch API keys: ${result.error}`, "error");
await pause();
return null;
}
return { items: result.data.keys || [] };
},
formatItem: (key) => `${key.name} (${maskKey(key.key)})`,
onSelect: async (key) => {
await showKeyActions(key, port, breadcrumb);
},
createAction: {
label: "Create New API Key",
action: async () => {
await handleCreateKey();
}
}
});
}
module.exports = {
showApiKeysMenu
};

View File

@@ -0,0 +1,618 @@
const api = require("../api/client");
const { pause, confirm } = require("../utils/input");
const { showStatus } = require("../utils/display");
const { selectModelFromList } = require("../utils/modelSelector");
const { showMenuWithBack } = require("../utils/menuHelper");
const { getEndpoint } = require("../utils/endpoint");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
// Claude model types with defaults (matching Web UI)
const CLAUDE_MODEL_TYPES = [
{ id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
{ id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
{ id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
];
// ─── Shared helpers ───────────────────────────────────────────────────────────
/**
* Get first available API key from server
* @returns {Promise<string|null>}
*/
async function getFirstApiKey() {
const result = await api.getApiKeys();
const keys = result.success ? (result.data.keys || []) : [];
return keys.length > 0 ? keys[0].key : null;
}
// ─── Claude Code ──────────────────────────────────────────────────────────────
/**
* Build header showing current Claude config status
* @returns {Promise<string>}
*/
async function buildClaudeHeader() {
const result = await api.getCliToolSettings("claude");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const settings = result.data.settings;
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
const lines = [];
if (currentUrl) {
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
if (currentKey) {
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
}
} else {
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
}
return lines.join("\n");
}
/**
* Get current Claude model from settings
* @param {string} envKey
* @returns {Promise<string>}
*/
async function getClaudeModel(envKey) {
const result = await api.getCliToolSettings("claude");
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
}
/**
* Quick setup for Claude Code — sets endpoint, key, and all default models
* @param {number} port
*/
async function claudeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Select and save a specific Claude model type
* @param {Object} modelType
* @param {number} port
*/
async function claudeSelectModel(modelType, port) {
const current = await getClaudeModel(modelType.envKey);
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
if (!selected) return;
const env = { [modelType.envKey]: selected };
// Also set base URL if not configured yet
const settingsResult = await api.getCliToolSettings("claude");
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
env.ANTHROPIC_BASE_URL = endpoint;
env.API_TIMEOUT_MS = "600000";
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
}
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? `${modelType.name}${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Claude Code settings
*/
async function claudeReset() {
const result = await api.resetCliToolSettings("claude");
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Claude Code submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showClaudeCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🔧 Claude Code Settings",
breadcrumb,
headerContent: buildClaudeHeader,
refresh: async () => ({
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
}),
items: [
{
label: "⚡ Quick Setup (recommended)",
action: async () => { await claudeQuickSetup(port); return true; }
},
{
label: (d) => `Sonnet → ${d.sonnet}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
},
{
label: (d) => `Opus → ${d.opus}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
},
{
label: (d) => `Haiku → ${d.haiku}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
},
{
label: "Reset to Default",
action: async () => { await claudeReset(); return true; }
}
]
});
}
// ─── Codex CLI ────────────────────────────────────────────────────────────────
/**
* Build header showing current Codex config status
* @returns {Promise<string>}
*/
async function buildCodexHeader() {
const result = await api.getCliToolSettings("codex");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, config } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Parse base_url and model from raw TOML string
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
const model = modelMatch ? modelMatch[1] : "";
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Codex CLI
* @param {number} port
*/
async function codexQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Get model selection
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Codex CLI settings
*/
async function codexReset() {
const result = await api.resetCliToolSettings("codex");
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Codex CLI submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCodexMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Codex CLI Settings",
breadcrumb,
headerContent: buildCodexHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await codexQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await codexReset(); return true; }
}
]
});
}
// ─── Factory Droid ────────────────────────────────────────────────────────────
/**
* Build header showing current Droid config status
* @returns {Promise<string>}
*/
async function buildDroidHeader() {
const result = await api.getCliToolSettings("droid");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router custom model config
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Factory Droid
* @param {number} port
*/
async function droidQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Factory Droid settings
*/
async function droidReset() {
const result = await api.resetCliToolSettings("droid");
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Factory Droid submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showDroidMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Factory Droid Settings",
breadcrumb,
headerContent: buildDroidHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await droidQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await droidReset(); return true; }
}
]
});
}
// ─── Open Claw ────────────────────────────────────────────────────────────────
/**
* Build header showing current OpenClaw config status
* @returns {Promise<string>}
*/
async function buildOpenClawHeader() {
const result = await api.getCliToolSettings("openclaw");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router provider config
const provider = settings?.models?.providers?.["9router"];
const primary = settings?.agents?.defaults?.model?.primary || "";
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Open Claw
* @param {number} port
*/
async function openClawQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Open Claw settings
*/
async function openClawReset() {
const result = await api.resetCliToolSettings("openclaw");
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Open Claw submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showOpenClawMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🦞 Open Claw Settings",
breadcrumb,
headerContent: buildOpenClawHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await openClawQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await openClawReset(); return true; }
}
]
});
}
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
async function buildOpenCodeHeader() {
const result = await api.getCliToolSettings("opencode");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, opencode } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
}
return lines.join("\n");
}
async function openCodeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Pick first model (also becomes active model by default)
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
if (!firstModel) return;
const models = [firstModel];
// Optionally add more models
while (true) {
const more = await confirm(`Add another model? (current: ${models.length})`);
if (!more) break;
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
if (!next) break;
if (!models.includes(next)) models.push(next);
}
// Optional subagent model
let subagentModel = firstModel;
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
if (wantSubagent) {
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
if (picked) subagentModel = picked;
}
const result = await api.applyCliToolSettings("opencode", {
baseUrl: endpoint,
apiKey,
models,
activeModel: firstModel,
subagentModel,
});
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function openCodeReset() {
const result = await api.resetCliToolSettings("opencode");
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showOpenCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "💻 OpenCode CLI Settings",
breadcrumb,
headerContent: buildOpenCodeHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
]
});
}
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
async function buildHermesHeader() {
const result = await api.getCliToolSettings("hermes");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const model = settings?.model || {};
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
return lines.join("\n");
}
async function hermesQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function hermesReset() {
const result = await api.resetCliToolSettings("hermes");
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showHermesMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "⚡ Hermes Agent Settings",
breadcrumb,
headerContent: buildHermesHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
]
});
}
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
/**
* Main CLI Tools menu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCliToolsMenu(port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: "🔧 CLI Tools",
breadcrumb,
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
items: [
{
label: "Claude Code",
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
},
{
label: "Codex CLI",
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
},
{
label: "Factory Droid",
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
},
{
label: "Open Claw",
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
},
{
label: "OpenCode",
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
},
{
label: "Hermes",
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
}
]
});
}
module.exports = { showCliToolsMenu };

477
cli/src/cli/menus/combos.js Normal file
View File

@@ -0,0 +1,477 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { formatDate } = require("../utils/format");
const { selectModelFromList } = require("../utils/modelSelector");
const { showMenuWithBack } = require("../utils/menuHelper");
/**
* Format model to string (handle both string and object)
*/
function formatModel(model) {
if (typeof model === "string") return model;
if (model && typeof model === "object") {
return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model);
}
return String(model);
}
/**
* Show actions for a specific combo
* @param {Object} combo - Combo object
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showComboActions(combo, breadcrumb = []) {
const modelsChain = Array.isArray(combo.models)
? combo.models.map(formatModel).join(" → ")
: "";
await showMenuWithBack({
title: `🔀 ${combo.name}`,
breadcrumb: [...breadcrumb, combo.name],
headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`,
items: [
{
label: "Edit Combo",
action: async () => {
await handleEditSingleCombo(combo);
return true;
}
},
{
label: "Delete Combo",
action: async () => {
await handleDeleteSingleCombo(combo);
return false; // Exit after delete
}
}
]
});
}
/**
* Handle editing a single combo
* @param {Object} combo - Combo to edit
*/
async function handleEditSingleCombo(combo) {
clearScreen();
console.log(`\n✏️ Edit Combo: ${combo.name}\n`);
const newName = await prompt(`New name (Enter to keep "${combo.name}"): `);
const name = newName || combo.name;
console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""));
console.log("\nSelect models for this combo (add one by one):");
const models = [];
let addMore = true;
while (addMore) {
const currentChain = models.length > 0 ? models.join(" → ") : "None";
const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`);
if (model) {
models.push(model);
console.log(`\n✓ Added: ${model}`);
console.log(`Current chain: ${models.join(" → ")}\n`);
const continueAdding = await confirm("Add another model?");
addMore = continueAdding;
} else {
addMore = false;
}
}
// Use new models if any were added, otherwise keep current
const finalModels = models.length > 0 ? models : combo.models;
const result = await api.updateCombo(combo.id, { name, models: finalModels });
if (result.success) {
showStatus("Combo updated!", "success");
} else {
showStatus(`Update failed: ${result.error}`, "error");
}
await pause();
}
/**
* Handle deleting a single combo
* @param {Object} combo - Combo to delete
*/
async function handleDeleteSingleCombo(combo) {
const confirmed = await confirm(`Delete combo "${combo.name}"?`);
if (confirmed) {
const result = await api.deleteCombo(combo.id);
if (result.success) {
showStatus("Combo deleted!", "success");
} else {
showStatus(`Delete failed: ${result.error}`, "error");
}
await pause();
}
}
/**
* Main combos menu - list all combos and actions
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showCombosMenu(breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: "🔀 Combos Management",
breadcrumb,
fetchItems: async () => {
const result = await api.getCombos();
if (!result.success) {
clearScreen();
showStatus(`Failed to load combos: ${result.error}`, "error");
await pause();
return null;
}
return { items: result.data.combos || [] };
},
formatItem: (combo) => {
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
const maxLen = 35;
const displayModels = modelsChain.length > maxLen
? modelsChain.substring(0, maxLen - 3) + "..."
: modelsChain;
return `${combo.name}: ${displayModels}`;
},
onSelect: async (combo) => {
await showComboActions(combo, breadcrumb);
},
createAction: {
label: "Create New Combo",
action: async () => {
await handleCreateCombo();
}
}
});
}
/**
* Show combo detail with stats
*/
async function showComboDetail(comboId) {
clearScreen();
const result = await api.getComboById(comboId);
if (!result.success) {
showStatus(`Failed to load combo: ${result.error}`, "error");
await pause();
return;
}
const combo = result.data;
console.log("┌─────────────────────────────────────────────────────────┐");
console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)}`);
console.log("├─────────────────────────────────────────────────────────┤");
console.log("│ │");
console.log(`│ ID: ${combo.id.padEnd(51)}`);
console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)}`);
console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)}`);
console.log("│ │");
console.log("│ Model Chain: │");
// Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"]
const models = Array.isArray(combo.models) ? combo.models : [];
models.forEach((modelStr, index) => {
const arrow = index < models.length - 1 ? " →" : " ";
const displayText = `${index + 1}. ${modelStr}${arrow}`;
const padding = Math.max(0, 54 - displayText.length);
console.log(`${displayText}${" ".repeat(padding)}`);
});
console.log("│ │");
console.log("└─────────────────────────────────────────────────────────┘");
await pause();
}
/**
* Format combo for menu display
*/
function formatComboLabel(combo) {
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
const maxLen = 40;
const displayModels = modelsChain.length > maxLen
? modelsChain.substring(0, maxLen - 3) + "..."
: modelsChain;
return `${combo.name}: ${displayModels}`;
}
/**
* Create new combo
*/
async function handleCreateCombo() {
clearScreen();
showStatus("Create New Combo", "info");
console.log();
// Get combo name
const name = await prompt("Combo name: ");
if (!name) {
showStatus("Combo name is required", "error");
await pause();
return;
}
// Fetch available models
showStatus("Loading available models...", "info");
const modelsResult = await api.getModels();
if (!modelsResult.success) {
showStatus(`Failed to load models: ${modelsResult.error}`, "error");
await pause();
return;
}
const availableModels = modelsResult.data.models || [];
if (availableModels.length === 0) {
showStatus("No models available. Please add providers first.", "warning");
await pause();
return;
}
// Select models for chain
const selectedModels = [];
console.log();
showStatus("Select models for the chain (minimum 2)", "info");
while (true) {
clearScreen();
console.log(`Creating combo: ${name}`);
console.log(`Selected models (${selectedModels.length}):`);
if (selectedModels.length > 0) {
selectedModels.forEach((m, i) => {
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
});
} else {
console.log(" (none)");
}
console.log();
console.log("Available models:");
availableModels.forEach((m, i) => {
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
});
console.log();
console.log("Actions:");
console.log(" - Enter number to add model");
console.log(" - Type 'done' to finish (min 2 models)");
console.log(" - Type 'cancel' to abort");
const input = await prompt("\nAction: ");
if (input.toLowerCase() === "cancel") {
showStatus("Cancelled", "warning");
await pause();
return;
}
if (input.toLowerCase() === "done") {
if (selectedModels.length < 2) {
showStatus("Please select at least 2 models", "error");
await pause();
continue;
}
break;
}
const num = parseInt(input, 10);
if (isNaN(num) || num < 1 || num > availableModels.length) {
showStatus("Invalid model number", "error");
await pause();
continue;
}
selectedModels.push(availableModels[num - 1]);
}
// Create combo
showStatus("Creating combo...", "info");
const createResult = await api.createCombo({
name,
models: selectedModels
});
if (!createResult.success) {
showStatus(`Failed to create combo: ${createResult.error}`, "error");
await pause();
return;
}
showStatus(`Combo "${name}" created successfully!`, "success");
await pause();
}
/**
* Edit combo - select which combo to edit
*/
async function handleEditCombo(combos) {
if (combos.length === 0) {
showStatus("No combos available", "warning");
await pause();
return;
}
let selectedCombo = null;
await showMenuWithBack({
title: "✏️ Select Combo to Edit",
items: combos.map(combo => ({
label: formatComboLabel(combo),
action: async () => {
selectedCombo = combo;
return false;
}
}))
});
if (!selectedCombo) return;
await editSingleCombo(selectedCombo);
}
/**
* Edit a single combo
*/
async function editSingleCombo(combo) {
clearScreen();
showStatus(`Editing combo: ${combo.name}`, "info");
console.log();
const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `);
const editModels = await confirm("Edit model chain?");
let newModels = combo.models;
if (editModels) {
newModels = [];
while (true) {
clearScreen();
console.log(`Editing combo: ${combo.name}`);
console.log(`Selected models (${newModels.length}):`);
if (newModels.length > 0) {
newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
} else {
console.log(" (none)");
}
console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n");
const model = await selectModelFromList("Add Model", "");
if (model === null) {
showStatus("Cancelled", "warning");
await pause();
return;
}
if (model === "done") {
if (newModels.length < 2) {
showStatus("Please select at least 2 models", "error");
await pause();
continue;
}
break;
}
newModels.push(model);
showStatus(`Added: ${model}`, "success");
await pause();
}
}
const updateData = {};
if (newName) updateData.name = newName;
if (editModels) updateData.models = newModels;
if (Object.keys(updateData).length === 0) {
showStatus("No changes made", "warning");
await pause();
return;
}
showStatus("Updating combo...", "info");
const updateResult = await api.updateCombo(combo.id, updateData);
if (!updateResult.success) {
showStatus(`Failed to update combo: ${updateResult.error}`, "error");
await pause();
return;
}
showStatus("Combo updated successfully!", "success");
await pause();
}
/**
* Delete combo - select which combo to delete
*/
async function handleDeleteCombo(combos) {
if (combos.length === 0) {
showStatus("No combos available", "warning");
await pause();
return;
}
let selectedCombo = null;
await showMenuWithBack({
title: "🗑️ Select Combo to Delete",
items: combos.map(combo => ({
label: formatComboLabel(combo),
action: async () => {
selectedCombo = combo;
return false;
}
}))
});
if (!selectedCombo) return;
clearScreen();
showStatus(`Combo: ${selectedCombo.name}`, "warning");
const modelsDisplay = Array.isArray(selectedCombo.models)
? selectedCombo.models.map(formatModel).join(" → ")
: "";
console.log(`Models: ${modelsDisplay}`);
console.log();
const confirmed = await confirm("Are you sure you want to delete this combo?");
if (!confirmed) {
showStatus("Cancelled", "info");
await pause();
return;
}
showStatus("Deleting combo...", "info");
const deleteResult = await api.deleteCombo(selectedCombo.id);
if (!deleteResult.success) {
showStatus(`Failed to delete combo: ${deleteResult.error}`, "error");
await pause();
return;
}
showStatus("Combo deleted successfully!", "success");
await pause();
}
module.exports = { showCombosMenu };

View File

@@ -0,0 +1,844 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { formatDate, getRelativeTime } = require("../utils/format");
const { showMenuWithBack } = require("../utils/menuHelper");
const { copyToClipboard } = require("../utils/clipboard");
// ANSI colors for styling
const COLORS = {
reset: "\x1b[0m",
bold: "\x1b[1m",
cyan: "\x1b[36m",
dim: "\x1b[2m"
};
// Provider models - static config (synced from open-sse/config/providerModels.js)
const PROVIDER_MODELS = {
cc: [
{ id: "claude-opus-4-5-20251101" },
{ id: "claude-sonnet-4-5-20250929" },
{ id: "claude-haiku-4-5-20251001" },
],
cx: [
{ id: "gpt-5.2-codex" },
{ id: "gpt-5.2" },
{ id: "gpt-5.1-codex-max" },
{ id: "gpt-5.1-codex" },
{ id: "gpt-5.1-codex-mini" },
{ id: "gpt-5.1" },
{ id: "gpt-5-codex" },
{ id: "gpt-5-codex-mini" },
],
gc: [
{ id: "gemini-3-flash-preview" },
{ id: "gemini-3-pro-preview" },
{ id: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash" },
{ id: "gemini-2.5-flash-lite" },
],
qw: [
{ id: "qwen3-coder-plus" },
{ id: "qwen3-coder-flash" },
{ id: "vision-model" },
],
if: [
{ id: "qwen3-coder-plus" },
{ id: "kimi-k2" },
{ id: "kimi-k2-thinking" },
{ id: "deepseek-r1" },
{ id: "deepseek-v3.2-chat" },
{ id: "deepseek-v3.2-reasoner" },
{ id: "minimax-m2" },
{ id: "glm-4.7" },
],
ag: [
{ id: "gemini-3-pro-low" },
{ id: "gemini-3-pro-high" },
{ id: "gemini-3-flash" },
{ id: "gemini-2.5-flash" },
{ id: "claude-sonnet-4-5" },
{ id: "claude-sonnet-4-5-thinking" },
{ id: "claude-opus-4-5-thinking" },
],
gh: [
{ id: "gpt-5" },
{ id: "gpt-5-mini" },
{ id: "gpt-5.1-codex" },
{ id: "gpt-5.1-codex-max" },
{ id: "gpt-4.1" },
{ id: "claude-4.5-sonnet" },
{ id: "claude-4.5-opus" },
{ id: "claude-4.5-haiku" },
{ id: "gemini-3-pro" },
{ id: "gemini-3-flash" },
{ id: "gemini-2.5-pro" },
{ id: "grok-code-fast-1" },
],
kr: [
{ id: "claude-sonnet-4.5" },
{ id: "claude-haiku-4.5" },
],
openai: [
{ id: "gpt-4o" },
{ id: "gpt-4o-mini" },
{ id: "gpt-4-turbo" },
{ id: "o1" },
{ id: "o1-mini" },
],
anthropic: [
{ id: "claude-sonnet-4-20250514" },
{ id: "claude-opus-4-20250514" },
{ id: "claude-3-5-sonnet-20241022" },
],
gemini: [
{ id: "gemini-3-pro-preview" },
{ id: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash" },
{ id: "gemini-2.5-flash-lite" },
],
openrouter: [
{ id: "auto" },
],
glm: [
{ id: "glm-4.7" },
{ id: "glm-4.6v" },
],
kimi: [
{ id: "kimi-latest" },
],
minimax: [
{ id: "MiniMax-M2.1" },
],
};
// Provider definitions
const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code" },
codex: { id: "codex", alias: "cx", name: "OpenAI Codex" },
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" },
github: { id: "github", alias: "gh", name: "GitHub Copilot" },
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" },
iflow: { id: "iflow", alias: "if", name: "iFlow AI" },
qwen: { id: "qwen", alias: "qw", name: "Qwen Code" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI" },
};
const APIKEY_PROVIDERS = {
openrouter: { id: "openrouter", name: "OpenRouter" },
glm: { id: "glm", name: "GLM Coding" },
minimax: { id: "minimax", name: "Minimax Coding" },
kimi: { id: "kimi", name: "Kimi Coding" },
openai: { id: "openai", name: "OpenAI" },
anthropic: { id: "anthropic", name: "Anthropic" },
gemini: { id: "gemini", name: "Gemini" },
};
const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
/**
* Get auth type for provider
* @param {string} providerId - Provider ID
* @returns {string} "oauth" or "apikey"
*/
function getAuthType(providerId) {
return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey";
}
/**
* Count connections by provider
* @param {Array} connections - Array of connection objects
* @returns {Object} Map of providerId -> count
*/
function countConnectionsByProvider(connections) {
const counts = {};
connections.forEach(conn => {
const providerId = conn.provider || conn.providerId;
counts[providerId] = (counts[providerId] || 0) + 1;
});
return counts;
}
/**
* Show main providers menu
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showProvidersMenu(breadcrumb = []) {
// Build provider items list
const providerItems = [];
Object.values(OAUTH_PROVIDERS).forEach(provider => {
providerItems.push({
provider,
authType: "oauth",
label: (data) => {
const count = data.counts[provider.id] || 0;
return `${provider.name} (OAuth) - ${count} Connected`;
},
action: async (data) => {
await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]);
return true;
}
});
});
Object.values(APIKEY_PROVIDERS).forEach(provider => {
providerItems.push({
provider,
authType: "apikey",
label: (data) => {
const count = data.counts[provider.id] || 0;
return `${provider.name} (API) - ${count} Connected`;
},
action: async (data) => {
await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]);
return true;
}
});
});
// Custom provider nodes section
providerItems.push({
label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`,
action: async () => true, // separator, no-op
isSeparator: true,
});
providerItems.push({
label: (data) => {
const count = data.nodeCount || 0;
return `Custom Providers - ${count} Configured`;
},
action: async () => {
await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]);
return true;
}
});
await showMenuWithBack({
title: "🔌 Providers Management",
breadcrumb,
refresh: async () => {
const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]);
if (!provRes.success) {
showStatus(`Failed to fetch providers: ${provRes.error}`, "error");
await pause();
return null;
}
const connections = provRes.data.connections || [];
const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : [];
return {
connections,
counts: countConnectionsByProvider(connections),
nodeCount: nodes.length,
};
},
items: providerItems
});
}
/**
* Build provider header with alias and models
* @param {string} providerId - Provider ID
* @returns {string}
*/
function buildProviderHeader(providerId) {
const provider = ALL_PROVIDERS[providerId];
const alias = provider.alias || providerId;
const lines = [];
lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`);
// Get models from static config
const models = PROVIDER_MODELS[alias] || [];
if (models.length > 0) {
const modelList = models
.slice(0, 5)
.map(m => `${alias}/${m.id}`)
.join(", ");
const more = models.length > 5 ? ` (+${models.length - 5} more)` : "";
lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`);
} else {
lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`);
}
return lines.join("\n");
}
/**
* Show provider detail with connections and actions
* @param {string} providerId - Provider ID
* @param {string} authType - "oauth" or "apikey"
* @param {Array} allConnections - All connections
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) {
const provider = ALL_PROVIDERS[providerId];
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: `🔌 ${provider.name} (${authType.toUpperCase()})`,
breadcrumb,
backLabel: "← Back to Providers",
headerContent: buildProviderHeader(providerId),
fetchItems: async () => {
const response = await api.getProviders();
if (response.success) {
allConnections.length = 0;
allConnections.push(...(response.data.connections || []));
}
const providerConns = allConnections.filter(conn =>
(conn.provider || conn.providerId) === providerId
);
return { items: providerConns };
},
formatItem: (conn) => {
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
const name = conn.name || conn.email || conn.displayName || "Unnamed";
return `${name} (${status})`;
},
onSelect: async (conn) => {
await showConnectionActions(conn, providerId, breadcrumb);
},
createAction: {
label: "Add New Connection",
action: async () => {
await handleAddConnection(providerId, authType);
}
}
});
}
/**
* Show actions for a specific connection
* @param {Object} connection - Connection object
* @param {string} providerId - Provider ID
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showConnectionActions(connection, providerId, breadcrumb = []) {
const name = connection.name || connection.email || connection.displayName || "Unnamed";
const status = connection.testStatus === "active" ? "✓ Active" :
connection.testStatus === "error" ? "✗ Error" : "? Unknown";
await showMenuWithBack({
title: `🔌 ${name}`,
breadcrumb: [...breadcrumb, name],
headerContent: `Connection: ${name}\nStatus: ${status}`,
items: [
{
label: "Rename Connection",
action: async () => {
const newName = await prompt(`New name (current: ${name}): `);
if (newName && newName.trim()) {
showStatus("Renaming connection...", "info");
const result = await api.updateConnection(connection.id, { name: newName.trim() });
if (result.success) {
showStatus("Connection renamed!", "success");
connection.name = newName.trim();
} else {
showStatus(`Rename failed: ${result.error}`, "error");
}
await pause();
}
return true;
}
},
{
label: "Test Connection",
action: async () => {
showStatus("Testing connection...", "info");
const result = await api.testConnection(connection.id);
if (result.success) {
showStatus("Connection is working!", "success");
} else {
showStatus(`Test failed: ${result.error}`, "error");
}
await pause();
return true;
}
},
{
label: "Delete Connection",
action: async () => {
const confirmed = await confirm(`Delete connection "${name}"?`);
if (confirmed) {
const result = await api.deleteConnection(connection.id);
if (result.success) {
showStatus("Connection deleted!", "success");
} else {
showStatus(`Delete failed: ${result.error}`, "error");
}
await pause();
return false; // Exit menu after delete
}
return true;
}
}
]
});
}
/**
* Handle adding new connection
* @param {string} providerId - Provider ID
* @param {string} authType - "oauth" or "apikey"
*/
// Providers that use Device Code Flow (terminal-based polling)
const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"];
/**
* Handle adding new connection - auto-detect flow type
* @param {string} providerId - Provider ID
* @param {string} authType - "oauth" or "apikey"
*/
async function handleAddConnection(providerId, authType) {
if (authType === "apikey") {
await handleAddApiKeyConnection(providerId);
} else {
// OAuth: auto-detect flow type based on provider
if (DEVICE_CODE_PROVIDERS.includes(providerId)) {
// Device Code Flow for GitHub, Qwen, Kiro
await handleAddDeviceCodeConnection(providerId);
} else {
// Authorization Code Flow for Claude, Codex, Gemini, etc.
await handleAddOAuthConnection(providerId);
}
}
}
/**
* Handle adding API Key connection
* @param {string} providerId - Provider ID
*/
async function handleAddApiKeyConnection(providerId) {
clearScreen();
const provider = ALL_PROVIDERS[providerId];
console.log(`\n Add ${provider.name} API Key Connection\n`);
const name = await prompt("Connection Name: ");
if (!name) {
showStatus("Cancelled", "warning");
await pause();
return;
}
const apiKey = await prompt("API Key: ");
if (!apiKey) {
showStatus("Cancelled", "warning");
await pause();
return;
}
showStatus("Creating connection...", "info");
const result = await api.createApiKeyProvider({
provider: providerId,
name,
apiKey
});
if (result.success) {
showStatus("✓ Connection created successfully!", "success");
} else {
showStatus(`✗ Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Handle adding OAuth Authorization Code connection
* User opens URL manually and pastes callback URL
* @param {string} providerId - Provider ID
*/
async function handleAddOAuthConnection(providerId) {
clearScreen();
const provider = ALL_PROVIDERS[providerId];
// Step 1: Get auth URL
showStatus("Requesting authorization URL...", "info");
const authResult = await api.getOAuthAuthUrl(providerId);
if (!authResult.success) {
showStatus(`Failed: ${authResult.error}`, "error");
await pause();
return;
}
const authData = authResult.data || authResult;
const authUrl = authData.authUrl;
const codeVerifier = authData.codeVerifier;
const state = authData.state;
const redirectUri = authData.redirectUri;
if (!authUrl) {
showStatus("Failed: No auth URL received", "error");
await pause();
return;
}
// Step 2: Show URL and instructions
clearScreen();
showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`);
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`);
console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`);
if (copyToClipboard(authUrl)) {
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
}
console.log();
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`);
console.log();
console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`);
console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`);
console.log();
const callbackUrl = await prompt(" Paste callback URL: ");
if (!callbackUrl) {
showStatus("Cancelled", "warning");
await pause();
return;
}
// Step 3: Parse callback URL and extract code
let code, urlState, error;
try {
const url = new URL(callbackUrl.trim());
code = url.searchParams.get("code");
urlState = url.searchParams.get("state");
error = url.searchParams.get("error");
if (error) {
const errorDesc = url.searchParams.get("error_description") || error;
showStatus(`Authorization failed: ${errorDesc}`, "error");
await pause();
return;
}
if (!code) {
showStatus("No authorization code found in URL", "error");
await pause();
return;
}
} catch (err) {
showStatus("Invalid URL format", "error");
await pause();
return;
}
// Step 4: Exchange code for tokens
console.log();
showStatus("Exchanging code for tokens...", "info");
const exchangeResult = await api.exchangeOAuthCode(providerId, {
code,
redirectUri,
codeVerifier,
state: urlState || state
});
if (exchangeResult.success) {
showStatus("Connection created successfully!", "success");
} else {
showStatus(`Failed: ${exchangeResult.error}`, "error");
}
await pause();
}
/**
* Handle adding OAuth Device Code connection
* @param {string} providerId - Provider ID
*/
async function handleAddDeviceCodeConnection(providerId) {
clearScreen();
const provider = ALL_PROVIDERS[providerId];
// Step 1: Request device code
showStatus("Requesting device code...", "info");
const deviceResult = await api.getOAuthDeviceCode(providerId);
if (!deviceResult.success) {
showStatus(`Failed: ${deviceResult.error}`, "error");
await pause();
return;
}
const deviceData = deviceResult.data || deviceResult;
const device_code = deviceData.device_code;
const user_code = deviceData.user_code;
const verification_uri = deviceData.verification_uri;
const verification_uri_complete = deviceData.verification_uri_complete;
const codeVerifier = deviceData.codeVerifier;
const extraData = deviceData.extraData || deviceData;
if (!device_code) {
showStatus("Failed: No device code received", "error");
await pause();
return;
}
// Step 2: Show instructions
clearScreen();
const deviceUrl = verification_uri_complete || verification_uri;
showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`);
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`);
if (copyToClipboard(deviceUrl)) {
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
}
console.log();
if (!verification_uri_complete && user_code) {
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`);
console.log();
}
console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`);
console.log();
// Step 3: Poll for token
const maxAttempts = 60; // 5 minutes (5s interval)
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, 5000));
const pollResult = await api.pollOAuthToken(providerId, {
deviceCode: device_code,
codeVerifier,
extraData
});
if (pollResult.success) {
showStatus("\nConnection created successfully!", "success");
await pause();
return;
}
// Check if still pending (pending flag is at root level, not in data)
const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down";
if (!isPending) {
showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error");
await pause();
return;
}
process.stdout.write(".");
}
showStatus("\nTimeout waiting for authorization", "error");
await pause();
}
// ============================================================================
// CUSTOM PROVIDERS (provider nodes)
// ============================================================================
const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"];
const OPENAI_API_TYPES = ["chat", "responses"];
/**
* Show custom providers section in main providers menu
* @param {Array} nodes - List of provider nodes
* @param {Array} connections - All connections
* @param {Array<string>} breadcrumb
*/
async function showCustomProvidersMenu(breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: "🔧 Custom Providers",
breadcrumb,
backLabel: "← Back to Providers",
fetchItems: async () => {
const res = await api.getProviderNodes();
if (!res.success) return { items: [] };
return { items: res.data.nodes || res.data || [] };
},
formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`,
onSelect: async (node) => {
await showCustomNodeDetail(node, [...breadcrumb, node.name]);
},
createAction: {
label: " Add Custom Provider",
action: async () => {
await handleAddCustomNode();
}
}
});
}
/**
* Show detail menu for a custom provider node
*/
async function showCustomNodeDetail(node, breadcrumb = []) {
await showMenuWithBack({
title: `🔧 ${node.name}`,
breadcrumb,
headerContent: [
`Type: ${node.type}`,
`Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`,
`Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`,
].join("\n"),
items: [
{
label: "Connections",
action: async () => {
await showCustomNodeConnections(node, breadcrumb);
return true;
}
},
{
label: "Edit Node",
action: async () => {
await handleEditCustomNode(node);
return true;
}
},
{
label: "Delete Node",
action: async () => {
const confirmed = await confirm(`Delete "${node.name}" and all its connections?`);
if (confirmed) {
const res = await api.deleteProviderNode(node.id);
if (res.success) {
showStatus("Node deleted!", "success");
} else {
showStatus(`Delete failed: ${res.error}`, "error");
}
await pause();
return false;
}
return true;
}
}
]
});
}
/**
* Show connections for a custom provider node
*/
async function showCustomNodeConnections(node, breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: `🔌 ${node.name} Connections`,
breadcrumb,
backLabel: "← Back",
fetchItems: async () => {
const res = await api.getProviders();
if (!res.success) return { items: [] };
const all = res.data.connections || [];
const items = all.filter(c => c.provider === node.id);
return { items };
},
formatItem: (conn) => {
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
return `${conn.name || "Unnamed"} (${status})`;
},
onSelect: async (conn) => {
await showConnectionActions(conn, node.id, breadcrumb);
},
createAction: {
label: "Add API Key Connection",
action: async () => {
await handleAddCustomNodeConnection(node);
}
}
});
}
/**
* Add API key connection to a custom provider node
*/
async function handleAddCustomNodeConnection(node) {
clearScreen();
console.log(`\n Add Connection to ${node.name}\n`);
const name = await prompt("Connection Name: ");
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
const apiKey = await prompt("API Key: ");
if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; }
showStatus("Creating connection...", "info");
const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey });
showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
await pause();
}
/**
* Handle adding a new custom provider node
*/
async function handleAddCustomNode() {
clearScreen();
console.log("\n Add Custom Provider\n");
// Step 1: Select type
const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
console.log(`Select type:\n${typeChoices}\n`);
const typeInput = await prompt("Type (1/2): ");
const typeIdx = parseInt(typeInput) - 1;
if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) {
showStatus("Cancelled", "warning"); await pause(); return;
}
const type = CUSTOM_NODE_TYPES[typeIdx];
// Step 2: Inputs
const name = await prompt("Name: ");
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): ");
if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; }
const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): ");
if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; }
// Step 3: API type (OpenAI only)
let apiType;
if (type === "openai-compatible") {
const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
console.log(`\nAPI Type:\n${apiTypeChoices}\n`);
const apiTypeInput = await prompt("API Type (1/2, default 1): ");
const apiTypeIdx = parseInt(apiTypeInput) - 1;
apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat";
}
showStatus("Creating provider node...", "info");
const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) };
const res = await api.createProviderNode(body);
showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
await pause();
}
/**
* Handle editing a custom provider node
*/
async function handleEditCustomNode(node) {
clearScreen();
console.log(`\n✏️ Edit ${node.name}\n`);
console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`);
const name = await prompt(`Name (${node.name}): `);
const baseUrl = await prompt(`Base URL (${node.baseUrl}): `);
const prefix = await prompt(`Prefix (${node.prefix}): `);
const updates = {};
if (name && name.trim()) updates.name = name.trim();
if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim();
if (prefix && prefix.trim()) updates.prefix = prefix.trim();
if (!Object.keys(updates).length) {
showStatus("No changes", "warning"); await pause(); return;
}
showStatus("Updating...", "info");
const res = await api.updateProviderNode(node.id, updates);
if (res.success) {
Object.assign(node, updates);
showStatus("✓ Updated!", "success");
} else {
showStatus(`✗ Failed: ${res.error}`, "error");
}
await pause();
}
module.exports = { showProvidersMenu };

View File

@@ -0,0 +1,207 @@
const path = require("path");
const fs = require("fs");
const os = require("os");
const api = require("../api/client");
const { confirm, pause } = require("../utils/input");
const { showStatus } = require("../utils/display");
const { showMenuWithBack } = require("../utils/menuHelper");
// ANSI colors
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
yellow: "\x1b[33m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
const DEFAULT_PASSWORD = "123456";
// Resolve db.json path (matches app/src/lib/dataDir.js convention)
function getDbPath() {
return process.platform === "win32"
? path.join(process.env.APPDATA || "", "9router", "db.json")
: path.join(os.homedir(), ".9router", "db.json");
}
/**
* Show settings menu (tunnel + RTK + reset password)
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showSettingsMenu(breadcrumb = []) {
await showMenuWithBack({
title: "⚙️ Settings",
breadcrumb,
headerContent: async (data) => {
const lines = [];
// Tunnel section
const tunnel = data?.tunnel || {};
if (tunnel.enabled && tunnel.publicUrl) {
lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
} else {
lines.push(` Endpoint: http://localhost:20128/v1`);
lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
}
// RTK section
const rtkOn = data?.settings?.rtkEnabled !== false;
lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`);
// Auth mode section
const authMode = data?.settings?.authMode || "password";
const authColor = authMode === "password" ? COLORS.green : COLORS.yellow;
lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`);
return lines.join("\n");
},
refresh: async () => {
const [tunnelRes, settingsRes] = await Promise.all([
api.getTunnelStatus(),
api.getSettings()
]);
return {
tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
settings: settingsRes.success ? (settingsRes.data || {}) : {}
};
},
items: [
{
label: "Tunnel ON",
action: async () => { await enableTunnel(); return true; }
},
{
label: "Tunnel OFF",
action: async () => { await disableTunnel(); return true; }
},
{
label: (d) => {
const on = d?.settings?.rtkEnabled !== false;
return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`;
},
action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; }
},
{
label: "🔑 Reset Password to Default",
action: async () => { await resetPassword(); return true; }
},
{
label: (d) => {
const mode = d?.settings?.authMode || "password";
return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`;
},
action: async () => { await resetAuthMode(); return true; }
}
]
});
}
/**
* Reset authMode to "password" via API. Used when OIDC is misconfigured
* and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token.
*/
async function resetAuthMode() {
const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?");
if (!ok) {
showStatus("Cancelled", "info");
await pause();
return;
}
const result = await api.updateSettings({ authMode: "password" });
if (result.success) {
showStatus("Auth mode reset to password. OIDC disabled.", "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Enable tunnel via API
*/
async function enableTunnel() {
showStatus("Creating tunnel...", "info");
const result = await api.enableTunnel();
if (result.success) {
const { publicUrl, shortId, alreadyRunning } = result.data || {};
if (alreadyRunning) {
showStatus(`Tunnel already running: ${publicUrl}`, "success");
} else {
showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success");
}
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Disable tunnel via API
*/
async function disableTunnel() {
const result = await api.disableTunnel();
if (result.success) {
showStatus("Tunnel disabled", "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Toggle RTK (Token Saver) via API
* @param {boolean} currentlyOn
*/
async function toggleRtk(currentlyOn) {
const next = !currentlyOn;
const result = await api.updateSettings({ rtkEnabled: next });
if (result.success) {
showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Reset dashboard password by clearing the hash in db.json (Phase B).
* After reset, user can log in with the default password "123456".
*/
async function resetPassword() {
const dbPath = getDbPath();
if (!fs.existsSync(dbPath)) {
showStatus(`db.json not found at ${dbPath}`, "error");
await pause();
return;
}
const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`);
if (!ok) {
showStatus("Cancelled", "info");
await pause();
return;
}
try {
const raw = fs.readFileSync(dbPath, "utf-8");
const db = JSON.parse(raw);
if (db.settings && Object.prototype.hasOwnProperty.call(db.settings, "password")) {
delete db.settings.password;
}
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success");
} catch (err) {
showStatus(`Failed to reset password: ${err.message}`, "error");
}
await pause();
}
module.exports = { showSettingsMenu };

109
cli/src/cli/terminalUI.js Normal file
View File

@@ -0,0 +1,109 @@
const api = require("./api/client");
const { showMenuWithBack } = require("./utils/menuHelper");
const { showProvidersMenu } = require("./menus/providers");
const { showApiKeysMenu } = require("./menus/apiKeys");
const { showCombosMenu } = require("./menus/combos");
const { showSettingsMenu } = require("./menus/settings");
const { showCliToolsMenu } = require("./menus/cliTools");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
/**
* Build header content with endpoint and API keys
* @param {number} port - Server port
* @returns {Promise<string>} Header content string
*/
async function buildHeaderContent(port) {
const [keysResult, tunnelResult] = await Promise.all([
api.getApiKeys(),
api.getTunnelStatus()
]);
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
const tunnelEnabled = tunnel.enabled === true;
const lines = [];
if (tunnelEnabled && tunnel.publicUrl) {
lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
} else {
lines.push(`Endpoint: http://localhost:${port}/v1`);
lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
}
if (keys.length === 0) {
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
} else {
lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`);
keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`));
}
return lines.join("\n");
}
/**
* Start Terminal UI
* @param {number} port - Server port number
*/
async function startTerminalUI(port) {
// Configure API client
api.configure({ port });
const basePath = ["9Router"];
// Main menu
await showMenuWithBack({
title: "📡 9Router Terminal UI",
breadcrumb: basePath,
headerContent: async () => await buildHeaderContent(port),
refresh: async () => ({}), // Refresh header on each loop
items: [
{
label: "Providers",
action: async () => {
await showProvidersMenu([...basePath, "Providers"]);
return true; // Continue
}
},
{
label: "API Keys",
action: async () => {
await showApiKeysMenu(port, [...basePath, "API Keys"]);
return true;
}
},
{
label: "Combos",
action: async () => {
await showCombosMenu([...basePath, "Combos"]);
return true;
}
},
{
label: "CLI Tools",
action: async () => {
await showCliToolsMenu(port, [...basePath, "CLI Tools"]);
return true;
}
},
{
label: "Settings",
action: async () => {
await showSettingsMenu([...basePath, "Settings"]);
return true;
}
}
],
backLabel: "← Back to Interface Menu"
});
}
module.exports = { startTerminalUI };

View File

@@ -0,0 +1,321 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execSync } = require("child_process");
const APP_NAME = "9router";
const APP_LABEL = "com.9router.autostart";
/**
* Get the command to run 9router in tray mode
*/
function getStartCommand() {
// Find the global npm bin path for 9router
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerPath = path.join(npmBin, "9router");
if (fs.existsSync(routerPath)) {
return `"${routerPath}" --tray --skip-update`;
}
} catch (e) {
// npm not available or failed
}
// Fallback: use npx
return "npx 9router --tray --skip-update";
}
/**
* Enable auto startup on OS boot
* @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
* @returns {boolean} success
*/
function enableAutoStart(cliPath) {
const platform = process.platform;
// Skip on unsupported platforms
if (!["darwin", "win32", "linux"].includes(platform)) {
return false;
}
// Skip on Linux without GUI
if (platform === "linux" && !process.env.DISPLAY) {
return false;
}
try {
if (platform === "darwin") {
return enableMacOS(cliPath);
} else if (platform === "win32") {
return enableWindows(cliPath);
} else if (platform === "linux") {
return enableLinux(cliPath);
}
} catch (err) {
// Silent fail - autostart is optional
}
return false;
}
/**
* Disable auto startup
* @returns {boolean} success
*/
function disableAutoStart() {
const platform = process.platform;
try {
if (platform === "darwin") {
return disableMacOS();
} else if (platform === "win32") {
return disableWindows();
} else if (platform === "linux") {
return disableLinux();
}
} catch (err) {
// Silent fail
}
return false;
}
/**
* Check if autostart is enabled
* @returns {boolean}
*/
function isAutoStartEnabled() {
const platform = process.platform;
try {
if (platform === "darwin") {
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
return fs.existsSync(plistPath);
} else if (platform === "win32") {
const startupPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
return fs.existsSync(startupPath);
} else if (platform === "linux") {
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
return fs.existsSync(desktopPath);
}
} catch (e) {}
return false;
}
// ============ macOS ============
function enableMacOS(cliPath) {
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
// Ensure directory exists
if (!fs.existsSync(launchAgentsDir)) {
fs.mkdirSync(launchAgentsDir, { recursive: true });
}
// Get absolute paths for node and 9router script
const nodePath = process.execPath;
let routerScript;
if (cliPath) {
// Use provided path (from running cli.js)
routerScript = path.resolve(cliPath);
} else {
// Fallback: try to resolve from npm bin
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerLink = path.join(npmBin, "9router");
routerScript = fs.realpathSync(routerLink);
} catch (e) {
// Last resort fallback
routerScript = "/usr/local/lib/node_modules/9router/cli.js";
}
}
// Determine user shell
const userShell = process.env.SHELL || '/bin/zsh';
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${APP_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${userShell}</string>
<string>-l</string>
<string>-c</string>
<string>${nodePath} ${routerScript} --tray --skip-update</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/9router.log</string>
<key>StandardErrorPath</key>
<string>/tmp/9router.error.log</string>
</dict>
</plist>`;
fs.writeFileSync(plistPath, plistContent);
// Load the launch agent
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
} catch (e) {}
return true;
}
function disableMacOS() {
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
} catch (e) {}
if (fs.existsSync(plistPath)) {
fs.unlinkSync(plistPath);
}
return true;
}
// ============ Windows ============
function enableWindows(cliPath) {
const startupDir = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
// Ensure startup directory exists
if (!fs.existsSync(startupDir)) {
return false;
}
// Get absolute paths
const nodePath = process.execPath;
let routerScript;
if (cliPath) {
// Use provided path (from running cli.js)
routerScript = path.resolve(cliPath);
} else {
// Fallback: try to resolve from npm bin
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", shell: true, stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerLink = path.join(npmBin, "9router.cmd");
if (fs.existsSync(routerLink)) {
routerScript = routerLink;
} else {
// Try to resolve actual script
const routerJs = path.join(npmBin, "../lib/node_modules/9router/cli.js");
if (fs.existsSync(routerJs)) {
routerScript = routerJs;
}
}
} catch (e) {
// Fallback
}
}
// Create VBS script to run hidden (no console window)
let vbsContent;
if (routerScript && routerScript.endsWith(".js")) {
// Run node directly with script
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
`;
} else if (routerScript) {
// Run .cmd file
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run """${routerScript}"" --tray --skip-update", 0, False
`;
} else {
// Fallback to npx
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "npx 9router --tray --skip-update", 0, False
`;
}
fs.writeFileSync(vbsPath, vbsContent);
return true;
}
function disableWindows() {
const vbsPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
if (fs.existsSync(vbsPath)) {
fs.unlinkSync(vbsPath);
}
return true;
}
// ============ Linux ============
function enableLinux(cliPath) {
const autostartDir = path.join(os.homedir(), ".config", "autostart");
const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
// Ensure directory exists
if (!fs.existsSync(autostartDir)) {
try {
fs.mkdirSync(autostartDir, { recursive: true });
} catch (e) {
return false;
}
}
// Get absolute paths
const nodePath = process.execPath;
let routerScript;
if (cliPath) {
// Use provided path (from running cli.js)
routerScript = path.resolve(cliPath);
} else {
// Fallback: try to resolve from npm bin
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerLink = path.join(npmBin, "9router");
if (fs.existsSync(routerLink)) {
routerScript = fs.realpathSync(routerLink);
}
} catch (e) {
// Last resort fallback
routerScript = "/usr/local/lib/node_modules/9router/cli.js";
}
}
const desktopContent = `[Desktop Entry]
Type=Application
Name=9Router
Comment=9Router API Proxy
Exec=${nodePath} ${routerScript} --tray --skip-update
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
`;
fs.writeFileSync(desktopPath, desktopContent);
return true;
}
function disableLinux() {
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
if (fs.existsSync(desktopPath)) {
fs.unlinkSync(desktopPath);
}
return true;
}
module.exports = {
enableAutoStart,
disableAutoStart,
isAutoStartEnabled
};

BIN
cli/src/cli/tray/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

BIN
cli/src/cli/tray/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

236
cli/src/cli/tray/tray.js Normal file
View File

@@ -0,0 +1,236 @@
const { exec } = require("child_process");
const fs = require("fs");
const path = require("path");
let trayInstance = null;
let isWinTray = false;
/**
* Get icon base64 from file — used for systray (mac/linux)
*/
function getIconBase64() {
const isWin = process.platform === "win32";
const iconFile = isWin ? "icon.ico" : "icon.png";
try {
const iconPath = path.join(__dirname, iconFile);
if (fs.existsSync(iconPath)) {
return fs.readFileSync(iconPath).toString("base64");
}
} catch (e) {}
// Fallback: minimal green dot icon (PNG)
return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC";
}
/**
* Check if system tray is supported on current OS
* Supported: macOS, Windows, Linux (with GUI)
*/
function isTraySupported() {
const platform = process.platform;
if (!["darwin", "win32", "linux"].includes(platform)) {
return false;
}
if (platform === "linux" && !process.env.DISPLAY) {
return false;
}
return true;
}
/**
* Initialize system tray with menu
* @param {Object} options - { port, onQuit, onOpenDashboard }
* @returns {Object|null} tray instance or null if not supported/failed
*/
function initTray(options) {
if (!isTraySupported()) {
return null;
}
// Windows uses PowerShell NotifyIcon (AV-safe), others use systray
if (process.platform === "win32") {
return initWindowsTray(options);
}
return initUnixTray(options);
}
/**
* Build menu items array shared between platforms
*/
function buildMenuItems(port, autostartEnabled) {
return [
{ title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false },
{ title: "Open Dashboard", tooltip: "Open in browser", enabled: true },
{
title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
tooltip: "Run on OS startup",
enabled: true
},
{ title: "Quit", tooltip: "Stop server and exit", enabled: true }
];
}
// Menu item indexes
const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 };
/**
* Get current autostart state
*/
function getAutostartEnabled() {
try {
const { isAutoStartEnabled } = require("./autostart");
return isAutoStartEnabled();
} catch (e) {
return false;
}
}
/**
* Handle menu item click (shared logic)
*/
function handleClick(index, options, onAutostartToggle) {
const { onQuit, onOpenDashboard, port } = options;
if (index === MENU_INDEX.DASHBOARD) {
if (onOpenDashboard) onOpenDashboard();
else openBrowser(`http://localhost:${port}/dashboard`);
} else if (index === MENU_INDEX.AUTOSTART) {
const enabled = getAutostartEnabled();
try {
const { enableAutoStart, disableAutoStart } = require("./autostart");
if (enabled) disableAutoStart();
else enableAutoStart();
onAutostartToggle(!enabled);
} catch (e) {}
} else if (index === MENU_INDEX.QUIT) {
console.log("\n👋 Shutting down...");
if (onQuit) onQuit();
killTray();
setTimeout(() => process.exit(0), 500);
}
}
/**
* Windows tray via PowerShell NotifyIcon
*/
function initWindowsTray(options) {
const { port } = options;
try {
const { initWinTray } = require("./trayWin");
const iconPath = path.join(__dirname, "icon.ico");
const autostartEnabled = getAutostartEnabled();
const items = buildMenuItems(port, autostartEnabled);
trayInstance = initWinTray({
iconPath,
tooltip: `9Router - Port ${port}`,
items,
onClick: (index) => {
handleClick(index, options, (newEnabled) => {
const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start";
trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true);
});
}
});
isWinTray = true;
return trayInstance;
} catch (err) {
return null;
}
}
/**
* macOS/Linux tray via systray binary
*/
function resolveSystray() {
// Try local first (dev), then runtime dir (production lazy install)
try {
return require("systray").default;
} catch (e) {}
try {
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
const systrayPath = path.join(getRuntimeNodeModules(), "systray");
return require(systrayPath).default;
} catch (e) {
return null;
}
}
function initUnixTray(options) {
const { port } = options;
try {
const SysTray = resolveSystray();
if (!SysTray) return null;
const autostartEnabled = getAutostartEnabled();
const items = buildMenuItems(port, autostartEnabled);
const menu = {
icon: getIconBase64(),
title: "",
tooltip: `9Router - Port ${port}`,
items
};
trayInstance = new SysTray({ menu, debug: false, copyDir: true });
isWinTray = false;
trayInstance.onClick((action) => {
handleClick(action.seq_id, options, (newEnabled) => {
trayInstance.sendAction({
type: "update-item",
item: {
title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
tooltip: "Run on OS startup",
enabled: true
},
seq_id: MENU_INDEX.AUTOSTART
});
});
});
trayInstance.onReady(() => {});
trayInstance.onError(() => {});
return trayInstance;
} catch (err) {
return null;
}
}
/**
* Kill/close system tray gracefully
*/
function killTray() {
const instance = trayInstance;
const wasWin = isWinTray;
trayInstance = null;
if (instance) {
try {
if (wasWin) instance.kill();
else instance.kill(true);
} catch (e) {}
}
}
/**
* Open browser
*/
function openBrowser(url) {
const platform = process.platform;
let cmd;
if (platform === "darwin") {
cmd = `open "${url}"`;
} else if (platform === "win32") {
cmd = `start "" "${url}"`;
} else {
cmd = `xdg-open "${url}"`;
}
exec(cmd);
}
module.exports = {
initTray,
killTray
};

79
cli/src/cli/tray/tray.ps1 Normal file
View File

@@ -0,0 +1,79 @@
# 9Router tray icon for Windows using NotifyIcon
# IPC: stdin JSON commands, stdout JSON events
param([string]$IconPath, [string]$Tooltip)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
$script:notifyIcon.Text = $Tooltip
$script:notifyIcon.Visible = $true
$script:menu = New-Object System.Windows.Forms.ContextMenuStrip
$script:notifyIcon.ContextMenuStrip = $script:menu
$script:items = @()
function Write-Event($obj) {
$json = $obj | ConvertTo-Json -Compress
[Console]::Out.WriteLine($json)
[Console]::Out.Flush()
}
function Add-MenuItem($index, $title, $enabled) {
$item = New-Object System.Windows.Forms.ToolStripMenuItem
$item.Text = $title
$item.Enabled = $enabled
$idx = $index
$item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure())
$script:menu.Items.Add($item) | Out-Null
$script:items += $item
}
function Update-MenuItem($index, $title, $enabled) {
if ($index -lt $script:items.Count) {
$script:items[$index].Text = $title
$script:items[$index].Enabled = $enabled
}
}
function Set-Tooltip($text) {
# NotifyIcon.Text max 63 chars
if ($text.Length -gt 63) { $text = $text.Substring(0, 63) }
$script:notifyIcon.Text = $text
}
# Background reader thread polls stdin via timer on UI thread
$script:timer = New-Object System.Windows.Forms.Timer
$script:timer.Interval = 100
$script:timer.Add_Tick({
try {
while ([Console]::In.Peek() -ne -1) {
$line = [Console]::In.ReadLine()
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$cmd = $line | ConvertFrom-Json
switch ($cmd.action) {
"add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled }
"update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled }
"set-tooltip" { Set-Tooltip $cmd.text }
"ready" { Write-Event @{ type = "ready" } }
"kill" {
$script:notifyIcon.Visible = $false
$script:notifyIcon.Dispose()
[System.Windows.Forms.Application]::Exit()
}
}
}
} catch {
Write-Event @{ type = "error"; message = $_.Exception.Message }
}
})
$script:timer.Start()
Write-Event @{ type = "started" }
[System.Windows.Forms.Application]::Run()

View File

@@ -0,0 +1,89 @@
const { spawn } = require("child_process");
const path = require("path");
const readline = require("readline");
// PowerShell-based tray for Windows (AV-safe, zero binary deps)
let psProcess = null;
let clickHandler = null;
/**
* Send JSON command to PowerShell tray process via stdin
*/
function sendCommand(cmd) {
if (psProcess && psProcess.stdin.writable) {
psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8");
}
}
/**
* Initialize Windows tray using PowerShell NotifyIcon
* @param {Object} options - { iconPath, tooltip, items, onClick }
* items: [{ title, enabled }]
* @returns {Object|null} controller with sendAction/kill
*/
function initWinTray(options) {
const { iconPath, tooltip, items, onClick } = options;
clickHandler = onClick;
const scriptPath = path.join(__dirname, "tray.ps1");
try {
psProcess = spawn(
"powershell.exe",
[
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden",
"-InputFormat", "Text",
"-OutputFormat", "Text",
"-File", scriptPath,
"-IconPath", iconPath,
"-Tooltip", tooltip
],
{ windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }
);
} catch (err) {
return null;
}
const rl = readline.createInterface({ input: psProcess.stdout });
rl.on("line", (line) => {
try {
const evt = JSON.parse(line);
if (evt.type === "click" && clickHandler) {
clickHandler(evt.index);
}
} catch (e) {}
});
psProcess.on("error", () => {});
psProcess.stderr.on("data", () => {});
// Send initial menu items
items.forEach((item, index) => {
sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled });
});
return {
updateItem(index, title, enabled) {
sendCommand({ action: "update-item", index, title, enabled });
},
setTooltip(text) {
sendCommand({ action: "set-tooltip", text });
},
kill() {
try {
sendCommand({ action: "kill" });
} catch (e) {}
setTimeout(() => {
if (psProcess && !psProcess.killed) {
try { psProcess.kill(); } catch (e) {}
}
psProcess = null;
}, 300);
}
};
}
module.exports = { initWinTray };

View File

@@ -0,0 +1,30 @@
const { execSync } = require("child_process");
/**
* Copy text to clipboard based on OS
* @param {string} text - Text to copy
* @returns {boolean} Success status
*/
function copyToClipboard(text) {
try {
const platform = process.platform;
if (platform === "darwin") {
execSync("pbcopy", { input: text });
} else if (platform === "win32") {
execSync("clip", { input: text });
} else {
// Linux - try xclip first, then xsel
try {
execSync("xclip -selection clipboard", { input: text });
} catch {
execSync("xsel --clipboard --input", { input: text });
}
}
return true;
} catch (error) {
return false;
}
}
module.exports = { copyToClipboard };

View File

@@ -0,0 +1,156 @@
const { formatNumber } = require("./format");
// ANSI color codes
const COLORS = {
reset: "\x1b[0m",
success: "\x1b[32m",
error: "\x1b[31m",
warning: "\x1b[33m",
info: "\x1b[36m",
dim: "\x1b[2m",
bold: "\x1b[1m",
bright: "\x1b[1m",
cyan: "\x1b[36m"
};
// Box drawing characters
const BOX_CHARS = {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│"
};
/**
* Draw a box with border around content
* @param {string} title - Box title
* @param {string} content - Content to display inside box
* @param {number} [width=60] - Box width
*/
function showBox(title, content, width = 60) {
const innerWidth = width - 4;
const lines = content.split("\n");
// Top border with title
const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) +
` ${title} ` +
BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) +
BOX_CHARS.topRight;
console.log(topBorder);
// Content lines
lines.forEach(line => {
const paddedLine = line.padEnd(innerWidth);
console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`);
});
// Bottom border
const bottomBorder = BOX_CHARS.bottomLeft +
BOX_CHARS.horizontal.repeat(innerWidth + 2) +
BOX_CHARS.bottomRight;
console.log(bottomBorder);
}
/**
* Display a menu with numbered items
* @param {string} title - Menu title
* @param {string[]} items - Array of menu items
* @param {string} [footer] - Optional footer text
*/
function showMenu(title, items, footer) {
console.log(`\n${COLORS.bold}${title}${COLORS.reset}`);
console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset);
items.forEach((item, index) => {
console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`);
});
if (footer) {
console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`);
}
console.log();
}
/**
* Display data in table format
* @param {string[]} headers - Array of column headers
* @param {Array<Array<string|number>>} rows - Array of row data
*/
function showTable(headers, rows) {
if (!headers.length || !rows.length) {
return;
}
// Calculate column widths
const colWidths = headers.map((header, i) => {
const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length));
return Math.max(header.length, maxDataWidth);
});
// Print header
const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ ");
console.log(COLORS.bold + headerRow + COLORS.reset);
// Print separator
const separator = colWidths.map(w => "─".repeat(w)).join("─┼─");
console.log(COLORS.dim + separator + COLORS.reset);
// Print rows
rows.forEach(row => {
const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ ");
console.log(rowStr);
});
}
/**
* Show colored status message
* @param {string} message - Message to display
* @param {string} [type="info"] - Status type: success, error, warning, info
*/
function showStatus(message, type = "info") {
const symbols = {
success: "✓",
error: "✗",
warning: "⚠",
info: ""
};
const color = COLORS[type] || COLORS.info;
const symbol = symbols[type] || symbols.info;
console.log(`${color}${symbol} ${message}${COLORS.reset}`);
}
/**
* Clear the terminal screen
*/
function clearScreen() {
console.clear();
}
/**
* Show menu header with title and subtitle
* @param {string} title - Main title
* @param {string} subtitle - Optional subtitle
*/
function showHeader(title, subtitle) {
console.log(`\n${"=".repeat(60)}`);
console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
if (subtitle) {
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
}
console.log(`${"=".repeat(60)}\n`);
}
module.exports = {
showBox,
showMenu,
showTable,
showStatus,
clearScreen,
showHeader
};

View File

@@ -0,0 +1,32 @@
const api = require("../api/client");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m"
};
/**
* Get endpoint URL based on tunnel status
* @param {number} port - Local server port
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
*/
async function getEndpoint(port) {
const result = await api.getTunnelStatus();
const tunnelEnabled = result.success && result.data?.enabled === true;
const publicUrl = result.success ? result.data?.publicUrl : "";
const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
return { endpoint, tunnelEnabled };
}
/**
* Get endpoint with color formatting
* @param {number} port - Local server port
* @returns {Promise<string>} Colored endpoint string
*/
async function getEndpointColored(port) {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
}
module.exports = { getEndpoint, getEndpointColored };

125
cli/src/cli/utils/format.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* Truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated text
*/
function truncate(text, maxLength) {
if (!text || text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + "...";
}
/**
* Mask API key showing only first and last characters
* @param {string} key - API key to mask
* @returns {string} Masked key
*/
function maskKey(key) {
if (!key || key.length < 8) {
return "***";
}
const firstChars = key.substring(0, 4);
const lastChars = key.substring(key.length - 4);
return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`;
}
/**
* Format date to readable string
* @param {Date|string|number} date - Date to format
* @returns {string} Formatted date string
*/
function formatDate(date) {
const d = new Date(date);
if (isNaN(d.getTime())) {
return "Invalid Date";
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
const seconds = String(d.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Format number with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
function formatNumber(num) {
if (typeof num !== "number" || isNaN(num)) {
return "0";
}
return num.toLocaleString("en-US");
}
/**
* Format bytes to human readable size
* @param {number} bytes - Bytes to format
* @returns {string} Formatted size string
*/
function formatBytes(bytes) {
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
/**
* Get relative time string
* @param {Date|string|number} date - Date to compare
* @returns {string} Relative time string
*/
function getRelativeTime(date) {
const d = new Date(date);
if (isNaN(d.getTime())) {
return "Invalid Date";
}
const now = new Date();
const diffMs = now - d;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffDay / 365);
if (diffSec < 60) {
return "just now";
} else if (diffMin < 60) {
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
} else if (diffDay < 30) {
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
} else if (diffMonth < 12) {
return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`;
} else {
return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`;
}
}
module.exports = {
truncate,
maskKey,
formatDate,
formatNumber,
formatBytes,
getRelativeTime
};

229
cli/src/cli/utils/input.js Normal file
View File

@@ -0,0 +1,229 @@
const readline = require("readline");
const COLORS = {
reset: "\x1b[0m",
bright: "\x1b[1m",
dim: "\x1b[2m",
underline: "\x1b[4m",
reverse: "\x1b[7m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
white: "\x1b[37m",
bgGreen: "\x1b[42m",
bgBlue: "\x1b[44m",
black: "\x1b[30m",
// Terracotta/Earth orange - using RGB escape code
terracotta: "\x1b[38;2;217;119;87m", // #D97757
bgTerracotta: "\x1b[48;2;217;119;87m"
};
/**
* Ask a question and return the user's answer
* @param {string} question - The question to ask
* @returns {Promise<string>} The user's answer
*/
async function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Show a numbered menu and return the selected option number
* @param {string} question - The question to ask
* @param {string[]} options - Array of options to display
* @returns {Promise<number>} The selected option index (0-based)
*/
async function select(question, options) {
console.log(question);
options.forEach((option, index) => {
console.log(` ${index + 1}. ${option}`);
});
while (true) {
const answer = await prompt("\nSelect option (number): ");
const num = parseInt(answer, 10);
if (!isNaN(num) && num >= 1 && num <= options.length) {
return num - 1;
}
console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`);
}
}
/**
* Ask a yes/no question and return boolean
* @param {string} question - The question to ask
* @returns {Promise<boolean>} True for yes, false for no
*/
async function confirm(question) {
while (true) {
const answer = await prompt(`${question} (y/n): `);
const lower = answer.toLowerCase();
if (lower === "y" || lower === "yes") {
return true;
}
if (lower === "n" || lower === "no") {
return false;
}
console.log("Please answer 'y' or 'n'");
}
}
/**
* Pause execution until user presses Enter
* @param {string} [message="Press Enter to continue..."] - Message to display
* @returns {Promise<void>}
*/
async function pause(message = "Press Enter to continue...") {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(message, () => {
rl.close();
resolve();
});
});
}
/**
* Show interactive menu with arrow key navigation
* @param {string} title - Menu title
* @param {Array<{label: string, icon?: string}>} items - Menu items
* @param {number} defaultIndex - Default selected index
* @param {string} headerContent - Optional content to show above menu
* @param {Array<string>} breadcrumb - Optional breadcrumb path
* @returns {Promise<number>} Selected index, or -1 if ESC pressed
*/
async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
return new Promise((resolve) => {
let selectedIndex = defaultIndex;
let isActive = true;
// Remove any existing keypress listeners first
process.stdin.removeAllListeners("keypress");
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) {
try {
process.stdin.setRawMode(true);
} catch (err) {
// TTY disconnected or EIO error - exit gracefully
resolve(-1);
return;
}
}
const renderMenu = () => {
if (!isActive) return;
// Clear previous menu
process.stdout.write("\x1b[2J\x1b[H");
// Show title with terracotta color
const width = Math.min(process.stdout.columns || 40, 40);
console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`);
// Show subtitle inside the frame
if (subtitle) {
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
}
console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
// Show breadcrumb if provided
if (breadcrumb.length > 0) {
console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
}
console.log();
// Show header content if provided
if (headerContent) {
console.log(headerContent);
console.log();
}
// Show menu items with proper alignment
items.forEach((item, index) => {
const isSelected = index === selectedIndex;
// Fallback to ASCII on Windows (cmd/powershell can't render unicode stars)
const isWin = process.platform === "win32";
const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
if (isSelected) {
// Selected: reverse + bright for high visibility on any terminal
console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
} else {
// Not selected: plain text with empty star
console.log(` ${icon} ${item.label}`);
}
});
};
const cleanup = () => {
if (!isActive) return;
isActive = false;
if (process.stdin.isTTY) {
try {
process.stdin.setRawMode(false);
} catch (err) {
// Ignore cleanup errors
}
}
process.stdin.removeListener("keypress", onKeypress);
process.stdin.pause();
};
const onKeypress = (str, key) => {
if (!isActive || !key) return;
if (key.name === "up") {
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
renderMenu();
} else if (key.name === "down") {
selectedIndex = (selectedIndex + 1) % items.length;
renderMenu();
} else if (key.name === "return") {
cleanup();
resolve(selectedIndex);
} else if (key.name === "escape") {
cleanup();
resolve(-1);
} else if (key.ctrl && key.name === "c") {
cleanup();
process.exit(0);
}
};
process.stdin.on("keypress", onKeypress);
process.stdin.resume();
renderMenu();
});
}
module.exports = {
prompt,
select,
confirm,
pause,
selectMenu
};

View File

@@ -0,0 +1,156 @@
const { selectMenu } = require("./input");
/**
* Show a menu with back button at top and handle selection
* @param {Object} config - Menu configuration
* @param {string} config.title - Menu title
* @param {string} config.headerContent - Optional header content
* @param {Array<{label: string, action: Function}>} config.items - Menu items with actions
* @param {string} config.backLabel - Back button label (default: "← Back")
* @param {number} config.defaultIndex - Default selected index (default: 0)
* @param {Function} config.refresh - Optional refresh function to call after each action
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
async function showMenuWithBack(config) {
const {
title,
headerContent = "",
items,
backLabel = "← Back",
defaultIndex = 0,
refresh = null,
breadcrumb = []
} = config;
while (true) {
// Call refresh if provided
let refreshedData = null;
if (refresh) {
refreshedData = await refresh();
if (refreshedData === null) {
// Refresh failed, exit menu
return;
}
}
// Build menu items with back at top
const menuItems = [
{ label: backLabel, icon: "☆" },
...items.map(item => ({
label: typeof item.label === "function" ? item.label(refreshedData) : item.label,
icon: "☆"
}))
];
// Resolve headerContent if it's a function
const resolvedHeader = typeof headerContent === "function"
? await headerContent(refreshedData)
: headerContent;
const selected = await selectMenu(
title,
menuItems,
defaultIndex,
"",
resolvedHeader,
breadcrumb
);
// Back or ESC
if (selected === -1 || selected === 0) {
return;
}
// Execute action for selected item
const actionIndex = selected - 1;
const item = items[actionIndex];
if (item && item.action) {
const shouldContinue = await item.action(refreshedData);
// If action returns false, exit menu
if (shouldContinue === false) {
return;
}
}
}
}
/**
* Show a list menu where items are fetched dynamically
* @param {Object} config - Menu configuration
* @param {string} config.title - Menu title
* @param {string} config.headerContent - Optional header content
* @param {Function} config.fetchItems - Async function to fetch items array
* @param {Function} config.formatItem - Function to format each item to {label, data}
* @param {Function} config.onSelect - Action when item is selected
* @param {Object} config.createAction - Optional create action {label, action}
* @param {string} config.backLabel - Back button label
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
async function showListMenu(config) {
const {
title,
headerContent = "",
fetchItems,
formatItem,
onSelect,
createAction = null,
backLabel = "← Back",
breadcrumb = []
} = config;
while (true) {
// Fetch items
const result = await fetchItems();
if (!result) {
return;
}
const items = result.items || [];
const metadata = result.metadata || {};
// Build menu items
const menuItems = [{ label: backLabel, icon: "☆" }];
if (createAction) {
menuItems.push({ label: createAction.label, icon: "☆" });
}
items.forEach(item => {
const formatted = formatItem(item);
menuItems.push({ label: formatted, icon: "☆" });
});
const header = typeof headerContent === "function"
? await headerContent(metadata)
: headerContent;
const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb);
// Back or ESC
if (selected === -1 || selected === 0) {
return;
}
// Create action
if (createAction && selected === 1) {
await createAction.action();
continue;
}
// Select item
const offset = createAction ? 2 : 1;
const itemIndex = selected - offset;
if (itemIndex >= 0 && itemIndex < items.length) {
await onSelect(items[itemIndex]);
}
}
}
module.exports = {
showMenuWithBack,
showListMenu
};

View File

@@ -0,0 +1,136 @@
const api = require("../api/client");
const { prompt } = require("./input");
const { clearScreen } = require("./display");
// Provider alias order: OAuth first, then API Key (matches ModelSelectModal)
const PROVIDER_ALIAS_ORDER = [
"cc", "ag", "cx", "if", "qw", "gc", "gh", "kr",
"openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
];
// Alias to display name mapping
const PROVIDER_ALIAS_NAMES = {
cc: "Claude Code",
ag: "Antigravity",
cx: "OpenAI Codex",
if: "iFlow AI",
qw: "Qwen Code",
gc: "Gemini CLI",
gh: "GitHub Copilot",
kr: "Kiro AI",
openrouter: "OpenRouter",
glm: "GLM Coding",
kimi: "Kimi Coding",
minimax: "Minimax Coding",
openai: "OpenAI",
anthropic: "Anthropic",
gemini: "Gemini"
};
/**
* Get all available models grouped by provider + combos
* @returns {Promise<{combos: Array, groups: Object}>}
*/
async function getAvailableModelsGrouped() {
const result = await api.getAvailableModels();
if (!result.success) return { combos: [], groups: {} };
const models = result.data?.data || [];
const combos = [];
const groups = {};
models.forEach(m => {
if (m.owned_by === "combo") {
combos.push(m.id);
} else {
const provider = m.owned_by;
if (!groups[provider]) {
groups[provider] = [];
}
groups[provider].push(m.id);
}
});
return { combos, groups };
}
/**
* Display model list and prompt for selection
* @param {string} title - Title to display
* @param {string} currentValue - Current selected value (optional)
* @param {Object} options - { excludeCombos?: boolean }
* @returns {Promise<string|null>} Selected model ID or null if cancelled
*/
async function selectModelFromList(title, currentValue = "", options = {}) {
const { excludeCombos = false } = options;
const { combos: rawCombos, groups } = await getAvailableModelsGrouped();
const combos = excludeCombos ? [] : rawCombos;
const totalModels = combos.length + Object.values(groups).flat().length;
if (totalModels === 0) {
return null;
}
// Build flat list for selection
const allModels = [];
// Display
clearScreen();
console.log(`\n🎯 ${title}`);
console.log("=".repeat(50));
if (currentValue) {
console.log(`Current: ${currentValue}\n`);
} else {
console.log();
}
let idx = 1;
// Combos first (skipped when excludeCombos is true)
if (combos.length > 0) {
console.log("[Combos]");
combos.forEach(combo => {
console.log(` ${idx}. ${combo}`);
allModels.push(combo);
idx++;
});
console.log();
}
// Provider groups in order (by alias)
const sortedProviders = Object.keys(groups).sort((a, b) => {
const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
sortedProviders.forEach(provider => {
const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
console.log(`[${providerName}]`);
groups[provider].forEach(model => {
console.log(` ${idx}. ${model}`);
allModels.push(model);
idx++;
});
console.log();
});
console.log(" 0. Cancel\n");
// Prompt for number input
const input = await prompt("Enter number: ");
const num = parseInt(input, 10);
if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) {
return null;
}
return allModels[num - 1];
}
module.exports = {
selectModelFromList,
getAvailableModelsGrouped,
PROVIDER_ALIAS_ORDER,
PROVIDER_ALIAS_NAMES
};