diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js index 257fcd22..1872b480 100644 --- a/cli/src/cli/api/client.js +++ b/cli/src/cli/api/client.js @@ -357,38 +357,6 @@ 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} { 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} { 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} { success, data } - */ -async function resetCliToolSettings(tool) { - return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`); -} - // ============================================================================ // SETTINGS API // ============================================================================ @@ -528,11 +496,6 @@ module.exports = { updateCombo, deleteCombo, - // CLI Tools - getCliToolSettings, - applyCliToolSettings, - resetCliToolSettings, - // Settings getSettings, updateSettings, diff --git a/cli/src/cli/menus/cliTools.js b/cli/src/cli/menus/cliTools.js deleted file mode 100644 index 3a84a074..00000000 --- a/cli/src/cli/menus/cliTools.js +++ /dev/null @@ -1,618 +0,0 @@ -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} - */ -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} - */ -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} - */ -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} 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} - */ -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} 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} - */ -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} 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} - */ -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} 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} 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 }; diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js index fb28330e..71e34006 100644 --- a/cli/src/cli/terminalUI.js +++ b/cli/src/cli/terminalUI.js @@ -4,7 +4,6 @@ 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", @@ -99,13 +98,6 @@ async function startTerminalUI(port) { return true; } }, - { - label: "CLI Tools", - action: async () => { - await showCliToolsMenu(port, [...basePath, "CLI Tools"]); - return true; - } - }, { label: "Settings", action: async () => { diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index e8a52451..82e3cf0c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -1,45 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; -import { CardSkeleton } from "@/shared/components"; import { CLI_TOOLS, MITM_TOOLS } from "@/shared/constants/cliTools"; import { MitmLinkCard } from "./components"; import ToolSummaryCard from "./components/ToolSummaryCard"; -const ALL_STATUSES_URL = "/api/cli-tools/all-statuses"; - export default function CLIToolsPageClient({ machineId }) { - const [loading, setLoading] = useState(true); - const [toolStatuses, setToolStatuses] = useState({}); - - useEffect(() => { - let mounted = true; - (async () => { - try { - const res = await fetch(ALL_STATUSES_URL); - if (res.ok && mounted) setToolStatuses(await res.json()); - } catch (error) { - console.log("Error fetching tool statuses:", error); - } finally { - if (mounted) setLoading(false); - } - })(); - return () => { mounted = false; }; - }, []); - - if (loading) { - return ( -
- - - - - - -
- ); - } - const regularTools = Object.entries(CLI_TOOLS); const mitmTools = Object.entries(MITM_TOOLS); @@ -47,7 +12,7 @@ export default function CLIToolsPageClient({ machineId }) {
{regularTools.map(([toolId, tool]) => ( - + ))}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index fa8d7111..a3bbadad 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -1,16 +1,10 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import { CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; -import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { - ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, - HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, - CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, - JcodeToolCard, -} from "../components"; +import { ConfigGeneratorCard, DefaultToolCard } from "../components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -18,7 +12,6 @@ export default function ToolDetailClient({ toolId, machineId }) { const tool = CLI_TOOLS[toolId]; const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); - const [modelMappings, setModelMappings] = useState({}); const [cloudEnabled, setCloudEnabled] = useState(false); const [tunnelEnabled, setTunnelEnabled] = useState(false); const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); @@ -67,31 +60,6 @@ export default function ToolDetailClient({ toolId, machineId }) { const getActiveProviders = () => connections.filter(c => c.isActive !== false); - const getAllAvailableModels = () => { - const activeProviders = getActiveProviders(); - const models = []; - const seenModels = new Set(); - activeProviders.forEach(conn => { - const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; - const providerModels = getModelsByProviderId(conn.provider); - providerModels.forEach(m => { - const modelValue = `${alias}/${m.id}`; - if (!seenModels.has(modelValue)) { - seenModels.add(modelValue); - models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id }); - } - }); - }); - return models; - }; - - const handleModelMappingChange = useCallback((tId, alias, target) => { - setModelMappings(prev => { - if (prev[tId]?.[alias] === target) return prev; - return { ...prev, [tId]: { ...prev[tId], [alias]: target } }; - }); - }, []); - const getBaseUrl = () => { if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; if (cloudEnabled && CLOUD_URL) return CLOUD_URL; @@ -100,48 +68,21 @@ export default function ToolDetailClient({ toolId, machineId }) { }; const renderToolCard = () => { - const availableModels = getAllAvailableModels(); - const hasActiveProviders = availableModels.length > 0; const commonProps = { tool, - isExpanded: true, - onToggle: () => {}, + toolId, baseUrl: getBaseUrl(), apiKeys, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, + activeProviders: getActiveProviders(), + cloudEnabled, }; - switch (toolId) { - case "claude": - return handleModelMappingChange(toolId, a, t)} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />; - case "codex": - return ; - case "opencode": - return ; - case "cowork": - return ; - case "droid": - return ; - case "openclaw": - return ; - case "hermes": - return ; - case "copilot": - return ; - case "cline": - return ; - case "kilo": - return ; - case "deepseek-tui": - return ; - case "jcode": - return ; - default: - return ; - } + if (tool.configType === "guide") return ; + return ; }; // Guard removed/unknown tools (e.g. disabled Cowork) to avoid crash on direct URL. diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js deleted file mode 100644 index 589d847a..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ /dev/null @@ -1,390 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; - -export default function ClaudeToolCard({ - tool, - isExpanded, - onToggle, - activeProviders, - modelMappings, - onModelMappingChange, - baseUrl, - hasActiveProviders, - apiKeys, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, -}) { - const [claudeStatus, setClaudeStatus] = useState(initialStatus || null); - const [checkingClaude, setCheckingClaude] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [showInstallGuide, setShowInstallGuide] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [currentEditingAlias, setCurrentEditingAlias] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const [ccFilterNaming, setCcFilterNaming] = useState(false); - const hasInitializedModels = useRef(false); - - const getConfigStatus = () => { - if (!claudeStatus?.installed) return null; - const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; - if (!currentUrl) return "not_configured"; - if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured"; - return "other"; - }; - - const configStatus = getConfigStatus(); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setClaudeStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !claudeStatus) { - checkClaudeStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - useEffect(() => { - fetch("/api/settings").then(r => r.json()).then(data => { - setCcFilterNaming(!!data.ccFilterNaming); - }).catch(() => {}); - }, []); - - const handleCcFilterNamingToggle = async (e) => { - const value = e.target.checked; - setCcFilterNaming(value); - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ccFilterNaming: value }), - }).catch(() => {}); - }; - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - useEffect(() => { - if (claudeStatus?.installed && !hasInitializedModels.current) { - hasInitializedModels.current = true; - const env = claudeStatus.settings?.env || {}; - - tool.defaultModels.forEach((model) => { - if (model.envKey) { - const value = env[model.envKey] || model.defaultValue || ""; - // Only sync initial values from file once - if (value) { - onModelMappingChange(model.alias, value); - } - } - }); - // Only set selectedApiKey if it exists in apiKeys list - const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN; - if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) { - setSelectedApiKey(tokenFromFile); - } - } - }, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]); - - const checkClaudeStatus = async () => { - setCheckingClaude(true); - try { - const res = await fetch("/api/cli-tools/claude-settings"); - const data = await res.json(); - setClaudeStatus(data); - } catch (error) { - setClaudeStatus({ installed: false, error: error.message }); - } finally { - setCheckingClaude(false); - } - }; - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || baseUrl; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => { - const url = customBaseUrl || baseUrl; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const handleApplySettings = async () => { - setApplying(true); - setMessage(null); - try { - const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() }; - - // Get key from dropdown, fallback to first key or sk_9router for localhost - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - if (keyToUse) { - env.ANTHROPIC_AUTH_TOKEN = keyToUse; - } - - tool.defaultModels.forEach((model) => { - const targetModel = modelMappings[model.alias]; - if (targetModel && model.envKey) env[model.envKey] = targetModel; - }); - const res = await fetch("/api/cli-tools/claude-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } })); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleResetSettings = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "")); - setSelectedApiKey(""); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const openModelSelector = (alias) => { - setCurrentEditingAlias(alias); - setModalOpen(true); - }; - - const handleModelSelect = (model) => { - if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value); - }; - - // Generate settings.json content for manual copy - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse }; - tool.defaultModels.forEach((model) => { - const targetModel = modelMappings[model.alias]; - if (targetModel && model.envKey) env[model.envKey] = targetModel; - }); - - return [ - { - filename: "~/.claude/settings.json", - content: JSON.stringify({ hasCompletedOnboarding: true, env }, null, 2), - }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checkingClaude && ( -
- progress_activity - Checking Claude CLI... -
- )} - - {!checkingClaude && claudeStatus && !claudeStatus.installed && ( -
-
-
- warning -
-

Claude CLI not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- - -
-
- {showInstallGuide && ( -
-

Installation Guide

-
-
-

macOS / Linux / Windows:

- npm install -g @anthropic-ai/claude-code -
-

After installation, run claude to verify.

-
-
- )} -
- )} - - {!checkingClaude && claudeStatus?.installed && ( - <> -
- {/* Endpoint (selector) */} -
- Select Endpoint - arrow_forward - -
- - {/* Current configured */} - {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && ( -
- Current - arrow_forward - - {claudeStatus.settings.env.ANTHROPIC_BASE_URL} - -
- )} - - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Model Mappings */} - {tool.defaultModels.map((model) => ( -
- {model.name} - arrow_forward -
- onModelMappingChange(model.alias, e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {modelMappings[model.alias] && } -
- -
- ))} - - {/* CC Filter Naming */} -
- Filter naming - arrow_forward - -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> - - setShowManualConfigModal(false)} - title="Claude CLI - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js deleted file mode 100644 index 41fd0b11..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js +++ /dev/null @@ -1,301 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { - const [status, setStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [showInstallGuide, setShowInstallGuide] = useState(false); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key); - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !status) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - useEffect(() => { - if (status?.settings?.openAiModelId) setSelectedModel(status.settings.openAiModelId); - }, [status]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - const getConfigStatus = () => { - if (!status?.installed) return null; - if (!status.has9Router) return "not_configured"; - const url = status.settings?.openAiBaseUrl || ""; - return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || `${baseUrl}/v1`; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch("/api/cli-tools/cline-settings"); - const data = await res.json(); - setStatus(data); - } catch (error) { - setStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; - - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - - const res = await fetch("/api/cli-tools/cline-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - const effectiveUrl = getEffectiveBaseUrl(); - const baseWithoutV1 = effectiveUrl.endsWith("/v1") ? effectiveUrl.slice(0, -3) : effectiveUrl; - - return [ - { - filename: "~/.cline/data/globalState.json", - content: JSON.stringify({ - actModeApiProvider: "openai", - planModeApiProvider: "openai", - openAiBaseUrl: baseWithoutV1, - openAiModelId: selectedModel || "provider/model-id", - planModeOpenAiModelId: selectedModel || "provider/model-id", - }, null, 2), - }, - { - filename: "~/.cline/data/secrets.json", - content: JSON.stringify({ openAiApiKey: keyToUse }, null, 2), - }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking Cline... -
- )} - - {!checking && status && !status.installed && ( -
-
-
- warning -
-

Cline not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- - -
-
- {showInstallGuide && ( -
-

Installation Guide

-
-

Install Cline VS Code extension or CLI from docs.cline.bot.

-
-
- )} -
- )} - - {!checking && status?.installed && ( - <> -
-
- Select Endpoint - arrow_forward - -
- - {status?.settings?.openAiBaseUrl && ( -
- Current - arrow_forward - - {status.settings.openAiBaseUrl} - -
- )} - -
- API Key - arrow_forward - -
- -
- Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Cline" - /> - - setShowManualConfigModal(false)} - title="Cline - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js deleted file mode 100644 index 12ef389e..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ /dev/null @@ -1,402 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { - const [codexStatus, setCodexStatus] = useState(initialStatus || null); - const [checkingCodex, setCheckingCodex] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [showInstallGuide, setShowInstallGuide] = useState(false); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [subagentModel, setSubagentModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [subagentModalOpen, setSubagentModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setCodexStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !codexStatus) { - checkCodexStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - // Parse model and subagent settings from config content - useEffect(() => { - if (codexStatus?.config) { - const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m); - if (modelMatch) setSelectedModel(modelMatch[1]); - - // Parse subagent settings - const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m); - if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]); - } - }, [codexStatus]); - - const getConfigStatus = () => { - if (!codexStatus?.installed) return null; - if (!codexStatus.config) return "not_configured"; - const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); - const currentUrl = parsed ? parsed[1] : ""; - return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || `${baseUrl}/v1`; - // Ensure URL ends with /v1 - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - - const checkCodexStatus = async () => { - setCheckingCodex(true); - try { - const res = await fetch("/api/cli-tools/codex-settings"); - const data = await res.json(); - setCodexStatus(data); - } catch (error) { - setCodexStatus({ installed: false, error: error.message }); - } finally { - setCheckingCodex(false); - } - }; - - const handleApplySettings = async () => { - setApplying(true); - setMessage(null); - try { - // Use sk_9router for localhost if no key, otherwise use selected key - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - - const res = await fetch("/api/cli-tools/codex-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - model: selectedModel, - subagentModel: subagentModel || selectedModel - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkCodexStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleResetSettings = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - setSubagentModel(""); - checkCodexStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const handleModelSelect = (model) => { - setSelectedModel(model.value); - // Auto-set subagent model if not set - if (!subagentModel) { - setSubagentModel(model.value); - } - setModalOpen(false); - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const effectiveSubagentModel = subagentModel || selectedModel; - - const configContent = `# 9Router Configuration for Codex CLI -model = "${selectedModel}" -model_provider = "9router" - -[model_providers.9router] -name = "9Router" -base_url = "${getEffectiveBaseUrl()}" -wire_api = "responses" - -[agents.subagent] -model = "${effectiveSubagentModel}" -`; - - const authContent = JSON.stringify({ - auth_mode: "apikey", - OPENAI_API_KEY: keyToUse - }, null, 2); - - return [ - { - filename: "~/.codex/config.toml", - content: configContent, - }, - { - filename: "~/.codex/auth.json", - content: authContent, - }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checkingCodex && ( -
- progress_activity - Checking Codex CLI... -
- )} - - {!checkingCodex && codexStatus && !codexStatus.installed && ( -
-
-
- warning -
-

Codex CLI not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- - -
-
- {showInstallGuide && ( -
-

Installation Guide

-
-
-

macOS / Linux / Windows:

- npm install -g @openai/codex -
-

After installation, run codex to verify.

-
-

- Codex uses ~/.codex/auth.json with OPENAI_API_KEY. - Click "Apply" to auto-configure. -

-
-
-
- )} -
- )} - - {!checkingCodex && codexStatus?.installed && ( - <> -
- {/* Endpoint (selector) */} -
- Select Endpoint - arrow_forward - -
- - {/* Current configured */} - {codexStatus?.config && (() => { - const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); - const currentBaseUrl = parsed ? parsed[1] : null; - return currentBaseUrl ? ( -
- Current - arrow_forward - - {currentBaseUrl} - -
- ) : null; - })()} - - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Model */} -
- Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
- - {/* Subagent Model */} -
- Subagent Model - arrow_forward -
- setSubagentModel(e.target.value)} - placeholder={selectedModel || "provider/model-id (defaults to main model)"} - className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" - /> - {subagentModel && ( - - )} -
- -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Codex" - /> - - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for Codex" - /> - - setShowManualConfigModal(false)} - title="Codex CLI - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js new file mode 100644 index 00000000..ecb1e7c4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js @@ -0,0 +1,373 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; +import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components"; +import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; +import BaseUrlSelect from "./BaseUrlSelect"; +import ApiKeySelect from "./ApiKeySelect"; + +const DEFAULT_MODEL = "provider/model-id"; + +const normalizeV1 = (url) => { + const trimmed = (url || "").replace(/\/+$/, ""); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +}; + +const toJson = (value) => JSON.stringify(value, null, 2); + +const withThinkingLevel = (model, thinkingLevel) => ( + model && thinkingLevel ? `${model}(${thinkingLevel})` : model +); + +function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "" }) { + const endpoint = normalizeV1(baseUrl); + const selectedModels = models.length ? models : [DEFAULT_MODEL]; + const model = selectedModels[0]; + + switch (toolId) { + case "claude": + return [{ + filename: "~/.claude/settings.json", + content: toJson({ + hasCompletedOnboarding: true, + env: { + ANTHROPIC_BASE_URL: endpoint, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_DEFAULT_SONNET_MODEL: withThinkingLevel(claudeModels.sonnet || DEFAULT_MODEL, claudeThinking.sonnet), + ANTHROPIC_DEFAULT_OPUS_MODEL: withThinkingLevel(claudeModels.opus || DEFAULT_MODEL, claudeThinking.opus), + ANTHROPIC_DEFAULT_HAIKU_MODEL: withThinkingLevel(claudeModels.haiku || DEFAULT_MODEL, claudeThinking.haiku), + }, + }), + }]; + case "codex": + return [ + { + filename: "~/.codex/config.toml", + content: `model = "${withThinkingLevel(codexModel || DEFAULT_MODEL, codexThinking)}"\nmodel_provider = "9router"\n\n[model_providers.9router]\nname = "9Router"\nbase_url = "${endpoint}"\nwire_api = "responses"\n`, + }, + { filename: "~/.codex/auth.json", content: toJson({ auth_mode: "apikey", OPENAI_API_KEY: apiKey }) }, + ]; + case "openclaw": + return [{ + filename: "~/.openclaw/openclaw.json", + content: toJson({ + agents: { defaults: { model: { primary: `9router/${model}` } } }, + models: { providers: { "9router": { + baseUrl: endpoint, + apiKey, + api: "openai-completions", + models: selectedModels.map((id) => ({ id, name: id.split("/").pop() })), + } } }, + }), + }]; + case "opencode": { + const modelEntries = Object.fromEntries(selectedModels.map((id) => [id, { + name: id, + modalities: { input: ["text", "image"], output: ["text"] }, + }])); + return [{ + filename: "~/.config/opencode/opencode.json", + content: toJson({ + provider: { "9router": { + npm: "@ai-sdk/openai-compatible", + options: { baseURL: endpoint, apiKey }, + models: modelEntries, + } }, + model: `9router/${model}`, + }), + }]; + } + case "copilot": + return [{ + filename: "chatLanguageModels.json", + content: toJson(selectedModels.map((id) => ({ + name: id, + vendor: "9Router", + model: id, + apiBase: endpoint, + apiKey, + }))), + }]; + case "cline": + return [{ + filename: "~/.cline/data/globalState.json", + content: toJson({ + openAiModelId: model, + openAiBaseUrl: endpoint, + openAiApiKey: apiKey, + }), + }]; + case "kilo": + return [{ + filename: "~/.local/share/kilo/auth.json", + content: toJson({ + "9router": { baseUrl: endpoint, apiKey, model }, + }), + }]; + case "deepseek-tui": + return [{ + filename: "~/.deepseek/config.toml", + content: `[model]\nprovider = "openai"\nbase_url = "${endpoint}"\napi_key = "${apiKey}"\ndefault = "${model}"\n`, + }]; + case "hermes": + return [{ + filename: "~/.hermes/config.yaml", + content: `model:\n provider: openai\n base_url: ${endpoint}\n api_key: ${apiKey}\n default: ${model}\n`, + }]; + case "droid": + return [{ + filename: "~/.factory/settings.json", + content: toJson({ customModels: selectedModels.map((id, index) => ({ + id: `custom:9Router-${index}`, + name: `9Router: ${id}`, + baseUrl: endpoint, + apiKey, + model: id, + })) }), + }]; + case "jcode": + return [{ + filename: "~/.config/jcode/config.json", + content: toJson({ provider: "openai", baseUrl: endpoint, apiKey, model }), + }]; + case "cowork": + return [{ + filename: "Claude Desktop third-party inference configuration.json", + content: toJson({ baseUrl: endpoint, apiKey, models: selectedModels }), + }]; + default: + return [{ filename: "config.json", content: toJson({ baseUrl: endpoint, apiKey, model }) }]; + } +} + +export default function ConfigGeneratorCard({ + tool, + toolId, + baseUrl, + apiKeys, + activeProviders, + cloudEnabled, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, +}) { + const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || ""); + const [selectedModels, setSelectedModels] = useState([]); + const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" }); + const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" }); + const [claudeModelSlot, setClaudeModelSlot] = useState(""); + const [codexModel, setCodexModel] = useState(""); + const [codexThinking, setCodexThinking] = useState(""); + const [connectedModels, setConnectedModels] = useState(null); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const [modelModalOpen, setModelModalOpen] = useState(false); + const [configModalOpen, setConfigModalOpen] = useState(false); + + const effectiveBaseUrl = customBaseUrl || baseUrl; + const apiKey = selectedApiKey.trim() || (cloudEnabled ? "" : "sk_9router"); + const configs = useMemo( + () => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking }), + [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking] + ); + + const getThinkingLevelsForModel = (fullModel) => { + const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel); + if (!connectedModel?.provider?.id || !connectedModel.model) return null; + return getThinkingLevels(connectedModel.provider.id, connectedModel.model); + }; + + useEffect(() => { + if (toolId !== "claude" && toolId !== "codex") return; + + let cancelled = false; + const loadConnectedModels = async () => { + try { + const response = await fetch("/api/models/connected", { cache: "no-store" }); + if (!response.ok) throw new Error("Failed to load connected models"); + const data = await response.json(); + if (!cancelled) setConnectedModels(data.models || []); + } catch (error) { + console.log(`Error loading connected models for ${tool.name}:`, error); + if (!cancelled) setConnectedModels([]); + } + }; + + loadConnectedModels(); + return () => { cancelled = true; }; + }, [toolId, tool.name]); + + const addModel = (selected) => { + if (!selected?.value || selectedModels.includes(selected.value)) return; + setSelectedModels((current) => [...current, selected.value]); + }; + + const selectClaudeModel = (selected) => { + if (!selected?.value || !claudeModelSlot) return; + setClaudeModels((current) => ({ ...current, [claudeModelSlot]: selected.value })); + setClaudeThinking((current) => ({ ...current, [claudeModelSlot]: "" })); + setClaudeModelSlot(""); + }; + + const openClaudeModelSelector = (slot) => { + setClaudeModelSlot(slot); + setModelModalOpen(true); + }; + + const selectCodexModel = (selected) => { + if (!selected?.value) return; + setCodexModel(selected.value); + setCodexThinking(""); + }; + + return ( + +
+
+ {tool.name} +
+
+

{tool.name}

+

Generate a configuration file to copy to your own machine.

+
+
+ +
+ + + {toolId === "claude" ? ( +
+ Default Claude models + {[ + { slot: "sonnet", label: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL" }, + { slot: "opus", label: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL" }, + { slot: "haiku", label: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL" }, + ].map(({ slot, label, envKey }) => { + const thinkingLevels = getThinkingLevelsForModel(claudeModels[slot]); + return ( +
+ + {thinkingLevels && ( + + )} +
+ ); + })} +

Choose a model for each Claude Code alias. Empty fields use {DEFAULT_MODEL} as a placeholder.

+
+ ) : toolId === "codex" ? ( +
+ + {getThinkingLevelsForModel(codexModel) && ( + + )} +

The selected reasoning level is appended to the model ID, for example cx/gpt-5.6-sol(high).

+
+ ) : ( +
+
+ Models + +
+ {selectedModels.length ? ( +
+ {selectedModels.map((model) => ( + + ))} +
+ ) :

No model selected. The generated file uses {DEFAULT_MODEL} as a placeholder.

} +
+ )} + +

9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.

+
+ + { setModelModalOpen(false); setClaudeModelSlot(""); }} + onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : addModel} + selectedModel="" + activeProviders={activeProviders} + title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : `Add model for ${tool.name}`} + closeOnSelect={toolId === "claude" || toolId === "codex"} + addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : selectedModels} + availableModels={toolId === "claude" || toolId === "codex" ? connectedModels : null} + /> + setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js deleted file mode 100644 index 5987688d..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ /dev/null @@ -1,323 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { - const [status, setStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [selectedModels, setSelectedModels] = useState([]); - const [modalOpen, setModalOpen] = useState(false); - const selectedModelsRef = useRef([]); - - useEffect(() => { - selectedModelsRef.current = selectedModels; - }, [selectedModels]); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !status) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - // Pre-fill from existing config - useEffect(() => { - if (status?.config && Array.isArray(status.config) && selectedModels.length === 0) { - const entry = status.config.find((e) => e.name === "9Router"); - if (entry?.models?.length > 0) { - setSelectedModels(entry.models.map((m) => m.id)); - } - } - }, [status]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - const saveModels = async (models) => { - try { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - await fetch("/api/cli-tools/copilot-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models }), - }); - } catch (error) { - console.log("Error saving models:", error); - } - }; - - const getConfigStatus = () => { - if (!status) return null; - if (!status.has9Router) return "not_configured"; - const url = status.currentUrl || ""; - return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || baseUrl; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - - const removeModel = (id) => setSelectedModels((prev) => prev.filter((m) => m !== id)); - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch("/api/cli-tools/copilot-settings"); - const data = await res.json(); - setStatus(data); - } catch (error) { - setStatus({ error: error.message }); - } finally { - setChecking(false); - } - }; - - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - - const res = await fetch("/api/cli-tools/copilot-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models: selectedModels }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/copilot-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModels([]); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - const effectiveBaseUrl = getEffectiveBaseUrl(); - const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"]; - - return [{ - filename: "~/Library/Application Support/Code/User/chatLanguageModels.json", - content: JSON.stringify([{ - name: "9Router", - vendor: "azure", - apiKey: keyToUse, - models: modelsToShow.map((id) => ({ - id, name: id, - url: `${effectiveBaseUrl}/chat/completions#models.ai.azure.com`, - toolCalling: true, vision: false, - maxInputTokens: 128000, maxOutputTokens: 16000, - })), - }], null, 2), - }]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking Copilot config... -
- )} - - {!checking && ( - <> -
- info -
-

Writes to chatLanguageModels.json

-

Reload VS Code after applying for changes to take effect.

-
-
- -
- {/* Endpoint */} -
- Select Endpoint - arrow_forward - -
- - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Models */} -
- Models - arrow_forward -
-
- {selectedModels.length === 0 ? ( - No models selected - ) : ( - selectedModels.map((model) => ( - - {model} - - - )) - )} -
-
- -
-
-
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - { - setModalOpen(false); - saveModels(selectedModelsRef.current); - }} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - } - }} - onDeselect={(model) => { - setSelectedModels(selectedModels.filter(m => m !== model.value)); - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - addedModelValues={selectedModels} - closeOnSelect={false} - title="Add Model for GitHub Copilot" - /> - - setShowManualConfigModal(false)} - title="GitHub Copilot - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js deleted file mode 100644 index 07bba260..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +++ /dev/null @@ -1,597 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; - -const ENDPOINT = "/api/cli-tools/cowork-settings"; - -const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, ""); -const ensureV1 = (url) => { - const trimmed = (url || "").replace(/\/+$/, ""); - if (!trimmed) return ""; - return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; -}; - -export default function CoworkToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - apiKeys, - activeProviders, - hasActiveProviders, - cloudEnabled, - cloudUrl, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, - initialStatus, -}) { - const [status, setStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModels, setSelectedModels] = useState([]); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const [plugins, setPlugins] = useState([]); - const [localPlugins, setLocalPlugins] = useState([]); - const [customPlugins, setCustomPlugins] = useState([]); - const [modelAliases, setModelAliases] = useState({}); - const [comboModalOpen, setComboModalOpen] = useState(false); - const [modelSelectOpen, setModelSelectOpen] = useState(false); - const [marketplaceOpen, setMarketplaceOpen] = useState(false); - const [addMcpOpen, setAddMcpOpen] = useState(false); - const [addMcpForm, setAddMcpForm] = useState({ name: "", url: "" }); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !status) checkStatus(); - }, [isExpanded]); - - useEffect(() => { - if (!isExpanded) return; - fetch("/api/models/alias") - .then((r) => r.ok ? r.json() : null) - .then((data) => { - if (data) setModelAliases(data.aliases || {}); - }) - .catch(() => {}); - }, [isExpanded]); - - useEffect(() => { - if (status?.cowork?.models?.length) { - setSelectedModels(status.cowork.models); - } - if (status?.cowork?.baseUrl && !customBaseUrl) { - setCustomBaseUrl(stripV1(status.cowork.baseUrl)); - } - // Initialize plugins: from current config, fallback to defaultPlugins - if (Array.isArray(status?.cowork?.plugins) && status.cowork.plugins.length > 0) { - setPlugins(status.cowork.plugins); - } else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) { - setPlugins(status.defaultPlugins); - } - if (Array.isArray(status?.cowork?.localPlugins)) { - setLocalPlugins(status.cowork.localPlugins); - } - if (Array.isArray(status?.cowork?.customPlugins) && status.cowork.customPlugins.length > 0) { - setCustomPlugins(status.cowork.customPlugins); - } - }, [status]); - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch(ENDPOINT); - const data = await res.json(); - setStatus(data); - } catch (error) { - setStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; - - const getEffectiveBaseUrl = () => ensureV1(customBaseUrl); - - const getConfigStatus = () => { - if (!status?.installed) return null; - const url = status?.cowork?.baseUrl; - if (!url) return "not_configured"; - return status.has9Router ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - const handleApply = async () => { - setMessage(null); - const effectiveUrl = getEffectiveBaseUrl(); - - if (selectedModels.length === 0) { - setMessage({ type: "error", text: "Please select at least one model" }); - return; - } - - setApplying(true); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - const res = await fetch(ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: effectiveUrl, - apiKey: keyToUse, - models: selectedModels, - plugins, - localPlugins, - customPlugins, - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleCreateCombo = async ({ name, models }) => { - try { - const res = await fetch("/api/combos", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, models }), - }); - if (!res.ok) { - const err = await res.json(); - setMessage({ type: "error", text: err.error || "Failed to create combo" }); - return; - } - if (!selectedModels.includes(name)) { - setSelectedModels([...selectedModels, name]); - } - setComboModalOpen(false); - setMessage({ type: "success", text: `Combo "${name}" created and added.` }); - } catch (error) { - setMessage({ type: "error", text: error.message }); - } - }; - - const handleAddModel = (model) => { - const value = model?.value || model?.name || model; - if (!value || selectedModels.includes(value)) return; - setSelectedModels((prev) => [...prev, value]); - }; - - const handleRemoveModel = (model) => { - const value = model?.value || model?.name || model; - setSelectedModels((prev) => prev.filter((item) => item !== value)); - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch(ENDPOINT, { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully" }); - setSelectedModels([]); - setPlugins(status?.defaultPlugins || []); - setLocalPlugins([]); - setCustomPlugins([]); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const addPlugin = (p) => { - if (plugins.some((x) => x.name === p.name)) return; - setPlugins([...plugins, p]); - }; - - const removePlugin = (name) => { - setPlugins(plugins.filter((p) => p.name !== name)); - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"]; - const cfg = { - inferenceProvider: "gateway", - inferenceGatewayBaseUrl: getEffectiveBaseUrl() || "https://your-public-host/v1", - inferenceGatewayApiKey: keyToUse, - inferenceModels: modelsToShow.map((name) => ({ name })), - }; - - return [{ - filename: "~/Library/Application Support/Claude-3p/configLibrary/.json", - content: JSON.stringify(cfg, null, 2), - }]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking Claude Cowork... -
- )} - - {!checking && status && !status.installed && ( -
-
- warning -
-

Claude Desktop (Cowork mode) not detected

-

Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.

-
-
-
- -
-
- )} - - {!checking && status?.installed && ( - <> -
-
- Select Endpoint - arrow_forward - setCustomBaseUrl(stripV1(url))} - tunnelEnabled={tunnelEnabled} - tunnelPublicUrl={tunnelPublicUrl} - tailscaleEnabled={tailscaleEnabled} - tailscaleUrl={tailscaleUrl} - cloudEnabled={cloudEnabled} - cloudUrl={cloudUrl} - /> -
- - {status?.cowork?.baseUrl && ( -
- Current - arrow_forward - - {status.cowork.baseUrl} - -
- )} - -
- API Key - arrow_forward - -
- -
- Models - arrow_forward -
-
- {selectedModels.length === 0 ? ( - No models selected - ) : ( - selectedModels.map((m) => ( - - {m} - - - )) - )} -
- -
-
- -
- MCP - arrow_forward -
- {/* Preset plugins */} - {plugins.filter((p) => p.name !== "exa").map((p) => ( -
- {p.title || p.name} - {p.oauth && OAuth} -
- {Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => ( - {t} - ))} - {Array.isArray(p.toolNames) && p.toolNames.length > 6 && ( - +{p.toolNames.length - 6} - )} -
- -
- ))} - {/* Custom plugins */} - {customPlugins.map((p) => ( -
- {p.name} - custom - {p.url} - -
- ))} - {plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && ( -
No MCPs added
- )} - {/* Actions row */} -
- - - Find MCPs → -
-
-
- -
- Tools - arrow_forward -
- {(() => { - const exaEnabled = plugins.some((p) => p.name === "exa"); - const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa"); - return ( - - ); - })()} - {(() => { - const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp"); - if (!browserDef) return null; - const browserEnabled = localPlugins.includes("browsermcp"); - return ( - - ); - })()} -
-
- - {Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && ( -
- Local Plugins - arrow_forward -
-
- {status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => { - const enabled = localPlugins.includes(p.name); - return ( - - ); - })} -
-

- ⚠️ Local plugins run as subprocess via npx. Requires Node.js installed. -

-
-
- )} -
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setShowManualConfigModal(false)} - title="Claude Cowork - Manual Configuration" - configs={getManualConfigs()} - /> - - setComboModalOpen(false)} - onSave={handleCreateCombo} - activeProviders={activeProviders} - forcePrefix="claude-" - title="Create Cowork Combo" - /> - - setModelSelectOpen(false)} - onSelect={handleAddModel} - onDeselect={handleRemoveModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Cowork Model" - addedModelValues={selectedModels} - closeOnSelect={false} - /> - - setMarketplaceOpen(false)} - onAdd={addPlugin} - addedNames={plugins.map((p) => p.name)} - /> - - {/* Add Custom MCP modal */} - {addMcpOpen && ( -
setAddMcpOpen(false)}> -
e.stopPropagation()}> -
-

Add Custom MCP

- -
- -
-
- - setAddMcpForm((f) => ({ ...f, name: e.target.value.replace(/\s+/g, "-").toLowerCase() }))} - className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary" - /> -
-
- - setAddMcpForm((f) => ({ ...f, url: e.target.value }))} - className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary" - /> -
-
- -
- - -
-
-
- )} -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js deleted file mode 100644 index 426ca742..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js +++ /dev/null @@ -1,338 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -const ENDPOINT = "/api/cli-tools/deepseek-tui-settings"; - -export default function DeepSeekTuiToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - hasActiveProviders, - apiKeys, - activeProviders, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, -}) { - const [deepseekStatus, setDeepseekStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); - - const getConfigStatus = () => { - if (!deepseekStatus?.installed) return null; - const openaiSection = deepseekStatus.settings?.["providers.openai"]; - if (!openaiSection?.base_url) return "not_configured"; - if (matchKnownEndpoint(openaiSection.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; - return "other"; - }; - - const configStatus = getConfigStatus(); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setDeepseekStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !deepseekStatus) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - useEffect(() => { - if (deepseekStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const openaiSection = deepseekStatus.settings?.["providers.openai"]; - if (openaiSection?.model) setSelectedModel(openaiSection.model); - } - }, [deepseekStatus]); - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch(ENDPOINT); - const data = await res.json(); - setDeepseekStatus(data); - } catch (error) { - setDeepseekStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; - - const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); - - const getLocalBaseUrl = () => { - if (typeof window !== "undefined") { - return normalizeLocalhost(window.location.origin); - } - return "http://127.0.0.1:20128"; - }; - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - const res = await fetch(ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - model: selectedModel, - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch(ENDPOINT, { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const handleModelSelect = (model) => { - setSelectedModel(model.value); - setModalOpen(false); - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const tomlContent = `[providers.openai] -base_url = "${getEffectiveBaseUrl()}" -api_key = "${keyToUse}" -model = "${selectedModel || "provider/model-id"}" -`; - - return [ - { filename: "~/.deepseek/config.toml", content: tomlContent }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking DeepSeek TUI... -
- )} - - {!checking && deepseekStatus && !deepseekStatus.installed && ( -
-
-
- warning -
-

DeepSeek TUI not detected locally

-

Install via npm:

- npm install -g deepseek-tui -

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- -
-
-
- )} - - {!checking && deepseekStatus?.installed && ( - <> -
- {tool.notes && tool.notes.length > 0 && ( -
- {tool.notes.map((note, idx) => ( -
- - {note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"} - - {note.text} -
- ))} -
- )} - -
- Select Endpoint - arrow_forward - -
- - {deepseekStatus?.settings?.["providers.openai"]?.base_url && ( -
- Current - arrow_forward - - {deepseekStatus.settings["providers.openai"].base_url} - -
- )} - -
- API Key - arrow_forward - -
- -
- Default Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for DeepSeek TUI" - /> - - setShowManualConfigModal(false)} - title="DeepSeek TUI - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js deleted file mode 100644 index 7e8e2eac..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ /dev/null @@ -1,410 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; - -export default function DroidToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - hasActiveProviders, - apiKeys, - activeProviders, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, -}) { - const [droidStatus, setDroidStatus] = useState(initialStatus || null); - const [checkingDroid, setCheckingDroid] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [modelList, setModelList] = useState([]); - const [modelInput, setModelInput] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [showInstallGuide, setShowInstallGuide] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); - - const getConfigStatus = () => { - if (!droidStatus?.installed) return null; - // Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...) - const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router")); - if (!currentConfig) return "not_configured"; - return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setDroidStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !droidStatus) { - checkDroidStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - // Pre-fill model list from existing config (supports multi-model) - useEffect(() => { - if (droidStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const existingModels = (droidStatus.settings?.customModels || []) - .filter(m => m.id?.startsWith("custom:9Router")) - .sort((a, b) => (a.index || 0) - (b.index || 0)) - .map(m => m.model); - if (existingModels.length > 0) { - setModelList(existingModels); - } else { - // Legacy: single model stored as custom:9Router-0 - const legacy = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0"); - if (legacy?.model) { - setModelList([legacy.model]); - } - } - } - }, [droidStatus]); - - const checkDroidStatus = async () => { - setCheckingDroid(true); - try { - const res = await fetch("/api/cli-tools/droid-settings"); - const data = await res.json(); - setDroidStatus(data); - } catch (error) { - setDroidStatus({ installed: false, error: error.message }); - } finally { - setCheckingDroid(false); - } - }; - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || baseUrl; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => { - const url = customBaseUrl || baseUrl; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const addModel = () => { - const val = modelInput.trim(); - if (!val || modelList.includes(val)) return; - setModelList((prev) => [...prev, val]); - setModelInput(""); - }; - - const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id)); - - const handleModelSelect = (model) => { - if (!model.value || modelList.includes(model.value)) return; - setModelList((prev) => [...prev, model.value]); - setModalOpen(false); - }; - - const handleApplySettings = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - const res = await fetch("/api/cli-tools/droid-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - models: modelList, - activeModel: modelList[0] || "", - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkDroidStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleResetSettings = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setModelList([]); - checkDroidStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const settingsContent = { - customModels: modelList.map((m, i) => ({ - model: m, - id: `custom:9Router-${i}`, - index: i, - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - displayName: m, - maxOutputTokens: 131072, - noImageSupport: false, - provider: "openai", - })), - }; - - const platform = typeof navigator !== "undefined" && navigator.platform; - const isWindows = platform?.toLowerCase().includes("win"); - const settingsPath = isWindows - ? "%USERPROFILE%\\.factory\\settings.json" - : "~/.factory/settings.json"; - - return [ - { - filename: settingsPath, - content: JSON.stringify(settingsContent, null, 2), - }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checkingDroid && ( -
- progress_activity - Checking Factory Droid CLI... -
- )} - - {!checkingDroid && droidStatus && !droidStatus.installed && ( -
-
-
- warning -
-

Factory Droid CLI not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- - -
-
- {showInstallGuide && ( -
-

Installation Guide

-
-
-

macOS / Linux / Windows:

- curl -fsSL https://app.factory.ai/cli | sh -
-

After installation, run droid to verify.

-
-
- )} -
- )} - - {!checkingDroid && droidStatus?.installed && ( - <> -
- {/* Endpoint (selector) */} -
- Select Endpoint - arrow_forward - -
- - {/* Current configured */} - {droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && ( -
- Current - arrow_forward - - {droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl} - -
- )} - - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Models */} -
- - Models {modelList.length > 0 && ({modelList.length})} - - arrow_forward -
- {/* Model list */} - {modelList.length > 0 && ( -
- {modelList.map((id) => ( -
- {id} - -
- ))} -
- )} - {/* Model input row */} -
- setModelInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }} - placeholder="provider/model-id" - className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" - /> - - -
-
-
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Factory Droid" - /> - - setShowManualConfigModal(false)} - title="Factory Droid - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js deleted file mode 100644 index 806be6bb..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ /dev/null @@ -1,317 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -const ENDPOINT = "/api/cli-tools/hermes-settings"; - -export default function HermesToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - hasActiveProviders, - apiKeys, - activeProviders, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, -}) { - const [hermesStatus, setHermesStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); - - const getConfigStatus = () => { - if (!hermesStatus?.installed) return null; - const cfg = hermesStatus.settings?.model; - if (!cfg?.base_url) return "not_configured"; - if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; - return "other"; - }; - - const configStatus = getConfigStatus(); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setHermesStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !hermesStatus) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - useEffect(() => { - if (hermesStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const cfg = hermesStatus.settings?.model; - if (cfg?.default) setSelectedModel(cfg.default); - } - }, [hermesStatus]); - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch(ENDPOINT); - const data = await res.json(); - setHermesStatus(data); - } catch (error) { - setHermesStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; - - const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); - - const getLocalBaseUrl = () => { - if (typeof window !== "undefined") { - return normalizeLocalhost(window.location.origin); - } - return "http://127.0.0.1:20128"; - }; - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - const res = await fetch(ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - model: selectedModel, - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch(ENDPOINT, { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const handleModelSelect = (model) => { - setSelectedModel(model.value); - setModalOpen(false); - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`; - const envContent = `OPENAI_API_KEY=${keyToUse}\n`; - - return [ - { filename: "~/.hermes/config.yaml", content: yamlContent }, - { filename: "~/.hermes/.env", content: envContent }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking Hermes Agent... -
- )} - - {!checking && hermesStatus && !hermesStatus.installed && ( -
-
-
- warning -
-

Hermes Agent not detected locally

-

Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash

-
-
-
- -
-
-
- )} - - {!checking && hermesStatus?.installed && ( - <> -
-
- Select Endpoint - arrow_forward - -
- - {hermesStatus?.settings?.model?.base_url && ( -
- Current - arrow_forward - - {hermesStatus.settings.model.base_url} - -
- )} - -
- API Key - arrow_forward - -
- -
- Default Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Hermes Agent" - /> - - setShowManualConfigModal(false)} - title="Hermes Agent - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js deleted file mode 100644 index 487def9b..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js +++ /dev/null @@ -1,380 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function JcodeToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - hasActiveProviders, - apiKeys, - activeProviders, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, -}) { - const [jcodeStatus, setJcodeStatus] = useState(initialStatus || null); - const [checkingJcode, setCheckingJcode] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); - - const getConfigStatus = () => { - if (!jcodeStatus?.installed) return null; - if (!jcodeStatus?.has9Router) return "not_configured"; - const currentProvider = jcodeStatus.config?.providers?.["9router"]; - if (!currentProvider) return "not_configured"; - return matchKnownEndpoint(currentProvider.base_url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setJcodeStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !jcodeStatus) { - checkJcodeStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - useEffect(() => { - if (jcodeStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const provider = jcodeStatus.config?.providers?.["9router"]; - if (provider) { - if (provider.default_model) { - setSelectedModel(provider.default_model); - } - // Try to match API key from env file - const envApiKey = jcodeStatus.envApiKey; - if (envApiKey && apiKeys?.some(k => k.key === envApiKey)) { - setSelectedApiKey(envApiKey); - } - } - } - }, [jcodeStatus, apiKeys]); - - const checkJcodeStatus = async () => { - setCheckingJcode(true); - try { - const res = await fetch("/api/cli-tools/jcode-settings"); - const data = await res.json(); - setJcodeStatus(data); - } catch (error) { - setJcodeStatus({ installed: false, error: error.message }); - } finally { - setCheckingJcode(false); - } - }; - - const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); - - const getLocalBaseUrl = () => { - if (typeof window !== "undefined") { - return normalizeLocalhost(window.location.origin); - } - return "http://127.0.0.1:20128"; - }; - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const handleApplySettings = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - const res = await fetch("/api/cli-tools/jcode-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - models: selectedModel ? [selectedModel] : [], - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkJcodeStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleResetSettings = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/jcode-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - setSelectedApiKey(""); - checkJcodeStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const handleModelSelect = (model) => { - setSelectedModel(model.value); - setModalOpen(false); - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const configToml = `[providers.9router] -type = "openai-compatible" -base_url = "${getEffectiveBaseUrl()}" -auth = "bearer" -api_key_env = "JCODE_9ROUTER_API_KEY" -env_file = "provider-9router.env" -default_model = "${selectedModel || "cc/claude-opus-4-7"}" -requires_api_key = true - -[[providers.9router.models]] -id = "${selectedModel || "cc/claude-opus-4-7"}"`; - - const envContent = `JCODE_9ROUTER_API_KEY="${keyToUse}"`; - - return [ - { - filename: "~/.jcode/config.toml", - content: configToml, - }, - { - filename: "~/.config/jcode/provider-9router.env", - content: envContent, - }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checkingJcode && ( -
- progress_activity - Checking jcode CLI... -
- )} - - {!checkingJcode && jcodeStatus && !jcodeStatus.installed && ( -
-
-
- warning -
-

jcode CLI not detected locally

-

Install jcode to enable automatic configuration:

- - curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash - -

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- -
-
-
- )} - - {!checkingJcode && jcodeStatus?.installed && ( - <> -
- {/* Info notes */} - {tool.notes && tool.notes.length > 0 && ( -
- {tool.notes.map((note, idx) => ( -
- - {note.type === "info" ? "info" : note.type === "warning" ? "warning" : "help"} - - {note.text} -
- ))} -
- )} - - {/* Endpoint (selector) */} -
- Select Endpoint - arrow_forward - -
- - {/* Current configured */} - {jcodeStatus?.config?.providers?.["9router"]?.base_url && ( -
- Current - arrow_forward - - {jcodeStatus.config.providers["9router"].base_url} - -
- )} - - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Default Model */} -
- Default Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="cc/claude-opus-4-7" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
- - {/* Usage hint */} -
-

Usage:

- jcode --provider-profile 9router - jcode --provider-profile 9router --model {selectedModel || "cc/claude-opus-4-7"} -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for jcode" - /> - - setShowManualConfigModal(false)} - title="jcode - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js deleted file mode 100644 index 2a8a074e..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js +++ /dev/null @@ -1,275 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { - const [status, setStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [showInstallGuide, setShowInstallGuide] = useState(false); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key); - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !status) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - const getConfigStatus = () => { - if (!status?.installed) return null; - return status.has9Router ? "configured" : "not_configured"; - }; - - const configStatus = getConfigStatus(); - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || `${baseUrl}/v1`; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch("/api/cli-tools/kilo-settings"); - const data = await res.json(); - setStatus(data); - } catch (error) { - setStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; - - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - - const res = await fetch("/api/cli-tools/kilo-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - return [{ - filename: "~/.local/share/kilo/auth.json", - content: JSON.stringify({ - "openai-compatible": { - type: "api-key", - apiKey: keyToUse, - baseUrl: getEffectiveBaseUrl(), - model: selectedModel || "provider/model-id", - }, - }, null, 2), - }]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking Kilo Code... -
- )} - - {!checking && status && !status.installed && ( -
-
-
- warning -
-

Kilo Code not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- - -
-
- {showInstallGuide && ( -
-

Installation Guide

-

Install Kilo Code from kilocode.ai or VS Code extension marketplace.

-
- )} -
- )} - - {!checking && status?.installed && ( - <> -
-
- Select Endpoint - arrow_forward - -
- -
- API Key - arrow_forward - -
- -
- Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Kilo Code" - /> - - setShowManualConfigModal(false)} - title="Kilo Code - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js deleted file mode 100644 index 88a73c63..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ /dev/null @@ -1,388 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function OpenClawToolCard({ - tool, - isExpanded, - onToggle, - baseUrl, - hasActiveProviders, - apiKeys, - activeProviders, - cloudEnabled, - initialStatus, - tunnelEnabled, - tunnelPublicUrl, - tailscaleEnabled, - tailscaleUrl, -}) { - const [openclawStatus, setOpenclawStatus] = useState(initialStatus || null); - const [checkingOpenclaw, setCheckingOpenclaw] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [agentModels, setAgentModels] = useState({}); // { [agentId]: modelId } - const [agentModalFor, setAgentModalFor] = useState(null); // agentId opening modal - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); - - const getConfigStatus = () => { - if (!openclawStatus?.installed) return null; - const currentProvider = openclawStatus.settings?.models?.providers?.["9router"]; - if (!currentProvider) return "not_configured"; - return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setOpenclawStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !openclawStatus) { - checkOpenclawStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - useEffect(() => { - if (openclawStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const provider = openclawStatus.settings?.models?.providers?.["9router"]; - if (provider) { - const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary; - if (primaryModel) setSelectedModel(primaryModel.replace("9router/", "")); - if (provider.apiKey && apiKeys?.some(k => k.key === provider.apiKey)) { - setSelectedApiKey(provider.apiKey); - } - } - // Init per-agent models from enriched agents list - const agentList = openclawStatus.agents || []; - const initAgentModels = {}; - agentList.forEach((agent) => { - if (agent.currentModel) initAgentModels[agent.id] = agent.currentModel; - }); - setAgentModels(initAgentModels); - } - }, [openclawStatus, apiKeys]); - - const checkOpenclawStatus = async () => { - setCheckingOpenclaw(true); - try { - const res = await fetch("/api/cli-tools/openclaw-settings"); - const data = await res.json(); - setOpenclawStatus(data); - } catch (error) { - setOpenclawStatus({ installed: false, error: error.message }); - } finally { - setCheckingOpenclaw(false); - } - }; - - const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); - - const getLocalBaseUrl = () => { - if (typeof window !== "undefined") { - return normalizeLocalhost(window.location.origin); - } - return "http://127.0.0.1:20128"; - }; - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const handleApplySettings = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = selectedApiKey?.trim() - || (apiKeys?.length > 0 ? apiKeys[0].key : null) - || (!cloudEnabled ? "sk_9router" : null); - - const res = await fetch("/api/cli-tools/openclaw-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - model: selectedModel, - agentModels, - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkOpenclawStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleResetSettings = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - setSelectedApiKey(""); - checkOpenclawStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const handleModelSelect = (model) => { - if (agentModalFor) { - setAgentModels(prev => ({ ...prev, [agentModalFor]: model.value })); - setAgentModalFor(null); - } else { - setSelectedModel(model.value); - } - setModalOpen(false); - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const settingsContent = { - agents: { - defaults: { - model: { - primary: `9router/${selectedModel || "provider/model-id"}`, - }, - }, - }, - models: { - providers: { - "9router": { - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - api: "openai-completions", - models: [ - { - id: selectedModel || "provider/model-id", - name: (selectedModel || "provider/model-id").split("/").pop(), - }, - ], - }, - }, - }, - }; - - return [ - { - filename: "~/.openclaw/openclaw.json", - content: JSON.stringify(settingsContent, null, 2), - }, - ]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checkingOpenclaw && ( -
- progress_activity - Checking Open Claw CLI... -
- )} - - {!checkingOpenclaw && openclawStatus && !openclawStatus.installed && ( -
-
-
- warning -
-

Open Claw CLI not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- -
-
-
- )} - - {!checkingOpenclaw && openclawStatus?.installed && ( - <> -
- {/* Endpoint (selector) */} -
- Select Endpoint - arrow_forward - -
- - {/* Current configured */} - {openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && ( -
- Current - arrow_forward - - {openclawStatus.settings.models.providers["9router"].baseUrl} - -
- )} - - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Default Model */} -
- Default Model - arrow_forward -
- setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> - {selectedModel && } -
- -
- - {/* Per-agent model overrides */} - {(openclawStatus.agents || []).filter(a => a.agentDir).map((agent) => ( -
- Agent {agent.name || agent.id} - arrow_forward -
- setAgentModels(prev => ({ ...prev, [agent.id]: e.target.value }))} - placeholder={`default (${selectedModel || "provider/model-id"})`} - className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" - /> - {agentModels[agent.id] && } -
- -
- ))} -
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Open Claw" - /> - - setShowManualConfigModal(false)} - title="Open Claw - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js deleted file mode 100644 index 9a4a4b84..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ /dev/null @@ -1,500 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; -import Image from "next/image"; -import BaseUrlSelect from "./BaseUrlSelect"; -import ApiKeySelect from "./ApiKeySelect"; -import { matchKnownEndpoint } from "./cliEndpointMatch"; - -export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { - const [status, setStatus] = useState(initialStatus || null); - const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [restoring, setRestoring] = useState(false); - const [message, setMessage] = useState(null); - const [showInstallGuide, setShowInstallGuide] = useState(false); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [subagentModel, setSubagentModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [subagentModalOpen, setSubagentModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); - const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [customBaseUrl, setCustomBaseUrl] = useState(""); - const [selectedModels, setSelectedModels] = useState([]); - const [activeModel, setActiveModel] = useState(""); - const selectedModelsRef = useRef([]); - - useEffect(() => { - selectedModelsRef.current = selectedModels; - }, [selectedModels]); - - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !status) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - // Sync models from existing config - useEffect(() => { - if (status?.opencode?.models) { - setSelectedModels(status.opencode.models); - } - if (status?.opencode?.activeModel) { - setActiveModel(status.opencode.activeModel); - } - - // Parse subagent settings from agent.explorer if exists - if (status?.config?.agent?.explorer?.model?.startsWith("9router/")) { - setSubagentModel(status.config.agent.explorer.model.replace("9router/", "")); - } - }, [status]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - - const saveModels = async (models) => { - try { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - const validActiveModel = models.includes(activeModel) ? activeModel : (models[0] || ""); - await fetch("/api/cli-tools/opencode-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - models, - activeModel: validActiveModel, - subagentModel, - }), - }); - } catch (error) { - console.log("Error saving models:", error); - } - }; - - const getConfigStatus = () => { - if (!status?.installed) return null; - if (!status.config) return "not_configured"; - if (!status.has9Router) return "not_configured"; - const url = status.config?.provider?.["9router"]?.options?.baseURL || ""; - return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; - }; - - const configStatus = getConfigStatus(); - - const getEffectiveBaseUrl = () => { - const url = customBaseUrl || baseUrl; - return url.endsWith("/v1") ? url : `${url}/v1`; - }; - - const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; - - const checkStatus = async () => { - setChecking(true); - try { - const res = await fetch("/api/cli-tools/opencode-settings"); - const data = await res.json(); - setStatus(data); - } catch (error) { - setStatus({ installed: false, error: error.message }); - } finally { - setChecking(false); - } - }; - - const handleApply = async () => { - setApplying(true); - setMessage(null); - try { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : selectedApiKey); - - const res = await fetch("/api/cli-tools/opencode-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: getEffectiveBaseUrl(), - apiKey: keyToUse, - models: selectedModels, - activeModel: activeModel === "" ? "" : (activeModel || selectedModels[0]), - subagentModel: subagentModel - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to apply settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setApplying(false); - } - }; - - const handleReset = async () => { - setRestoring(true); - setMessage(null); - try { - const res = await fetch("/api/cli-tools/opencode-settings", { method: "DELETE" }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: "Settings reset successfully!" }); - setSelectedModel(""); - setSubagentModel(""); - setSelectedModels([]); - setActiveModel(""); - checkStatus(); - } else { - setMessage({ type: "error", text: data.error || "Failed to reset settings" }); - } - } catch (error) { - setMessage({ type: "error", text: error.message }); - } finally { - setRestoring(false); - } - }; - - const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"]; - const activeModelToShow = activeModel || selectedModels[0] || modelsToShow[0]; - const effectiveSubagentModel = subagentModel || activeModelToShow; - - const modelsObj = {}; - modelsToShow.forEach(m => { - modelsObj[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } }; - }); - - return [{ - filename: "~/.config/opencode/opencode.json", - content: JSON.stringify({ - provider: { - "9router": { - npm: "@ai-sdk/openai-compatible", - options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse }, - models: modelsObj, - }, - }, - model: `9router/${activeModelToShow}`, - agent: { - explorer: { - description: "Fast explorer subagent for codebase exploration", - mode: "subagent", - model: `9router/${effectiveSubagentModel}` - } - } - }, null, 2), - }]; - }; - - return ( - -
-
-
- {tool.name} { e.target.style.display = "none"; }} /> -
-
-
-

{tool.name}

- {configStatus === "configured" && Connected} - {configStatus === "not_configured" && Not configured} - {configStatus === "other" && Other} -
-

{tool.description}

-
-
- expand_more -
- - {isExpanded && ( -
- {checking && ( -
- progress_activity - Checking OpenCode CLI... -
- )} - - {!checking && status && !status.installed && ( -
-
-
- warning -
-

OpenCode CLI not detected locally

-

Manual configuration is still available if 9router is deployed on a remote server.

-
-
-
- - -
-
- {showInstallGuide && ( -
-

Installation Guide

-
-
-

macOS / Linux:

- npm install -g opencode-ai -
-

After installation, run opencode to verify.

-
-
- )} -
- )} - - {!checking && status?.installed && ( - <> -
- {/* Current base URL */} - {/* Endpoint (selector) */} -
- Select Endpoint - arrow_forward - -
- - {/* Current configured */} - {status?.config?.provider?.["9router"]?.options?.baseURL && ( -
- Current - arrow_forward - - {status.config.provider["9router"].options.baseURL} - -
- )} - - {/* API Key */} -
- API Key - arrow_forward - -
- - {/* Models */} -
- Models - arrow_forward -
-
- {selectedModels.length === 0 ? ( - No models selected - ) : ( - selectedModels.map((model) => ( - { - if (model === activeModel) { - try { - const res = await fetch("/api/cli-tools/opencode-settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clearActiveModel: true }), - }); - if (res.ok) { - setActiveModel(""); - checkStatus(); - } - } catch (error) { - console.log("Error clearing active model:", error); - } - } else { - setActiveModel(model); - } - }} - className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${ - model === activeModel - ? "bg-primary/10 text-primary border border-primary" - : "bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border" - }`} - title={model === activeModel ? "Click to clear active model" : "Click to set as active"} - > - {model === activeModel && star} - {model} - - - )) - )} -
-
- - - {selectedModels.length > 0 && activeModel ? ( - <>Active: {activeModel} - ) : selectedModels.length > 0 ? ( - Click a model to set/clear active - ) : ( - "Select models to add" - )} - -
-
-
- - {/* Subagent Model */} -
- Subagent Model - arrow_forward - setSubagentModel(e.target.value)} - placeholder={selectedModel || "provider/model-id (defaults to main model)"} - className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" - /> - - {subagentModel && ( - - )} -
-
- - {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} - -
- - - -
- - )} -
- )} - - { - setModalOpen(false); - saveModels(selectedModelsRef.current); - }} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - if (!activeModel) setActiveModel(model.value); - } - }} - onDeselect={(model) => { - const remaining = selectedModels.filter(m => m !== model.value); - setSelectedModels(remaining); - if (activeModel === model.value) { - setActiveModel(remaining[0] || ""); - } - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - addedModelValues={selectedModels} - closeOnSelect={false} - title="Add Model for OpenCode" - /> - - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for OpenCode" - /> - - setShowManualConfigModal(false)} - title="OpenCode - Manual Configuration" - configs={getManualConfigs()} - /> -
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js index ea3d51c8..98740209 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js @@ -4,16 +4,7 @@ import Link from "next/link"; import Image from "next/image"; import { Card } from "@/shared/components"; -// Derive simple connected/configured/not-installed status from API payload -function getStatus(status) { - if (!status) return { label: "Unknown", cls: "bg-gray-500/10 text-gray-500" }; - if (!status.installed) return { label: "Not installed", cls: "bg-gray-500/10 text-gray-500" }; - if (status.has9Router) return { label: "Connected", cls: "bg-green-500/10 text-green-600 dark:text-green-400" }; - return { label: "Not configured", cls: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" }; -} - -export default function ToolSummaryCard({ toolId, tool, status }) { - const s = getStatus(status); +export default function ToolSummaryCard({ toolId, tool }) { return ( @@ -28,7 +19,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {

{tool.name}

- {s.label} +

Generate a copyable configuration file

chevron_right
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js deleted file mode 100644 index 0d7b7ca1..00000000 --- a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js +++ /dev/null @@ -1,13 +0,0 @@ -// Match a configured CLI base URL against all known endpoints (local/tunnel/tailscale/cloud) -const stripTrailingSlash = (s) => (s || "").replace(/\/+$/, ""); - -export function matchKnownEndpoint(currentUrl, opts = {}) { - if (!currentUrl) return false; - const url = stripTrailingSlash(currentUrl); - const { tunnelPublicUrl, tailscaleUrl, cloudUrl } = opts; - if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(url)) return true; - if (tunnelPublicUrl && url.startsWith(stripTrailingSlash(tunnelPublicUrl))) return true; - if (tailscaleUrl && url.startsWith(stripTrailingSlash(tailscaleUrl))) return true; - if (cloudUrl && url.startsWith(stripTrailingSlash(cloudUrl))) return true; - return false; -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index aeca8700..205980ed 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -1,19 +1,8 @@ -export { default as ClaudeToolCard } from "./ClaudeToolCard"; -export { default as CodexToolCard } from "./CodexToolCard"; -export { default as DroidToolCard } from "./DroidToolCard"; -export { default as OpenClawToolCard } from "./OpenClawToolCard"; -export { default as HermesToolCard } from "./HermesToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; export { default as AntigravityToolCard } from "./AntigravityToolCard"; -export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; -export { default as CoworkToolCard } from "./CoworkToolCard"; -export { default as CopilotToolCard } from "./CopilotToolCard"; -export { default as ClineToolCard } from "./ClineToolCard"; -export { default as KiloToolCard } from "./KiloToolCard"; -export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; -export { default as JcodeToolCard } from "./JcodeToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; export { default as EndpointPresetControl } from "./EndpointPresetControl"; export { default as BaseUrlSelect } from "./BaseUrlSelect"; +export { default as ConfigGeneratorCard } from "./ConfigGeneratorCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js deleted file mode 100644 index 4d5174f5..00000000 --- a/src/app/api/cli-tools/all-statuses/route.js +++ /dev/null @@ -1,46 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { GET as claudeGet } from "../claude-settings/route"; -import { GET as codexGet } from "../codex-settings/route"; -import { GET as opencodeGet } from "../opencode-settings/route"; -import { GET as droidGet } from "../droid-settings/route"; -import { GET as openclawGet } from "../openclaw-settings/route"; -import { GET as hermesGet } from "../hermes-settings/route"; -import { GET as coworkGet } from "../cowork-settings/route"; -import { GET as copilotGet } from "../copilot-settings/route"; -import { GET as clineGet } from "../cline-settings/route"; -import { GET as kiloGet } from "../kilo-settings/route"; -import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; -import { GET as jcodeGet } from "../jcode-settings/route"; - -const STATUS_GETTERS = { - claude: claudeGet, - codex: codexGet, - opencode: opencodeGet, - droid: droidGet, - openclaw: openclawGet, - hermes: hermesGet, - cowork: coworkGet, - copilot: copilotGet, - cline: clineGet, - kilo: kiloGet, - "deepseek-tui": deepseekTuiGet, - jcode: jcodeGet, -}; - -// Batch endpoint: gather all CLI tool statuses in one round-trip -export async function GET() { - const entries = await Promise.all( - Object.entries(STATUS_GETTERS).map(async ([toolId, getter]) => { - try { - const res = await getter(); - const data = await res.json(); - return [toolId, data]; - } catch { - return [toolId, null]; - } - }) - ); - return NextResponse.json(Object.fromEntries(entries)); -} diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js deleted file mode 100644 index 6a0e8291..00000000 --- a/src/app/api/cli-tools/claude-settings/route.js +++ /dev/null @@ -1,204 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -// Get claude settings path based on OS -const getClaudeSettingsPath = () => { - const homeDir = os.homedir(); - return path.join(homeDir, ".claude", "settings.json"); -}; - - -// Check if claude CLI is installed (via which/where or config file exists) -const checkClaudeInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where claude" : "which claude"; - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getClaudeSettingsPath()); - return true; - } catch { - return false; - } - } -}; - -// Read current settings -const readSettings = async () => { - try { - const settingsPath = getClaudeSettingsPath(); - const content = await fs.readFile(settingsPath, "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -// GET - Check claude CLI and read current settings -export async function GET() { - try { - const isInstalled = await checkClaudeInstalled(); - - if (!isInstalled) { - return NextResponse.json({ - installed: false, - settings: null, - message: "Claude CLI is not installed", - }); - } - - const settings = await readSettings(); - const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL); - - return NextResponse.json({ - installed: true, - settings: redactSecrets(settings), - has9Router: has9Router, - settingsPath: getClaudeSettingsPath(), - }); - } catch (error) { - console.log("Error checking claude settings:", error); - return NextResponse.json( - { error: "Failed to check claude settings" }, - { status: 500 } - ); - } -} - -// POST - Backup old fields and write new settings -export async function POST(request) { - try { - const { env } = await request.json(); - - if (!env || typeof env !== "object") { - return NextResponse.json( - { error: "Invalid env object" }, - { status: 400 } - ); - } - - const settingsPath = getClaudeSettingsPath(); - const claudeDir = path.dirname(settingsPath); - - // Ensure .claude directory exists - await fs.mkdir(claudeDir, { recursive: true }); - - // Read current settings - let currentSettings = {}; - try { - const content = await fs.readFile(settingsPath, "utf-8"); - currentSettings = JSON.parse(content); - } catch (error) { - if (error.code !== "ENOENT") { - throw error; - } - } - - // Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix - if (env.ANTHROPIC_BASE_URL) { - env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1") - ? env.ANTHROPIC_BASE_URL - : `${env.ANTHROPIC_BASE_URL}/v1`; - } - - // Merge new env with existing settings - const newSettings = { - ...currentSettings, - hasCompletedOnboarding: true, - env: { - ...(currentSettings.env || {}), - ...env, - }, - }; - - // Write new settings - await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); - - return NextResponse.json({ - success: true, - message: "Settings updated successfully", - }); - } catch (error) { - console.log("Error updating claude settings:", error); - return NextResponse.json( - { error: "Failed to update claude settings" }, - { status: 500 } - ); - } -} - -// Fields to remove when resetting -const RESET_ENV_KEYS = [ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "API_TIMEOUT_MS", -]; - -// DELETE - Reset settings (remove env fields) -export async function DELETE() { - try { - const settingsPath = getClaudeSettingsPath(); - - // Read current settings - let currentSettings = {}; - try { - const content = await fs.readFile(settingsPath, "utf-8"); - currentSettings = JSON.parse(content); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ - success: true, - message: "No settings file to reset", - }); - } - throw error; - } - - // Remove specified env fields - if (currentSettings.env) { - RESET_ENV_KEYS.forEach((key) => { - delete currentSettings.env[key]; - }); - - // Clean up empty env object - if (Object.keys(currentSettings.env).length === 0) { - delete currentSettings.env; - } - } - - // Write updated settings - await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2)); - - return NextResponse.json({ - success: true, - message: "Settings reset successfully", - }); - } catch (error) { - console.log("Error resetting claude settings:", error); - return NextResponse.json( - { error: "Failed to reset claude settings" }, - { status: 500 } - ); - } -} - diff --git a/src/app/api/cli-tools/cline-settings/route.js b/src/app/api/cli-tools/cline-settings/route.js deleted file mode 100644 index ecbccbd2..00000000 --- a/src/app/api/cli-tools/cline-settings/route.js +++ /dev/null @@ -1,135 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; - -const execAsync = promisify(exec); - -const getDataDir = () => path.join(os.homedir(), ".cline", "data"); -const getGlobalStatePath = () => path.join(getDataDir(), "globalState.json"); -const getSecretsPath = () => path.join(getDataDir(), "secrets.json"); - -const checkInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where cline" : "which cline"; - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getGlobalStatePath()); - return true; - } catch { - return false; - } - } -}; - -const readJson = async (filePath) => { - try { - const content = await fs.readFile(filePath, "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -const has9RouterConfig = (globalState) => { - if (!globalState) return false; - const isOpenAi = - globalState.actModeApiProvider === "openai" || globalState.planModeApiProvider === "openai"; - const baseUrl = globalState.openAiBaseUrl || ""; - return isOpenAi && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router")); -}; - -export async function GET() { - try { - const installed = await checkInstalled(); - if (!installed) { - return NextResponse.json({ installed: false, settings: null, message: "Cline CLI is not installed" }); - } - const globalState = await readJson(getGlobalStatePath()); - return NextResponse.json({ - installed: true, - settings: { - actModeApiProvider: globalState?.actModeApiProvider, - planModeApiProvider: globalState?.planModeApiProvider, - openAiBaseUrl: globalState?.openAiBaseUrl, - openAiModelId: globalState?.openAiModelId, - }, - has9Router: has9RouterConfig(globalState), - globalStatePath: getGlobalStatePath(), - }); - } catch (error) { - console.log("Error checking cline settings:", error); - return NextResponse.json({ error: "Failed to check cline settings" }, { status: 500 }); - } -} - -export async function POST(request) { - try { - const { baseUrl, apiKey, model } = await request.json(); - if (!baseUrl || !apiKey || !model) { - return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 }); - } - - await fs.mkdir(getDataDir(), { recursive: true }); - - // Cline expects base WITHOUT /v1 - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; - - const globalState = (await readJson(getGlobalStatePath())) || {}; - globalState.actModeApiProvider = "openai"; - globalState.planModeApiProvider = "openai"; - globalState.openAiBaseUrl = normalizedBaseUrl; - globalState.openAiModelId = model; - globalState.planModeOpenAiModelId = model; - await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2)); - - const secrets = (await readJson(getSecretsPath())) || {}; - secrets.openAiApiKey = apiKey; - await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2)); - - return NextResponse.json({ success: true, message: "Cline settings applied successfully!", globalStatePath: getGlobalStatePath() }); - } catch (error) { - console.log("Error updating cline settings:", error); - return NextResponse.json({ error: "Failed to update cline settings" }, { status: 500 }); - } -} - -export async function DELETE() { - try { - const globalState = await readJson(getGlobalStatePath()); - if (!globalState) { - return NextResponse.json({ success: true, message: "No settings file to reset" }); - } - - if (globalState.actModeApiProvider === "openai") { - delete globalState.openAiBaseUrl; - delete globalState.openAiModelId; - delete globalState.planModeOpenAiModelId; - globalState.actModeApiProvider = "cline"; - globalState.planModeApiProvider = "cline"; - } - await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2)); - - const secrets = (await readJson(getSecretsPath())) || {}; - delete secrets.openAiApiKey; - await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2)); - - return NextResponse.json({ success: true, message: "9Router settings removed from Cline" }); - } catch (error) { - console.log("Error resetting cline settings:", error); - return NextResponse.json({ error: "Failed to reset cline settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js deleted file mode 100644 index c0f0c936..00000000 --- a/src/app/api/cli-tools/codex-settings/route.js +++ /dev/null @@ -1,239 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { parseTOML, stringifyTOML } from "confbox"; -import { redactSecretsInText } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -const getCodexDir = () => path.join(os.homedir(), ".codex"); -const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml"); -const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json"); - -// Flatten confbox-parsed TOML into a writable object, preserving nested tables -const parsedToWritable = (obj) => obj ?? {}; - -// Set a nested key from a flat dotted path, creating intermediate objects as needed -const setNestedSection = (obj, dottedKey, value) => { - const keys = dottedKey.split("."); - let cur = obj; - for (let i = 0; i < keys.length - 1; i++) { - if (cur[keys[i]] == null || typeof cur[keys[i]] !== "object") { - cur[keys[i]] = {}; - } - cur = cur[keys[i]]; - } - cur[keys[keys.length - 1]] = value; -}; - -// Delete a nested key from a flat dotted path -const deleteNestedSection = (obj, dottedKey) => { - const keys = dottedKey.split("."); - let cur = obj; - for (let i = 0; i < keys.length - 1; i++) { - cur = cur?.[keys[i]]; - if (cur == null) return; - } - delete cur[keys[keys.length - 1]]; -}; - -// Check if codex CLI is installed (via which/where or config file exists) -const checkCodexInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where codex" : "which codex"; - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getCodexConfigPath()); - return true; - } catch { - return false; - } - } -}; - -// Read current config.toml -const readConfig = async () => { - try { - const configPath = getCodexConfigPath(); - const content = await fs.readFile(configPath, "utf-8"); - return content; - } catch (error) { - if (error.code === "ENOENT") return null; - throw error; - } -}; - -// Check if config has 9Router settings -const has9RouterConfig = (config) => { - if (!config) return false; - return config.includes("model_provider = \"9router\"") || config.includes("[model_providers.9router]"); -}; - -// GET - Check codex CLI and read current settings -export async function GET() { - try { - const isInstalled = await checkCodexInstalled(); - - if (!isInstalled) { - return NextResponse.json({ - installed: false, - config: null, - message: "Codex CLI is not installed", - }); - } - - const config = await readConfig(); - - return NextResponse.json({ - installed: true, - config: redactSecretsInText(config), - has9Router: has9RouterConfig(config), - configPath: getCodexConfigPath(), - }); - } catch (error) { - console.log("Error checking codex settings:", error); - return NextResponse.json({ error: "Failed to check codex settings" }, { status: 500 }); - } -} - -// POST - Update 9Router settings (merge with existing config) -export async function POST(request) { - try { - const { baseUrl, apiKey, model, subagentModel } = await request.json(); - - if (!baseUrl || !apiKey || !model) { - return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 }); - } - - const codexDir = getCodexDir(); - const configPath = getCodexConfigPath(); - - // Ensure directory exists - await fs.mkdir(codexDir, { recursive: true }); - - // Read and parse existing config - let parsed = {}; - try { - const existingConfig = await fs.readFile(configPath, "utf-8"); - parsed = parsedToWritable(parseTOML(existingConfig)); - } catch { /* No existing config */ } - - // Update only 9Router related fields (api_key goes to auth.json, not config.toml) - parsed.model = model; - parsed.model_provider = "9router"; - - // Update or create 9router provider section (no api_key - Codex reads from auth.json) - // Ensure /v1 suffix is added only once - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - setNestedSection(parsed, "model_providers.9router", { - name: "9Router", - base_url: normalizedBaseUrl, - wire_api: "responses", - }); - - // Add subagent configuration - const effectiveSubagentModel = subagentModel || model; - setNestedSection(parsed, "agents.subagent", { - model: effectiveSubagentModel, - }); - - // Write merged config - const configContent = stringifyTOML(parsed); - await fs.writeFile(configPath, configContent); - - // Update auth.json with OPENAI_API_KEY (Codex reads this first) - const authPath = getCodexAuthPath(); - let authData = {}; - try { - const existingAuth = await fs.readFile(authPath, "utf-8"); - authData = JSON.parse(existingAuth); - } catch { /* No existing auth */ } - - // Force apikey mode (keep existing tokens untouched for ChatGPT login reuse) - authData.OPENAI_API_KEY = apiKey; - authData.auth_mode = "apikey"; - await fs.writeFile(authPath, JSON.stringify(authData, null, 2)); - - return NextResponse.json({ - success: true, - message: "Codex settings applied successfully!", - configPath, - }); - } catch (error) { - console.log("Error updating codex settings:", error); - return NextResponse.json({ error: "Failed to update codex settings" }, { status: 500 }); - } -} - -// DELETE - Remove 9Router settings only (keep other settings) -export async function DELETE() { - try { - const configPath = getCodexConfigPath(); - - // Read and parse existing config - let parsed = {}; - try { - const existingConfig = await fs.readFile(configPath, "utf-8"); - parsed = parsedToWritable(parseTOML(existingConfig)); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ - success: true, - message: "No config file to reset", - }); - } - throw error; - } - - // Remove 9Router related root fields only if they point to 9router - if (parsed.model_provider === "9router") { - delete parsed.model; - delete parsed.model_provider; - } - - // Remove 9router provider section - deleteNestedSection(parsed, "model_providers.9router"); - - // Remove subagent configuration - deleteNestedSection(parsed, "agents.subagent"); - - // Write updated config - const configContent = stringifyTOML(parsed); - await fs.writeFile(configPath, configContent); - - // Remove OPENAI_API_KEY from auth.json - const authPath = getCodexAuthPath(); - try { - const existingAuth = await fs.readFile(authPath, "utf-8"); - const authData = JSON.parse(existingAuth); - delete authData.OPENAI_API_KEY; - delete authData.auth_mode; - - // Write back or delete if empty - if (Object.keys(authData).length === 0) { - await fs.unlink(authPath); - } else { - await fs.writeFile(authPath, JSON.stringify(authData, null, 2)); - } - } catch { /* No auth file */ } - - return NextResponse.json({ - success: true, - message: "9Router settings removed successfully", - }); - } catch (error) { - console.log("Error resetting codex settings:", error); - return NextResponse.json({ error: "Failed to reset codex settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/copilot-settings/route.js b/src/app/api/cli-tools/copilot-settings/route.js deleted file mode 100644 index f2cf8b17..00000000 --- a/src/app/api/cli-tools/copilot-settings/route.js +++ /dev/null @@ -1,151 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -// Resolve chatLanguageModels.json path per OS -const getConfigPath = () => { - const home = os.homedir(); - const platform = os.platform(); - if (platform === "win32") { - return path.join(process.env.APPDATA || home, "Code", "User", "chatLanguageModels.json"); - } - if (platform === "darwin") { - return path.join(home, "Library", "Application Support", "Code", "User", "chatLanguageModels.json"); - } - return path.join(home, ".config", "Code", "User", "chatLanguageModels.json"); -}; - -const readConfig = async () => { - try { - const content = await fs.readFile(getConfigPath(), "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -const has9RouterConfig = (config) => { - if (!Array.isArray(config)) return false; - return config.some((entry) => entry.name === "9Router"); -}; - -const get9RouterEntry = (config) => { - if (!Array.isArray(config)) return null; - return config.find((entry) => entry.name === "9Router") || null; -}; - -// GET - Read current copilot config -export async function GET() { - try { - const config = await readConfig(); - const entry = get9RouterEntry(config); - - return NextResponse.json({ - installed: true, - config: redactSecrets(config), - has9Router: has9RouterConfig(config), - configPath: getConfigPath(), - currentModel: entry?.models?.[0]?.id || null, - currentUrl: entry?.models?.[0]?.url || null, - }); - } catch (error) { - console.log("Error checking copilot settings:", error); - return NextResponse.json({ error: "Failed to check copilot settings" }, { status: 500 }); - } -} - -// POST - Apply 9Router config to chatLanguageModels.json -export async function POST(request) { - try { - const { baseUrl, apiKey, models } = await request.json(); - - if (!baseUrl || !models?.length) { - return NextResponse.json({ error: "baseUrl and models are required" }, { status: 400 }); - } - - const configPath = getConfigPath(); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - - // Read existing config array - let config = []; - try { - const existing = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(existing); - config = Array.isArray(parsed) ? parsed : []; - } catch { /* No existing config */ } - - const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`; - const keyToUse = apiKey || "sk_9router"; - - const newEntry = { - name: "9Router", - vendor: "azure", - apiKey: keyToUse, - models: models.map((id) => ({ - id, - name: id, - url: endpointUrl, - toolCalling: true, - vision: false, - maxInputTokens: 128000, - maxOutputTokens: 16000, - })), - }; - - // Replace existing 9Router entry or append - const idx = config.findIndex((e) => e.name === "9Router"); - if (idx >= 0) { - config[idx] = newEntry; - } else { - config.push(newEntry); - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - - return NextResponse.json({ - success: true, - message: "Copilot settings applied! Reload VS Code to take effect.", - configPath, - }); - } catch (error) { - console.log("Error updating copilot settings:", error); - return NextResponse.json({ error: "Failed to update copilot settings" }, { status: 500 }); - } -} - -// DELETE - Remove 9Router entry from chatLanguageModels.json -export async function DELETE() { - try { - const configPath = getConfigPath(); - - let config = []; - try { - const existing = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(existing); - config = Array.isArray(parsed) ? parsed : []; - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ success: true, message: "No config file to reset" }); - } - throw error; - } - - config = config.filter((e) => e.name !== "9Router"); - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - - return NextResponse.json({ - success: true, - message: "9Router removed from Copilot config", - }); - } catch (error) { - console.log("Error resetting copilot settings:", error); - return NextResponse.json({ error: "Failed to reset copilot settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js deleted file mode 100644 index 8c9eb2f2..00000000 --- a/src/app/api/cli-tools/cowork-settings/route.js +++ /dev/null @@ -1,388 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import crypto from "crypto"; -import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins"; -import { UPDATER_CONFIG } from "@/shared/constants/config"; -import { getConsistentMachineId } from "@/shared/utils/machineId"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -const APP_PORT = UPDATER_CONFIG.appPort; -const CLI_TOKEN_HEADER = "x-9r-cli-token"; -const CLI_TOKEN_SALT = "9r-cli-auth"; -const LOCAL_MCP_PREFIX = `http://localhost:${APP_PORT}/api/mcp/`; - -let cachedCliToken = null; -const getCliToken = async () => { - if (!cachedCliToken) cachedCliToken = await getConsistentMachineId(CLI_TOKEN_SALT); - return cachedCliToken; -}; - -// Inject CLI token header into entries pointing at our local /api/mcp/ bridge. -const injectAuthHeaders = async (entries) => { - const token = await getCliToken(); - for (const e of entries) { - if (typeof e?.url === "string" && e.url.startsWith(LOCAL_MCP_PREFIX)) { - e.headers = { ...(e.headers || {}), [CLI_TOKEN_HEADER]: token }; - } - } - return entries; -}; - -const PROVIDER = "gateway"; - -// Hardcoded relax-security profile applied on every Apply. -const SECURITY_RELAX = { - coworkEgressAllowedHosts: ["*"], - disabledBuiltinTools: [], - isLocalDevMcpEnabled: true, - isDesktopExtensionEnabled: true, - isDesktopExtensionDirectoryEnabled: true, - isDesktopExtensionSignatureRequired: false, - isClaudeCodeForDesktopEnabled: true, - disableEssentialTelemetry: true, - disableNonessentialTelemetry: true, - disableNonessentialServices: true, -}; - -// Tools auto-allow per server via toolPolicy["*"] = "allow" semantics. -// 3p schema requires explicit tool names; we mark "*" via operonSkipMcpApprovals instead. - -const getCandidateRoots = () => { - if (os.platform() === "darwin") { - const base = path.join(os.homedir(), "Library", "Application Support"); - return [path.join(base, "Claude-3p"), path.join(base, "Claude")]; - } - if (os.platform() === "win32") { - const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); - const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); - return [ - path.join(localApp, "Claude-3p"), - path.join(roaming, "Claude-3p"), - path.join(localApp, "Claude"), - path.join(roaming, "Claude"), - ]; - } - return [ - path.join(os.homedir(), ".config", "Claude-3p"), - path.join(os.homedir(), ".config", "Claude"), - ]; -}; - -const getAppInstallPaths = () => { - if (os.platform() === "darwin") { - return ["/Applications/Claude.app", path.join(os.homedir(), "Applications", "Claude.app")]; - } - if (os.platform() === "win32") { - const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); - const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; - return [ - path.join(localApp, "AnthropicClaude"), - path.join(programFiles, "Claude"), - path.join(programFiles, "AnthropicClaude"), - ]; - } - return []; -}; - -const resolveAppRootForRead = async () => { - const candidates = getCandidateRoots(); - for (const dir of candidates) { - try { - await fs.access(path.join(dir, "configLibrary")); - return dir; - } catch { /* try next */ } - } - return candidates[0]; -}; - -const getWriteRoot = () => getCandidateRoots()[0]; -const getConfigDir = async () => path.join(await resolveAppRootForRead(), "configLibrary"); -const getWriteConfigDir = () => path.join(getWriteRoot(), "configLibrary"); -const getMetaPath = async () => path.join(await getConfigDir(), "_meta.json"); -const getWriteMetaPath = () => path.join(getWriteConfigDir(), "_meta.json"); - -const get1pRoot = () => { - if (os.platform() === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Claude"); - if (os.platform() === "win32") { - const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); - return path.join(roaming, "Claude"); - } - return path.join(os.homedir(), ".config", "Claude"); -}; - -const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json"); - -const read1pConfig = async () => { - try { - const content = await fs.readFile(get1pConfigPath(), "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as empty config - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped) || {}; - } catch (error) { - return {}; - } -}; - -const write1pConfig = async (cfg) => { - await fs.mkdir(get1pRoot(), { recursive: true }); - await fs.writeFile(get1pConfigPath(), JSON.stringify(cfg, null, 2)); -}; - -const bootstrapDeploymentMode = async () => { - const cfg = await read1pConfig(); - if (cfg.deploymentMode === "3p") return false; - cfg.deploymentMode = "3p"; - await write1pConfig(cfg); - return true; -}; - -// Remove any legacy stdio entries previously written into 1p claude_desktop_config.json. -const cleanup1pLegacy = async () => { - const cfg = await read1pConfig(); - if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") return; - const managedNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name)); - for (const k of Object.keys(cfg.mcpServers)) { - if (managedNames.has(k)) delete cfg.mcpServers[k]; - } - if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers; - await write1pConfig(cfg); -}; - -// Build SSE bridge entries pointing at this app's inline /api/mcp/{name} endpoint. -const buildLocalBridgeEntries = (localPluginNames) => { - const names = Array.isArray(localPluginNames) ? localPluginNames : []; - const out = []; - for (const n of names) { - const def = LOCAL_STDIO_PLUGINS.find((p) => p.name === n); - if (!def) continue; - const entry = { - name: def.name, - url: `http://localhost:${APP_PORT}/api/mcp/${def.name}/sse`, - transport: "sse", - }; - if (Array.isArray(def.toolNames) && def.toolNames.length > 0) { - const prefix = `${def.name}-`; - const policy = {}; - for (const t of def.toolNames) { - policy[t] = "allow"; - policy[`${prefix}${t}`] = "allow"; - } - entry.toolPolicy = policy; - } - out.push(entry); - } - return out; -}; - -// Build entries for user-defined custom MCP plugins (URL or stdio command). -const buildCustomEntries = (customPlugins) => { - if (!Array.isArray(customPlugins)) return []; - const out = []; - for (const p of customPlugins) { - if (!p?.name || !p?.url) continue; - out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true }); - } - return out; -}; - -const checkInstalled = async () => { - for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) { - try { await fs.access(dir); return true; } catch { /* try next */ } - } - return false; -}; - -const readJson = async (filePath) => { - try { - const content = await fs.readFile(filePath, "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -const ensureMeta = async () => { - const writeMetaPath = getWriteMetaPath(); - let meta = await readJson(writeMetaPath); - if (!meta || !meta.appliedId) { - const existingRead = await readJson(await getMetaPath()); - if (existingRead?.appliedId) { - meta = existingRead; - } else { - const newId = crypto.randomUUID(); - meta = { appliedId: newId, entries: [{ id: newId, name: "Default" }] }; - } - await fs.mkdir(getWriteConfigDir(), { recursive: true }); - await fs.writeFile(writeMetaPath, JSON.stringify(meta, null, 2)); - } - return meta; -}; - -// Auto-skip approvals for every managed server (no per-tool prompts). -async function writeSkipApprovals(managedServers) { - const cfgPath = path.join(getWriteRoot(), "config.json"); - let cfg = {}; - try { cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {}; } - catch (e) { if (e.code !== "ENOENT") return { error: e.code }; } - const skip = {}; - for (const srv of managedServers) { - if (srv?.name) skip[srv.name] = true; - } - cfg.operonSkipMcpApprovals = skip; - await fs.mkdir(getWriteRoot(), { recursive: true }); - await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2)); - return { written: Object.keys(skip).length }; -} - -export async function GET() { - try { - const installed = await checkInstalled(); - if (!installed) { - return NextResponse.json({ installed: false, config: null, message: "Claude Desktop (Cowork mode) not detected" }); - } - const meta = await readJson(await getMetaPath()); - const appliedId = meta?.appliedId || null; - const configDir = await getConfigDir(); - const configPath = appliedId ? path.join(configDir, `${appliedId}.json`) : null; - const config = configPath ? await readJson(configPath) : null; - - const baseUrl = config?.inferenceGatewayBaseUrl || null; - const models = Array.isArray(config?.inferenceModels) - ? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean) - : []; - const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : []; - const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl); - - // Active local plugins = managedMcp entries whose URL points at our inline bridge. - const stdioNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name)); - const activeLocalNames = managedMcp - .filter((m) => stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/")) - .map((m) => m.name); - - // Custom plugins = bridge entries not in preset LOCAL_STDIO_PLUGINS (custom:true or unknown name). - const activeCustomPlugins = managedMcp - .filter((m) => m.custom || (!stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))) - .map((m) => ({ name: m.name, url: m.url, transport: m.transport, custom: true })); - - return NextResponse.json({ - installed: true, - config: redactSecrets(config), - has9Router, - configPath, - cowork: { - appliedId, - baseUrl, - models, - provider: config?.inferenceProvider || null, - plugins: managedMcp.filter((m) => !m.custom && !(stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))).map((m) => { - // Strip "{name}-" prefix and dedupe so re-applies don't multiply entries. - const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : []; - const prefix = `${m.name}-`; - const bare = new Set(); - for (const k of keys) { - let t = k; - while (t.startsWith(prefix)) t = t.slice(prefix.length); - bare.add(t); - } - // If plugin matches a default, prefer default toolNames (curated/correct). - const def = DEFAULT_PLUGINS.find((d) => d.name === m.name); - const toolNames = def && Array.isArray(def.toolNames) ? def.toolNames : Array.from(bare); - return { name: m.name, url: m.url, transport: m.transport, oauth: !!m.oauth, toolNames }; - }), - localPlugins: activeLocalNames, - customPlugins: activeCustomPlugins, - }, - defaultPlugins: DEFAULT_PLUGINS, - localStdioPlugins: LOCAL_STDIO_PLUGINS, - }); - } catch (error) { - console.log("Error reading cowork settings:", error); - return NextResponse.json({ error: "Failed to read cowork settings" }, { status: 500 }); - } -} - -export async function POST(request) { - try { - const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json(); - - if (!baseUrl || !apiKey) { - return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 }); - } - const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : []; - if (modelsArray.length === 0) { - return NextResponse.json({ error: "At least one model is required" }, { status: 400 }); - } - - // Respect empty array (user toggled all off); fallback to defaults only when undefined. - const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS; - const localPluginNames = Array.isArray(localPlugins) ? localPlugins : []; - // Only URL-based custom plugins allowed (no stdio command spawning). - const customPluginsArray = (Array.isArray(customPlugins) ? customPlugins : []).filter((p) => p?.url); - - const bridgeEntries = await injectAuthHeaders(buildLocalBridgeEntries(localPluginNames)); - const customEntries = await injectAuthHeaders(buildCustomEntries(customPluginsArray)); - const managedMcpServers = [...buildManagedMcpServers(pluginsArray), ...bridgeEntries, ...customEntries]; - - const bootstrapped = await bootstrapDeploymentMode(); - const meta = await ensureMeta(); - const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`); - - const newConfig = { - ...SECURITY_RELAX, - inferenceProvider: PROVIDER, - inferenceGatewayBaseUrl: baseUrl, - inferenceGatewayApiKey: apiKey, - inferenceModels: modelsArray.map((name) => ({ name })), - }; - if (managedMcpServers.length > 0) newConfig.managedMcpServers = managedMcpServers; - - await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2)); - - let skipResult = null; - try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; } - - // Best-effort cleanup of legacy 1p mcpServers entries written by earlier versions. - let localMcpResult = { applied: localPluginNames, via: "3p-sse-bridge" }; - try { await cleanup1pLegacy(); } catch { /* ignore */ } - - return NextResponse.json({ - success: true, - bootstrapped, - message: bootstrapped - ? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop." - : "Cowork settings applied. Quit & reopen Claude Desktop.", - configPath, - skipApprovals: skipResult, - localMcp: localMcpResult, - }); - } catch (error) { - console.log("Error applying cowork settings:", error); - return NextResponse.json({ error: "Failed to apply cowork settings" }, { status: 500 }); - } -} - -export async function DELETE() { - try { - const meta = await readJson(await getMetaPath()); - if (!meta?.appliedId) { - return NextResponse.json({ success: true, message: "No active config to reset" }); - } - const configPath = path.join(await getConfigDir(), `${meta.appliedId}.json`); - try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); } - catch (error) { if (error.code !== "ENOENT") throw error; } - try { await writeSkipApprovals([]); } catch { /* ignore */ } - try { await cleanup1pLegacy(); } catch { /* ignore */ } - return NextResponse.json({ success: true, message: "Cowork config reset" }); - } catch (error) { - console.log("Error resetting cowork settings:", error); - return NextResponse.json({ error: "Failed to reset cowork settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/deepseek-tui-settings/route.js b/src/app/api/cli-tools/deepseek-tui-settings/route.js deleted file mode 100644 index 871abce0..00000000 --- a/src/app/api/cli-tools/deepseek-tui-settings/route.js +++ /dev/null @@ -1,165 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { redactSecretsInText } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -const PROVIDER_NAME = "9router"; - -const getDeepSeekDir = () => path.join(os.homedir(), ".deepseek"); -const getDeepSeekConfigPath = () => path.join(getDeepSeekDir(), "config.toml"); - -// Simple TOML parser for key = "value" and [section] patterns -const parseToml = (content) => { - const result = {}; - let currentSection = result; - - const lines = content.split(/\r?\n/); - for (const line of lines) { - const trimmed = line.trim(); - // Skip empty lines and comments - if (!trimmed || trimmed.startsWith("#")) continue; - - // Section header: [section] or [section.subsection] - const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); - if (sectionMatch) { - const sectionName = sectionMatch[1]; - if (!result[sectionName]) result[sectionName] = {}; - currentSection = result[sectionName]; - continue; - } - - // Key = "value" or key = value - const keyValueMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]*)"$/); - if (keyValueMatch) { - currentSection[keyValueMatch[1]] = keyValueMatch[2]; - continue; - } - - // Key = value (unquoted) - const unquotedMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/); - if (unquotedMatch) { - currentSection[unquotedMatch[1]] = unquotedMatch[2].trim(); - } - } - - return result; -}; - -// Build TOML config for 9Router (openai provider mode) -const build9RouterConfig = (baseUrl, apiKey, model) => { - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - return `provider = "openai" - -[providers.openai] -base_url = "${normalizedBaseUrl}" -api_key = "${apiKey}" -model = "${model}" -`; -}; - -// Default DeepSeek config (reset state) -const DEFAULT_CONFIG = `provider = "deepseek" -`; - -const checkDeepSeekInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where deepseek" : "which deepseek"; - await execAsync(command, { windowsHide: true }); - return true; - } catch { - try { - await fs.access(getDeepSeekConfigPath()); - return true; - } catch { - return false; - } - } -}; - -const readConfigToml = async () => { - try { - return await fs.readFile(getDeepSeekConfigPath(), "utf-8"); - } catch (error) { - if (error.code === "ENOENT") return ""; - throw error; - } -}; - -// Detect 9Router by checking if provider is "openai" and base_url points to localhost/127.0.0.1 -const has9RouterConfig = (config) => { - if (!config) return false; - const provider = config.provider; - if (provider !== "openai") return false; - const openaiSection = config["providers.openai"]; - if (!openaiSection?.base_url) return false; - return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(openaiSection.base_url); -}; - -export async function GET() { - try { - const installed = await checkDeepSeekInstalled(); - if (!installed) { - return NextResponse.json({ installed: false, settings: null, message: "DeepSeek TUI is not installed" }); - } - const toml = await readConfigToml(); - const config = parseToml(toml); - return NextResponse.json({ - installed: true, - settings: redactSecretsInText(config), - has9Router: has9RouterConfig(config), - configPath: getDeepSeekConfigPath(), - }); - } catch (error) { - console.log("Error checking deepseek-tui settings:", error); - return NextResponse.json({ error: "Failed to check deepseek-tui settings" }, { status: 500 }); - } -} - -export async function POST(request) { - try { - const { baseUrl, apiKey, model } = await request.json(); - if (!baseUrl || !model) { - return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); - } - - const dir = getDeepSeekDir(); - await fs.mkdir(dir, { recursive: true }); - - const newConfig = build9RouterConfig(baseUrl, apiKey || "sk_9router", model); - await fs.writeFile(getDeepSeekConfigPath(), newConfig); - - return NextResponse.json({ - success: true, - message: "DeepSeek TUI settings applied successfully!", - configPath: getDeepSeekConfigPath(), - }); - } catch (error) { - console.log("Error updating deepseek-tui settings:", error); - return NextResponse.json({ error: "Failed to update deepseek-tui settings" }, { status: 500 }); - } -} - -export async function DELETE() { - try { - const configPath = getDeepSeekConfigPath(); - try { - await fs.access(configPath); - } catch { - return NextResponse.json({ success: true, message: "No config file to reset" }); - } - - await fs.writeFile(configPath, DEFAULT_CONFIG); - return NextResponse.json({ success: true, message: `${PROVIDER_NAME} config reset to DeepSeek defaults` }); - } catch (error) { - console.log("Error resetting deepseek-tui settings:", error); - return NextResponse.json({ error: "Failed to reset deepseek-tui settings" }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js deleted file mode 100644 index d7140b31..00000000 --- a/src/app/api/cli-tools/droid-settings/route.js +++ /dev/null @@ -1,216 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -const getDroidDir = () => path.join(os.homedir(), ".factory"); -const getDroidSettingsPath = () => path.join(getDroidDir(), "settings.json"); - -// Check if droid CLI is installed (via which/where or config file exists) -const checkDroidInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where droid" : "which droid"; - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getDroidSettingsPath()); - return true; - } catch { - return false; - } - } -}; - -// Read current settings.json -const readSettings = async () => { - try { - const settingsPath = getDroidSettingsPath(); - const content = await fs.readFile(settingsPath, "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -// Check if settings has 9Router customModels -const has9RouterConfig = (settings) => { - if (!settings || !settings.customModels) return false; - return settings.customModels.some(m => m.id?.startsWith("custom:9Router")); -}; - -// GET - Check droid CLI and read current settings -export async function GET() { - try { - const isInstalled = await checkDroidInstalled(); - - if (!isInstalled) { - return NextResponse.json({ - installed: false, - settings: null, - message: "Factory Droid CLI is not installed", - }); - } - - const settings = await readSettings(); - - return NextResponse.json({ - installed: true, - settings: redactSecrets(settings), - has9Router: has9RouterConfig(settings), - settingsPath: getDroidSettingsPath(), - }); - } catch (error) { - console.log("Error checking droid settings:", error); - return NextResponse.json({ error: "Failed to check droid settings" }, { status: 500 }); - } -} - -// POST - Update 9Router customModels (merge with existing settings) -// Accepts either `model` (string, legacy single-model) or `models` (array of strings, multi-model) -// Also accepts `activeModel` to set which model is active/primary -export async function POST(request) { - try { - const { baseUrl, apiKey, model, models, activeModel } = await request.json(); - - // Accept either `models` (array) or `model` (string, legacy) - const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []); - - if (!baseUrl || modelsArray.length === 0) { - return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 }); - } - - const droidDir = getDroidDir(); - const settingsPath = getDroidSettingsPath(); - - // Ensure directory exists - await fs.mkdir(droidDir, { recursive: true }); - - // Read existing settings or create new - let settings = {}; - try { - const existingSettings = await fs.readFile(settingsPath, "utf-8"); - settings = JSON.parse(existingSettings); - } catch { /* No existing settings */ } - - // Ensure customModels array exists - if (!settings.customModels) { - settings.customModels = []; - } - - // Remove all existing 9Router configs - settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router")); - - // Normalize baseUrl to ensure /v1 suffix - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - const keyToUse = apiKey || "your_api_key"; - - // Determine active model: prefer explicit activeModel, else first of modelsArray - // If activeModel is explicitly empty string, no model will be set as default - let defaultIndex = 0; - if (typeof activeModel === "string") { - if (activeModel === "") { - defaultIndex = -1; // signal: don't set a default - } else { - const idx = modelsArray.indexOf(activeModel); - defaultIndex = idx >= 0 ? idx : 0; - } - } - - // Add entries for all requested models - // The first one (index 0) will be the default if defaultIndex >= 0 - for (let i = 0; i < modelsArray.length; i++) { - const m = modelsArray[i]; - if (!m || typeof m !== "string") continue; - settings.customModels.push({ - model: m, - id: `custom:9Router-${i}`, - index: i, - baseUrl: normalizedBaseUrl, - apiKey: keyToUse, - displayName: m, - maxOutputTokens: 131072, - noImageSupport: false, - provider: "openai", - }); - } - - // Set default model if applicable - if (defaultIndex >= 0 && settings.customModels[defaultIndex]) { - // Reorder so the default comes first - const [defaultEntry] = settings.customModels.splice(defaultIndex, 1); - settings.customModels.unshift({ ...defaultEntry, index: 0 }); - // Re-index the rest - settings.customModels.forEach((m, i) => { m.index = i; }); - } - - // Write settings - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - - return NextResponse.json({ - success: true, - message: "Factory Droid settings applied successfully!", - settingsPath, - }); - } catch (error) { - console.log("Error updating droid settings:", error); - return NextResponse.json({ error: "Failed to update droid settings" }, { status: 500 }); - } -} - -// DELETE - Remove 9Router customModels only (keep other settings) -export async function DELETE() { - try { - const settingsPath = getDroidSettingsPath(); - - // Read existing settings - let settings = {}; - try { - const existingSettings = await fs.readFile(settingsPath, "utf-8"); - settings = JSON.parse(existingSettings); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ - success: true, - message: "No settings file to reset", - }); - } - throw error; - } - - // Remove 9Router customModels - if (settings.customModels) { - settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router")); - - // Remove customModels array if empty - if (settings.customModels.length === 0) { - delete settings.customModels; - } - } - - // Write updated settings - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - - return NextResponse.json({ - success: true, - message: "9Router settings removed successfully", - }); - } catch (error) { - console.log("Error resetting droid settings:", error); - return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/api/cli-tools/hermes-settings/route.js b/src/app/api/cli-tools/hermes-settings/route.js deleted file mode 100644 index 104c4f19..00000000 --- a/src/app/api/cli-tools/hermes-settings/route.js +++ /dev/null @@ -1,175 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; - -const execAsync = promisify(exec); - -const PROVIDER_NAME = "9router"; -const API_KEY_ENV = "OPENAI_API_KEY"; - -const getHermesDir = () => path.join(os.homedir(), ".hermes"); -const getHermesConfigPath = () => path.join(getHermesDir(), "config.yaml"); -const getHermesEnvPath = () => path.join(getHermesDir(), ".env"); - -// Match top-level "model:" block (until next non-indented, non-empty line) -const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m; - -const buildModelBlock = (model, baseUrl) => - `model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`; - -// Parse current model block back to fields (best-effort, simple key:value) -const parseModelBlock = (yaml) => { - const match = yaml.match(MODEL_BLOCK_RE); - if (!match) return null; - const body = match[1] || ""; - const get = (key) => { - const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m")); - return m ? m[1].trim() : null; - }; - return { - default: get("default"), - provider: get("provider"), - base_url: get("base_url"), - }; -}; - -const upsertModelBlock = (yaml, newBlock) => { - if (MODEL_BLOCK_RE.test(yaml)) return yaml.replace(MODEL_BLOCK_RE, newBlock); - return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock; -}; - -const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, ""); - -// .env helpers — upsert/remove single KEY=VALUE line -const upsertEnvVar = (envText, key, value) => { - const re = new RegExp(`^${key}=.*$`, "m"); - const line = `${key}=${value}`; - if (re.test(envText)) return envText.replace(re, line); - return envText.length > 0 && !envText.endsWith("\n") ? `${envText}\n${line}\n` : `${envText}${line}\n`; -}; - -const removeEnvVar = (envText, key) => { - const re = new RegExp(`^${key}=.*\\r?\\n?`, "m"); - return envText.replace(re, ""); -}; - -const checkHermesInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where hermes" : "which hermes"; - await execAsync(command, { windowsHide: true }); - return true; - } catch { - try { - await fs.access(getHermesConfigPath()); - return true; - } catch { - return false; - } - } -}; - -const readConfigYaml = async () => { - try { - return await fs.readFile(getHermesConfigPath(), "utf-8"); - } catch (error) { - if (error.code === "ENOENT") return ""; - throw error; - } -}; - -const readEnvFile = async () => { - try { - return await fs.readFile(getHermesEnvPath(), "utf-8"); - } catch (error) { - if (error.code === "ENOENT") return ""; - throw error; - } -}; - -// Detect 9router by base_url containing localhost/127.0.0.1 or matching tunnel URL -const has9RouterConfig = (modelCfg) => { - if (!modelCfg?.base_url) return false; - return modelCfg.provider === "custom" && /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(modelCfg.base_url); -}; - -export async function GET() { - try { - const installed = await checkHermesInstalled(); - if (!installed) { - return NextResponse.json({ installed: false, settings: null, message: "Hermes Agent is not installed" }); - } - const yaml = await readConfigYaml(); - const model = parseModelBlock(yaml); - return NextResponse.json({ - installed: true, - settings: { model }, - has9Router: has9RouterConfig(model), - configPath: getHermesConfigPath(), - }); - } catch (error) { - console.log("Error checking hermes settings:", error); - return NextResponse.json({ error: "Failed to check hermes settings" }, { status: 500 }); - } -} - -export async function POST(request) { - try { - const { baseUrl, apiKey, model } = await request.json(); - if (!baseUrl || !model) { - return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); - } - - const dir = getHermesDir(); - await fs.mkdir(dir, { recursive: true }); - - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - - // Update config.yaml — replace/insert model: block, keep everything else - const existingYaml = await readConfigYaml(); - const newYaml = upsertModelBlock(existingYaml, buildModelBlock(model, normalizedBaseUrl)); - await fs.writeFile(getHermesConfigPath(), newYaml); - - // Update .env — upsert OPENAI_API_KEY only when caller provides one - if (apiKey) { - const existingEnv = await readEnvFile(); - const newEnv = upsertEnvVar(existingEnv, API_KEY_ENV, apiKey); - await fs.writeFile(getHermesEnvPath(), newEnv); - } - - return NextResponse.json({ - success: true, - message: "Hermes settings applied successfully!", - configPath: getHermesConfigPath(), - }); - } catch (error) { - console.log("Error updating hermes settings:", error); - return NextResponse.json({ error: "Failed to update hermes settings" }, { status: 500 }); - } -} - -export async function DELETE() { - try { - const configPath = getHermesConfigPath(); - let yaml = ""; - try { - yaml = await fs.readFile(configPath, "utf-8"); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ success: true, message: "No config file to reset" }); - } - throw error; - } - const newYaml = removeModelBlock(yaml); - await fs.writeFile(configPath, newYaml); - return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model block removed` }); - } catch (error) { - console.log("Error resetting hermes settings:", error); - return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/jcode-settings/route.js b/src/app/api/cli-tools/jcode-settings/route.js deleted file mode 100644 index c700e5b9..00000000 --- a/src/app/api/cli-tools/jcode-settings/route.js +++ /dev/null @@ -1,217 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { parseTOML, stringifyTOML } from "confbox"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -const getJcodeConfigDir = () => path.join(os.homedir(), ".jcode"); -const getConfigPath = () => path.join(getJcodeConfigDir(), "config.toml"); - -const getProviderEnvPath = () => { - const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); - return path.join(configDir, "jcode", "provider-9router.env"); -}; - -const checkJcodeInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where jcode" : "which jcode"; - await execAsync(command, { windowsHide: true }); - return true; - } catch { - try { - await fs.access(getJcodeConfigDir()); - return true; - } catch { - return false; - } - } -}; - -const readConfig = async () => { - try { - const configPath = getConfigPath(); - const content = await fs.readFile(configPath, "utf-8"); - return parseTOML(content); - } catch (error) { - return { providers: {} }; - } -}; - -const has9RouterConfig = (config) => { - if (!config || !config.providers) return false; - - const providers = config.providers; - - if (providers["9router"]) return true; - - for (const [name, provider] of Object.entries(providers)) { - if (provider.base_url && provider.base_url.includes("localhost:20128")) { - return true; - } - } - - return false; -}; - -const writeConfig = async (config) => { - const configPath = getConfigPath(); - const content = stringifyTOML(config); - await fs.writeFile(configPath, content, "utf-8"); -}; - -const readProviderEnv = async () => { - try { - const envPath = getProviderEnvPath(); - const content = await fs.readFile(envPath, "utf-8"); - const env = {}; - - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - - const eqIndex = trimmed.indexOf("="); - if (eqIndex > 0) { - const key = trimmed.slice(0, eqIndex).trim(); - let value = trimmed.slice(eqIndex + 1).trim(); - - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - - env[key] = value; - } - } - - return env; - } catch { - return {}; - } -}; - -const writeProviderEnv = async (env) => { - const envPath = getProviderEnvPath(); - let content = "# jcode provider environment variables\n"; - - for (const [key, value] of Object.entries(env)) { - content += `${key}="${value}"\n`; - } - - await fs.writeFile(envPath, content, "utf-8"); -}; - -export async function GET() { - const isInstalled = await checkJcodeInstalled(); - - if (!isInstalled) { - return NextResponse.json({ - installed: false, - message: "jcode not installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", - }); - } - - const config = await readConfig(); - const has9Router = has9RouterConfig(config); - - return NextResponse.json({ - installed: true, - config: redactSecrets(config), - has9Router, - configPath: getConfigPath(), - }); -} - -export async function POST(request) { - try { - const { baseUrl, apiKey, models } = await request.json(); - - if (!baseUrl || !apiKey) { - return NextResponse.json( - { error: "baseUrl and apiKey are required" }, - { status: 400 } - ); - } - - const normalizedBaseUrl = baseUrl.endsWith("/v1") - ? baseUrl - : `${baseUrl}/v1`; - - let config = await readConfig(); - - if (!config.providers) { - config.providers = {}; - } - - config.providers["9router"] = { - type: "openai-compatible", - base_url: normalizedBaseUrl, - auth: "bearer", - api_key_env: "JCODE_9ROUTER_API_KEY", - env_file: "provider-9router.env", - default_model: models && models.length > 0 ? models[0] : "cc/claude-opus-4-7", - requires_api_key: true, - }; - - const configDir = getJcodeConfigDir(); - await fs.mkdir(configDir, { recursive: true }); - - await writeConfig(config); - - const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); - const jcodeConfigDir = path.join(xdgConfigDir, "jcode"); - await fs.mkdir(jcodeConfigDir, { recursive: true }); - - const env = await readProviderEnv(); - env.JCODE_9ROUTER_API_KEY = apiKey; - await writeProviderEnv(env); - - return NextResponse.json({ - success: true, - message: "jcode configured successfully. Use: jcode --provider-profile 9router", - configPath: getConfigPath(), - }); - } catch (error) { - console.error("Error configuring jcode:", error); - return NextResponse.json( - { error: error.message }, - { status: 500 } - ); - } -} - -export async function DELETE() { - try { - const config = await readConfig(); - - if (!config.providers) { - return NextResponse.json({ success: true, message: "No configuration to remove" }); - } - - delete config.providers["9router"]; - - await writeConfig(config); - - const env = await readProviderEnv(); - delete env.JCODE_9ROUTER_API_KEY; - await writeProviderEnv(env); - - return NextResponse.json({ - success: true, - message: "9router configuration removed from jcode", - }); - } catch (error) { - console.error("Error removing jcode configuration:", error); - return NextResponse.json( - { error: error.message }, - { status: 500 } - ); - } -} diff --git a/src/app/api/cli-tools/kilo-settings/route.js b/src/app/api/cli-tools/kilo-settings/route.js deleted file mode 100644 index 9c5993f2..00000000 --- a/src/app/api/cli-tools/kilo-settings/route.js +++ /dev/null @@ -1,133 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; - -const execAsync = promisify(exec); - -const getDataDir = () => path.join(os.homedir(), ".local", "share", "kilo"); -const getAuthPath = () => path.join(getDataDir(), "auth.json"); -const getVscodeSettingsPath = () => path.join(os.homedir(), ".config", "Code", "User", "settings.json"); - -const checkInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where kilo" : "which kilo"; - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getAuthPath()); - return true; - } catch { - return false; - } - } -}; - -const readJson = async (filePath) => { - try { - const content = await fs.readFile(filePath, "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -const has9RouterConfig = (auth) => { - if (!auth) return false; - const entry = auth["openai-compatible"] || auth["9router"]; - if (!entry) return false; - const baseUrl = entry.baseUrl || entry.baseURL || ""; - return baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router"); -}; - -export async function GET() { - try { - const installed = await checkInstalled(); - if (!installed) { - return NextResponse.json({ installed: false, settings: null, message: "Kilo Code CLI is not installed" }); - } - const auth = await readJson(getAuthPath()); - return NextResponse.json({ - installed: true, - settings: { auth: auth ? Object.keys(auth) : [] }, - has9Router: has9RouterConfig(auth), - authPath: getAuthPath(), - }); - } catch (error) { - console.log("Error checking kilo settings:", error); - return NextResponse.json({ error: "Failed to check kilo settings" }, { status: 500 }); - } -} - -export async function POST(request) { - try { - const { baseUrl, apiKey, model } = await request.json(); - if (!baseUrl || !apiKey || !model) { - return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 }); - } - - await fs.mkdir(getDataDir(), { recursive: true }); - - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - - const auth = (await readJson(getAuthPath())) || {}; - auth["openai-compatible"] = { - type: "api-key", - apiKey, - baseUrl: normalizedBaseUrl, - model, - }; - await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2)); - - // Best-effort: update VS Code extension settings - try { - const vscode = (await readJson(getVscodeSettingsPath())) || {}; - vscode["kilocode.customProvider"] = { name: "9Router", baseURL: normalizedBaseUrl, apiKey }; - vscode["kilocode.defaultModel"] = model; - await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2)); - } catch { /* VS Code settings not writable */ } - - return NextResponse.json({ success: true, message: "Kilo Code settings applied successfully!", authPath: getAuthPath() }); - } catch (error) { - console.log("Error updating kilo settings:", error); - return NextResponse.json({ error: "Failed to update kilo settings" }, { status: 500 }); - } -} - -export async function DELETE() { - try { - const auth = await readJson(getAuthPath()); - if (!auth) { - return NextResponse.json({ success: true, message: "No settings file to reset" }); - } - delete auth["openai-compatible"]; - delete auth["9router"]; - await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2)); - - try { - const vscode = await readJson(getVscodeSettingsPath()); - if (vscode) { - delete vscode["kilocode.customProvider"]; - delete vscode["kilocode.defaultModel"]; - await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2)); - } - } catch { /* ignore */ } - - return NextResponse.json({ success: true, message: "9Router settings removed from Kilo Code" }); - } catch (error) { - console.log("Error resetting kilo settings:", error); - return NextResponse.json({ error: "Failed to reset kilo settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js deleted file mode 100644 index 534cd92f..00000000 --- a/src/app/api/cli-tools/openclaw-settings/route.js +++ /dev/null @@ -1,295 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -// OpenClaw 2026.5.x writes agents[].model as either a plain string -// (legacy) or as an object `{ primary, fallbacks }`. Normalize to the -// string id so downstream consumers can call `.startsWith()` safely. -const resolveAgentModel = (m) => { - if (typeof m === "string") return m; - if (m && typeof m === "object") return m.primary ?? ""; - return ""; -}; - -const getOpenClawDir = () => path.join(os.homedir(), ".openclaw"); -const getOpenClawSettingsPath = () => path.join(getOpenClawDir(), "openclaw.json"); - -// Check if openclaw CLI is installed (via which/where or config file exists) -const checkOpenClawInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where openclaw" : "which openclaw"; - // On Windows, inject %APPDATA%\npm into PATH so npm global packages are found - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getOpenClawSettingsPath()); - return true; - } catch { - return false; - } - } -}; - -// Read current settings.json -const readSettings = async () => { - try { - const settingsPath = getOpenClawSettingsPath(); - const content = await fs.readFile(settingsPath, "utf-8"); - // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" - // rather than throwing a 500 that the UI misreads as "tool not installed". - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - return null; - } -}; - -// Check if settings has 9Router config -const has9RouterConfig = (settings) => { - if (!settings || !settings.models || !settings.models.providers) return false; - return !!settings.models.providers["9router"]; -}; - -// Read per-agent models.json and return current model id (without "9router/" prefix) -const readAgentModel = async (agentDir) => { - try { - const modelsPath = path.join(agentDir, "models.json"); - const content = await fs.readFile(modelsPath, "utf-8"); - const data = JSON.parse(content); - const models = data?.providers?.["9router"]?.models; - return models?.[0]?.id || null; - } catch { - return null; - } -}; - -// GET - Check openclaw CLI and read current settings -export async function GET() { - try { - const isInstalled = await checkOpenClawInstalled(); - - if (!isInstalled) { - return NextResponse.json({ - installed: false, - settings: null, - message: "Open Claw CLI is not installed", - }); - } - - const settings = await readSettings(); - - // Enrich agents list with current per-agent model from models.json. - // Coerce agent.model to its string id when OpenClaw stores it as - // `{ primary, fallbacks }` so downstream `.startsWith()` calls work. - const agentList = settings?.agents?.list || []; - const enrichedAgents = await Promise.all( - agentList.map(async (agent) => { - const agentModel = agent.agentDir ? await readAgentModel(agent.agentDir) : null; - return { ...agent, model: resolveAgentModel(agent.model), currentModel: agentModel }; - }) - ); - - return NextResponse.json({ - installed: true, - settings: redactSecrets(settings), - agents: redactSecrets(enrichedAgents), - has9Router: has9RouterConfig(settings), - settingsPath: getOpenClawSettingsPath(), - }); - } catch (error) { - console.log("Error checking openclaw settings:", error); - return NextResponse.json({ error: "Failed to check openclaw settings" }, { status: 500 }); - } -} - -// Write per-agent models.json -const writeAgentModels = async (agentDir, model, baseUrl, apiKey) => { - await fs.mkdir(agentDir, { recursive: true }); - const modelsPath = path.join(agentDir, "models.json"); - let existing = {}; - try { - const content = await fs.readFile(modelsPath, "utf-8"); - existing = JSON.parse(content); - } catch { /* No existing */ } - - if (!existing.providers) existing.providers = {}; - existing.providers["9router"] = { - baseUrl, - apiKey: apiKey || "your_api_key", - api: "openai-completions", - models: [{ id: model, name: model.split("/").pop() || model }], - }; - await fs.writeFile(modelsPath, JSON.stringify(existing, null, 2)); -}; - -// POST - Update 9Router settings (merge with existing settings) -export async function POST(request) { - try { - // agentModels: { [agentId]: modelId } for per-agent override - const { baseUrl, apiKey, model, agentModels = {} } = await request.json(); - - if (!baseUrl || !model) { - return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); - } - - const openclawDir = getOpenClawDir(); - const settingsPath = getOpenClawSettingsPath(); - - await fs.mkdir(openclawDir, { recursive: true }); - - let settings = {}; - try { - const existingSettings = await fs.readFile(settingsPath, "utf-8"); - settings = JSON.parse(existingSettings); - } catch { /* No existing settings */ } - - if (!settings.agents) settings.agents = {}; - if (!settings.agents.defaults) settings.agents.defaults = {}; - if (!settings.agents.defaults.model) settings.agents.defaults.model = {}; - if (!settings.agents.defaults.models) settings.agents.defaults.models = {}; - if (!settings.models) settings.models = {}; - if (!settings.models.providers) settings.models.providers = {}; - - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - const fullModelId = `9router/${model}`; - - // Remove all old 9router/* entries from agents.defaults.models - Object.keys(settings.agents.defaults.models) - .filter((k) => k.startsWith("9router/")) - .forEach((k) => { delete settings.agents.defaults.models[k]; }); - - // Update default model - settings.agents.defaults.model.primary = fullModelId; - - // Collect all unique models (default + per-agent) - const allModelIds = new Set([model]); - Object.values(agentModels).forEach((m) => { if (m) allModelIds.add(m); }); - - // Add fresh 9router models to allowlist - allModelIds.forEach((m) => { - settings.agents.defaults.models[`9router/${m}`] = {}; - }); - - // Remove old 9router model from each agent in agents.list. The - // model field may be a plain string or `{ primary, fallbacks }`. - if (settings.agents.list) { - settings.agents.list = settings.agents.list.map((agent) => { - if (resolveAgentModel(agent.model).startsWith("9router/")) { - const { model: _, ...rest } = agent; - return rest; - } - return agent; - }); - } - - // Update models.providers.9router with all models - settings.models.providers["9router"] = { - baseUrl: normalizedBaseUrl, - apiKey: apiKey || "your_api_key", - api: "openai-completions", - models: [...allModelIds].map((m) => ({ id: m, name: m.split("/").pop() || m })), - }; - - // Set per-agent model in agents.list and write models.json - if (settings.agents.list) { - settings.agents.list = settings.agents.list.map((agent) => { - const agentModel = agentModels[agent.id]; - if (agentModel) return { ...agent, model: `9router/${agentModel}` }; - return agent; - }); - - // Write per-agent models.json for agents with agentDir - await Promise.all( - settings.agents.list.map(async (agent) => { - if (!agent.agentDir) return; - const agentModel = agentModels[agent.id]; - const modelToWrite = agentModel || model; // fallback to default - await writeAgentModels(agent.agentDir, modelToWrite, normalizedBaseUrl, apiKey); - }) - ); - } - - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - - return NextResponse.json({ - success: true, - message: "Open Claw settings applied successfully!", - settingsPath, - }); - } catch (error) { - console.log("Error updating openclaw settings:", error); - return NextResponse.json({ error: "Failed to update openclaw settings" }, { status: 500 }); - } -} - -// DELETE - Remove 9Router settings only (keep other settings) -export async function DELETE() { - try { - const settingsPath = getOpenClawSettingsPath(); - - // Read existing settings - let settings = {}; - try { - const existingSettings = await fs.readFile(settingsPath, "utf-8"); - settings = JSON.parse(existingSettings); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ - success: true, - message: "No settings file to reset", - }); - } - throw error; - } - - // Remove 9Router from models.providers - if (settings.models && settings.models.providers) { - delete settings.models.providers["9router"]; - - // Remove providers object if empty - if (Object.keys(settings.models.providers).length === 0) { - delete settings.models.providers; - } - } - - // Remove 9router models from agents.defaults.models allowlist - if (settings.agents?.defaults?.models) { - const keysToRemove = Object.keys(settings.agents.defaults.models).filter((k) => k.startsWith("9router/")); - for (const key of keysToRemove) { - delete settings.agents.defaults.models[key]; - } - if (Object.keys(settings.agents.defaults.models).length === 0) { - delete settings.agents.defaults.models; - } - } - - // Reset agents.defaults.model.primary if it uses 9router - if (settings.agents?.defaults?.model?.primary?.startsWith("9router/")) { - delete settings.agents.defaults.model.primary; - } - - // Write updated settings - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - - return NextResponse.json({ - success: true, - message: "9Router settings removed successfully", - }); - } catch (error) { - console.log("Error resetting openclaw settings:", error); - return NextResponse.json({ error: "Failed to reset openclaw settings" }, { status: 500 }); - } -} diff --git a/src/app/api/cli-tools/opencode-settings/route.js b/src/app/api/cli-tools/opencode-settings/route.js deleted file mode 100644 index 5456d671..00000000 --- a/src/app/api/cli-tools/opencode-settings/route.js +++ /dev/null @@ -1,266 +0,0 @@ -"use server"; - -import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { redactSecrets } from "@/lib/security/redactSecrets"; - -const execAsync = promisify(exec); - -const getConfigDir = () => path.join(os.homedir(), ".config", "opencode"); -const getConfigPath = () => path.join(getConfigDir(), "opencode.json"); - -// Check if opencode CLI is installed (via which/where or config file exists) -const checkOpenCodeInstalled = async () => { - try { - const isWindows = os.platform() === "win32"; - const command = isWindows ? "where opencode" : "which opencode"; - const env = isWindows - ? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` } - : process.env; - await execAsync(command, { windowsHide: true, env }); - return true; - } catch { - try { - await fs.access(getConfigPath()); - return true; - } catch { - return false; - } - } -}; - -const readConfig = async () => { - try { - const content = await fs.readFile(getConfigPath(), "utf-8"); - // opencode config files may use JSONC format (trailing commas, comments). - // Strip trailing commas before parsing to avoid SyntaxError on valid JSONC. - const stripped = content.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(stripped); - } catch (error) { - if (error.code === "ENOENT") return null; - // If the config file exists but is unparseable (corrupted, exotic JSONC), - // treat it as "no config" rather than throwing a 500 that the UI - // misinterprets as "opencode not installed". - return null; - } -}; - -const has9RouterConfig = (config) => { - if (!config?.provider) return false; - return !!config.provider["9router"]; -}; - -// GET - Check opencode CLI and read current settings -export async function GET() { - try { - const isInstalled = await checkOpenCodeInstalled(); - - if (!isInstalled) { - return NextResponse.json({ - installed: false, - config: null, - message: "OpenCode CLI is not installed", - }); - } - - const config = await readConfig(); - const providerConfig = config?.provider?.["9router"]; - const modelMap = providerConfig?.models || {}; - - return NextResponse.json({ - installed: true, - config: redactSecrets(config), - has9Router: has9RouterConfig(config), - configPath: getConfigPath(), - opencode: { - models: Object.keys(modelMap), - activeModel: config?.model?.startsWith("9router/") ? config.model.replace(/^9router\//, "") : null, - baseURL: providerConfig?.options?.baseURL || null, - }, - }); - } catch (error) { - console.log("Error checking opencode settings:", error); - return NextResponse.json({ error: "Failed to check opencode settings" }, { status: 500 }); - } -} - -// POST - Apply 9Router as openai-compatible provider (multi-model support) -export async function POST(request) { - try { - const { baseUrl, apiKey, model, models, activeModel, subagentModel } = await request.json(); - - // Accept either `model` (string, legacy) or `models` (array of strings) - const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []); - - if (!baseUrl || modelsArray.length === 0) { - return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 }); - } - - const configDir = getConfigDir(); - const configPath = getConfigPath(); - - await fs.mkdir(configDir, { recursive: true }); - - // Read existing config or start fresh - let config = {}; - try { - const existing = await fs.readFile(configPath, "utf-8"); - config = JSON.parse(existing); - } catch { /* No existing config */ } - - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - const keyToUse = apiKey || "sk_9router"; - const effectiveSubagentModel = subagentModel || modelsArray[0]; - - // Ensure provider object - if (!config.provider) config.provider = {}; - - // Preserve any existing 9router provider entry and its models - const existingProvider = config.provider["9router"] || { npm: "@ai-sdk/openai-compatible", options: {}, models: {} }; - - // Merge options (overwrite baseURL/apiKey) - existingProvider.options = { - ...existingProvider.options, - baseURL: normalizedBaseUrl, - apiKey: keyToUse, - }; - - // Ensure models map exists - existingProvider.models = existingProvider.models || {}; - - // Add or update entries for all requested models - for (const m of modelsArray) { - if (!m || typeof m !== "string") continue; - existingProvider.models[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } }; - } - - // Save merged provider back - config.provider["9router"] = existingProvider; - - // Set the active model: prefer explicit activeModel, else first of modelsArray - // If activeModel is explicitly empty string, clear the model - if (activeModel === "") { - config.model = ""; - } else { - const finalActive = activeModel || modelsArray[0]; - if (finalActive) { - config.model = `9router/${finalActive}`; - } - } - - // Add subagent configuration - if (!config.agent) config.agent = {}; - config.agent.explorer = { - description: "Fast explorer subagent for codebase exploration", - mode: "subagent", - model: `9router/${effectiveSubagentModel}`, - }; - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - - return NextResponse.json({ - success: true, - message: "OpenCode settings applied successfully!", - configPath, - }); - } catch (error) { - console.log("Error applying opencode settings:", error); - return NextResponse.json({ error: "Failed to apply settings" }, { status: 500 }); - } -} - -// PATCH - Update specific settings (e.g., clear active model) -export async function PATCH(request) { - try { - const { clearActiveModel } = await request.json(); - const configPath = getConfigPath(); - - let config = {}; - try { - const existing = await fs.readFile(configPath, "utf-8"); - config = JSON.parse(existing); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ success: true, message: "No config file found" }); - } - throw error; - } - - if (clearActiveModel === true) { - // Clear active model but keep models in the list - if (config.model?.startsWith("9router/")) { - config.model = ""; - } - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - - return NextResponse.json({ - success: true, - message: "Settings updated", - }); - } catch (error) { - console.log("Error patching opencode settings:", error); - return NextResponse.json({ error: "Failed to patch settings" }, { status: 500 }); - } -} - -// DELETE - Remove 9Router provider or specific models from config -export async function DELETE(request) { - try { - const { searchParams } = new URL(request.url); - const modelToRemove = searchParams.get("model"); - const configPath = getConfigPath(); - - let config = {}; - try { - const existing = await fs.readFile(configPath, "utf-8"); - config = JSON.parse(existing); - } catch (error) { - if (error.code === "ENOENT") { - return NextResponse.json({ success: true, message: "No config file to reset" }); - } - throw error; - } - - // If specific model provided, remove just that model - if (modelToRemove && config.provider?.["9router"]?.models) { - delete config.provider["9router"].models[modelToRemove]; - - // If no models left, remove the provider - if (Object.keys(config.provider["9router"].models).length === 0) { - delete config.provider["9router"]; - if (config.model?.startsWith("9router/")) delete config.model; - } else if (config.model === `9router/${modelToRemove}`) { - // If removed model was active, switch to first remaining model - const remainingModels = Object.keys(config.provider["9router"].models); - config.model = `9router/${remainingModels[0]}`; - } - } else { - // No specific model - remove entire 9router provider - if (config.provider) delete config.provider["9router"]; - if (config.model?.startsWith("9router/")) delete config.model; - } - - // Remove subagent configuration - if (config.agent?.explorer?.model?.startsWith("9router/")) { - delete config.agent.explorer; - // Clean up empty agent object - if (Object.keys(config.agent).length === 0) delete config.agent; - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - - return NextResponse.json({ - success: true, - message: modelToRemove ? `Model "${modelToRemove}" removed` : "9Router settings removed from OpenCode", - }); - } catch (error) { - console.log("Error resetting opencode settings:", error); - return NextResponse.json({ error: "Failed to reset opencode settings" }, { status: 500 }); - } -} diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 1a2a39dc..e66b4cc4 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -44,15 +44,12 @@ const ALWAYS_PROTECTED = [ ]; // User administration is never exposed to normal users, even if dashboard login -// is disabled for local single-user deployments. CLI Tools directly read and -// mutate the account running 9Router's local CLI configuration, so they are -// host administration rather than per-user dashboard preferences. +// is disabled for local single-user deployments. const ADMIN_ONLY_PATHS = [ "/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe", - "/api/cli-tools", "/api/media-providers", "/api/proxy-pools", "/api/translator/console-logs", @@ -63,7 +60,6 @@ const ADMIN_ONLY_PATHS = [ const ADMIN_ONLY_DASHBOARD_PATHS = [ "/dashboard/token-saver", "/dashboard/pxpipe", - "/dashboard/cli-tools", "/dashboard/media-providers", "/dashboard/proxy-pools", "/dashboard/console-log", @@ -84,7 +80,6 @@ const PROTECTED_API_PATHS = [ "/api/media-providers", "/api/pricing", "/api/tags", - "/api/cli-tools", "/api/mcp", "/api/translator", "/api/tunnel", @@ -223,15 +218,15 @@ export async function proxy(request) { const { pathname } = request.nextUrl; // CLI Tools access the server process's home directory and, in the MITM - // case, can change privileged system networking. Do not allow a shared CLI - // bearer token to become a remote host-administration credential: only a - // local, authenticated administrator may use these endpoints. + // case, can change privileged system networking. Keep them available only + // to authenticated dashboard users on the local machine; never allow the + // shared CLI bearer token to become a remote host-administration credential. if (pathname === "/api/cli-tools" || pathname.startsWith("/api/cli-tools/")) { if (!isLocalRequest(request)) { return NextResponse.json({ error: "CLI Tools are available only from the local machine" }, { status: 403 }); } - if (!(await isAdmin(request))) { - return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } } diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 5708807b..9e01a68d 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -32,6 +32,7 @@ export default function ModelSelectModal({ kindFilter = null, addedModelValues = [], closeOnSelect = true, + availableModels = null, }) { // Filter activeProviders by serviceKinds when kindFilter set (e.g. "webSearch", "webFetch") const filteredActiveProviders = useMemo(() => { @@ -119,6 +120,36 @@ export default function ModelSelectModal({ const groupedModels = useMemo(() => { const groups = {}; + // Consumers that need the exact catalog shown on the Models page supply its + // /api/models/connected result. This avoids recreating a separate, partial + // catalog from client-side provider constants, aliases, and custom models. + if (Array.isArray(availableModels)) { + availableModels.forEach((model) => { + const providerId = model.providerAlias || model.provider?.alias || model.provider?.id; + if (!providerId) return; + + if (!groups[providerId]) { + groups[providerId] = { + name: model.provider?.name || providerId, + alias: model.providerAlias || model.provider?.alias || providerId, + color: model.provider?.color || "#666", + models: [], + }; + } + + groups[providerId].models.push({ + id: model.model, + name: model.name || model.alias || model.model, + value: model.fullModel, + alias: model.alias, + caps: model.caps, + isCustom: model.isCustom, + }); + }); + + return groups; + } + // Kinds where the provider IS the model (no per-model selection needed) const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]); // Kinds that map directly to model.type field @@ -349,15 +380,15 @@ export default function ModelSelectModal({ }); return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]); + }, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]); // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) const filteredCombos = useMemo(() => { - if (kindFilter) return []; + if (kindFilter || availableModels) return []; if (!searchQuery.trim()) return combos; const query = searchQuery.toLowerCase(); return combos.filter(c => c.name.toLowerCase().includes(query)); - }, [combos, searchQuery, kindFilter]); + }, [combos, searchQuery, kindFilter, availableModels]); // Sort models alphabetically, with added models floated to top const sortModels = (models) => { @@ -578,4 +609,15 @@ ModelSelectModal.propTypes = { kindFilter: PropTypes.string, addedModelValues: PropTypes.arrayOf(PropTypes.string), closeOnSelect: PropTypes.bool, + availableModels: PropTypes.arrayOf(PropTypes.shape({ + fullModel: PropTypes.string.isRequired, + model: PropTypes.string.isRequired, + providerAlias: PropTypes.string, + provider: PropTypes.shape({ + id: PropTypes.string, + alias: PropTypes.string, + name: PropTypes.string, + color: PropTypes.string, + }), + })), }; diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index aa9b5e92..67e3de6a 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -224,14 +224,14 @@ describe("dashboard guard local-only access", () => { expect(response.body.error).toBe("Local only: CLI token required"); }); - it("requires an administrator for CLI Tools", async () => { + it("requires authentication for CLI Tools", async () => { const response = await proxy(request("/api/cli-tools/antigravity-mitm", { host: "localhost:20128", origin: "http://localhost:20128", })); - expect(response.status).toBe(403); - expect(response.body.error).toBe("Administrator access required"); + expect(response.status).toBe(401); + expect(response.body.error).toBe("Unauthorized"); }); it("rejects local-only route from a tunnel host", async () => { @@ -261,7 +261,7 @@ describe("dashboard guard local-only access", () => { }); }); -describe("dashboard guard CLI Tools administration access", () => { +describe("dashboard guard CLI Tools access", () => { beforeEach(() => { vi.clearAllMocks(); mocks.getSettings.mockResolvedValue({}); @@ -272,14 +272,13 @@ describe("dashboard guard CLI Tools administration access", () => { mocks.verifyDashboardAuthToken.mockResolvedValue(true); }); - it("rejects normal users from host-level CLI Tools operations", async () => { + it("allows local authenticated users to use CLI Tools", async () => { const response = await proxy(request("/api/cli-tools/claude-settings", { host: "localhost:20128", origin: "http://localhost:20128", }, "user-token")); - expect(response.status).toBe(403); - expect(response.body.error).toBe("Administrator access required"); + expect(response).toBe(mocks.nextResponse); }); it("rejects remote CLI Tools access even with an administrator session", async () => {