fix: update cli tools

This commit is contained in:
2026-07-12 22:44:11 +07:00
parent 43a0c90dac
commit 330eb4d936
37 changed files with 441 additions and 8073 deletions
-37
View File
@@ -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<Object>} { success, data: { installed, has9Router, ... } }
*/
async function getCliToolSettings(tool) {
return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
}
/**
* Apply CLI tool settings (POST)
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @param {Object} body - Payload depends on tool
* @returns {Promise<Object>} { success, data }
*/
async function applyCliToolSettings(tool, body) {
return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
}
/**
* Reset CLI tool settings (DELETE)
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @returns {Promise<Object>} { success, data }
*/
async function resetCliToolSettings(tool) {
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
}
// ============================================================================
// SETTINGS API
// ============================================================================
@@ -528,11 +496,6 @@ module.exports = {
updateCombo,
deleteCombo,
// CLI Tools
getCliToolSettings,
applyCliToolSettings,
resetCliToolSettings,
// Settings
getSettings,
updateSettings,
-618
View File
@@ -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<string|null>}
*/
async function getFirstApiKey() {
const result = await api.getApiKeys();
const keys = result.success ? (result.data.keys || []) : [];
return keys.length > 0 ? keys[0].key : null;
}
// ─── Claude Code ──────────────────────────────────────────────────────────────
/**
* Build header showing current Claude config status
* @returns {Promise<string>}
*/
async function buildClaudeHeader() {
const result = await api.getCliToolSettings("claude");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const settings = result.data.settings;
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
const lines = [];
if (currentUrl) {
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
if (currentKey) {
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
}
} else {
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
}
return lines.join("\n");
}
/**
* Get current Claude model from settings
* @param {string} envKey
* @returns {Promise<string>}
*/
async function getClaudeModel(envKey) {
const result = await api.getCliToolSettings("claude");
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
}
/**
* Quick setup for Claude Code — sets endpoint, key, and all default models
* @param {number} port
*/
async function claudeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Select and save a specific Claude model type
* @param {Object} modelType
* @param {number} port
*/
async function claudeSelectModel(modelType, port) {
const current = await getClaudeModel(modelType.envKey);
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
if (!selected) return;
const env = { [modelType.envKey]: selected };
// Also set base URL if not configured yet
const settingsResult = await api.getCliToolSettings("claude");
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
env.ANTHROPIC_BASE_URL = endpoint;
env.API_TIMEOUT_MS = "600000";
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
}
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? `${modelType.name}${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Claude Code settings
*/
async function claudeReset() {
const result = await api.resetCliToolSettings("claude");
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Claude Code submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showClaudeCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🔧 Claude Code Settings",
breadcrumb,
headerContent: buildClaudeHeader,
refresh: async () => ({
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
}),
items: [
{
label: "⚡ Quick Setup (recommended)",
action: async () => { await claudeQuickSetup(port); return true; }
},
{
label: (d) => `Sonnet → ${d.sonnet}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
},
{
label: (d) => `Opus → ${d.opus}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
},
{
label: (d) => `Haiku → ${d.haiku}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
},
{
label: "Reset to Default",
action: async () => { await claudeReset(); return true; }
}
]
});
}
// ─── Codex CLI ────────────────────────────────────────────────────────────────
/**
* Build header showing current Codex config status
* @returns {Promise<string>}
*/
async function buildCodexHeader() {
const result = await api.getCliToolSettings("codex");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, config } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Parse base_url and model from raw TOML string
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
const model = modelMatch ? modelMatch[1] : "";
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Codex CLI
* @param {number} port
*/
async function codexQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Get model selection
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Codex CLI settings
*/
async function codexReset() {
const result = await api.resetCliToolSettings("codex");
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Codex CLI submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCodexMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Codex CLI Settings",
breadcrumb,
headerContent: buildCodexHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await codexQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await codexReset(); return true; }
}
]
});
}
// ─── Factory Droid ────────────────────────────────────────────────────────────
/**
* Build header showing current Droid config status
* @returns {Promise<string>}
*/
async function buildDroidHeader() {
const result = await api.getCliToolSettings("droid");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router custom model config
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Factory Droid
* @param {number} port
*/
async function droidQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Factory Droid settings
*/
async function droidReset() {
const result = await api.resetCliToolSettings("droid");
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Factory Droid submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showDroidMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Factory Droid Settings",
breadcrumb,
headerContent: buildDroidHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await droidQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await droidReset(); return true; }
}
]
});
}
// ─── Open Claw ────────────────────────────────────────────────────────────────
/**
* Build header showing current OpenClaw config status
* @returns {Promise<string>}
*/
async function buildOpenClawHeader() {
const result = await api.getCliToolSettings("openclaw");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router provider config
const provider = settings?.models?.providers?.["9router"];
const primary = settings?.agents?.defaults?.model?.primary || "";
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Open Claw
* @param {number} port
*/
async function openClawQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Open Claw settings
*/
async function openClawReset() {
const result = await api.resetCliToolSettings("openclaw");
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Open Claw submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showOpenClawMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🦞 Open Claw Settings",
breadcrumb,
headerContent: buildOpenClawHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await openClawQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await openClawReset(); return true; }
}
]
});
}
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
async function buildOpenCodeHeader() {
const result = await api.getCliToolSettings("opencode");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, opencode } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
}
return lines.join("\n");
}
async function openCodeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Pick first model (also becomes active model by default)
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
if (!firstModel) return;
const models = [firstModel];
// Optionally add more models
while (true) {
const more = await confirm(`Add another model? (current: ${models.length})`);
if (!more) break;
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
if (!next) break;
if (!models.includes(next)) models.push(next);
}
// Optional subagent model
let subagentModel = firstModel;
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
if (wantSubagent) {
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
if (picked) subagentModel = picked;
}
const result = await api.applyCliToolSettings("opencode", {
baseUrl: endpoint,
apiKey,
models,
activeModel: firstModel,
subagentModel,
});
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function openCodeReset() {
const result = await api.resetCliToolSettings("opencode");
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showOpenCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "💻 OpenCode CLI Settings",
breadcrumb,
headerContent: buildOpenCodeHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
]
});
}
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
async function buildHermesHeader() {
const result = await api.getCliToolSettings("hermes");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const model = settings?.model || {};
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
return lines.join("\n");
}
async function hermesQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function hermesReset() {
const result = await api.resetCliToolSettings("hermes");
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showHermesMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "⚡ Hermes Agent Settings",
breadcrumb,
headerContent: buildHermesHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
]
});
}
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
/**
* Main CLI Tools menu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCliToolsMenu(port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: "🔧 CLI Tools",
breadcrumb,
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
items: [
{
label: "Claude Code",
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
},
{
label: "Codex CLI",
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
},
{
label: "Factory Droid",
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
},
{
label: "Open Claw",
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
},
{
label: "OpenCode",
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
},
{
label: "Hermes",
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
}
]
});
}
module.exports = { showCliToolsMenu };
-8
View File
@@ -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 () => {
@@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
);
}
const regularTools = Object.entries(CLI_TOOLS);
const mitmTools = Object.entries(MITM_TOOLS);
@@ -47,7 +12,7 @@ export default function CLIToolsPageClient({ machineId }) {
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-1 sm:px-0">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{regularTools.map(([toolId, tool]) => (
<ToolSummaryCard key={toolId} toolId={toolId} tool={tool} status={toolStatuses[toolId]} />
<ToolSummaryCard key={toolId} toolId={toolId} tool={tool} />
))}
</div>
<div className="flex flex-col gap-3 sm:gap-4">
@@ -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 <ClaudeToolCard {...commonProps} activeProviders={getActiveProviders()} modelMappings={modelMappings[toolId] || {}} onModelMappingChange={(a, t) => handleModelMappingChange(toolId, a, t)} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "codex":
return <CodexToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "opencode":
return <OpenCodeToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "cowork":
return <CoworkToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} cloudUrl={CLOUD_URL} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} />;
case "droid":
return <DroidToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "openclaw":
return <OpenClawToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "hermes":
return <HermesToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "copilot":
return <CopilotToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "cline":
return <ClineToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "kilo":
return <KiloToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "deepseek-tui":
return <DeepSeekTuiToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "jcode":
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
default:
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
}
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
return <ConfigGeneratorCard {...commonProps} />;
};
// Guard removed/unknown tools (e.g. disabled Cowork) to avoid crash on direct URL.
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingClaude && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude CLI...</span>
</div>
)}
{!checkingClaude && claudeStatus && !claudeStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @anthropic-ai/claude-code</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checkingClaude && claudeStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{claudeStatus.settings.env.ANTHROPIC_BASE_URL}
</span>
</div>
)}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Model Mappings */}
{tool.defaultModels.map((model) => (
<div key={model.alias} className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">{model.name}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={modelMappings[model.alias] || ""} onChange={(e) => 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] && <button onClick={() => onModelMappingChange(model.alias, "")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => openModelSelector(model.alias)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
))}
{/* CC Filter Naming */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Filter naming</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input type="checkbox" checked={ccFilterNaming} onChange={handleCcFilterNamingToggle} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
<span className="text-xs text-text-muted">Filter naming requests</span>
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
</Tooltip>
</label>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!hasActiveProviders} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude CLI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Cline...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Cline not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<p className="text-text-muted">Install Cline VS Code extension or CLI from <a className="text-primary underline" href="https://docs.cline.bot/" target="_blank" rel="noreferrer">docs.cline.bot</a>.</p>
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{status?.settings?.openAiBaseUrl && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.settings.openAiBaseUrl}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Cline"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingCodex && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Codex CLI...</span>
</div>
)}
{!checkingCodex && codexStatus && !codexStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Codex CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @openai/codex</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to verify.</p>
<div className="pt-2 border-t border-border">
<p className="text-text-muted text-xs">
Codex uses <code className="px-1 bg-black/5 dark:bg-white/5 rounded">~/.codex/auth.json</code> with <code className="px-1 bg-black/5 dark:bg-white/5 rounded">OPENAI_API_KEY</code>.
Click &quot;Apply&quot; to auto-configure.
</p>
</div>
</div>
</div>
)}
</div>
)}
{!checkingCodex && codexStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{codexStatus?.config && (() => {
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentBaseUrl = parsed ? parsed[1] : null;
return currentBaseUrl ? (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{currentBaseUrl}
</span>
</div>
) : null;
})()}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Model */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
{/* Subagent Model */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Subagent Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={subagentModel}
onChange={(e) => 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 && (
<button
onClick={() => setSubagentModel("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (will use main model)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => setSubagentModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
/>
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for Codex"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Codex CLI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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 ? "<API_KEY_FROM_DASHBOARD>" : "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 (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-start gap-3 sm:items-center">
<div className="size-9 shrink-0">
<Image src={tool.image} alt={tool.name} width={36} height={36} className="size-9 rounded-lg object-contain" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium text-text-main">{tool.name}</h3>
<p className="text-xs text-text-muted">Generate a configuration file to copy to your own machine.</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-4 border-t border-border pt-4">
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
Endpoint
<BaseUrlSelect
value={customBaseUrl || baseUrl}
onChange={setCustomBaseUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
/>
</label>
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
API key
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</label>
{toolId === "claude" ? (
<div className="flex flex-col gap-3">
<span className="text-xs font-medium text-text-muted">Default Claude models</span>
{[
{ 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 (
<div key={slot} className="flex flex-col gap-1.5">
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
<span>{label} <code className="font-normal">({envKey})</code></span>
<div className="flex gap-2">
<input
type="text"
readOnly
value={claudeModels[slot]}
placeholder={DEFAULT_MODEL}
aria-label={`Default ${label} model`}
onClick={() => openClaudeModelSelector(slot)}
className="min-w-0 flex-1 cursor-pointer rounded-lg border border-border bg-bg-secondary px-3 py-2 text-xs text-text-main outline-none transition-colors hover:border-primary/60 focus:border-primary"
/>
<Button type="button" variant="secondary" size="sm" onClick={() => openClaudeModelSelector(slot)}>Select</Button>
{claudeModels[slot] && <Button type="button" variant="ghost" size="sm" onClick={() => { setClaudeModels((current) => ({ ...current, [slot]: "" })); setClaudeThinking((current) => ({ ...current, [slot]: "" })); }}>Clear</Button>}
</div>
</label>
{thinkingLevels && (
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking
<select
value={claudeThinking[slot]}
onChange={(event) => setClaudeThinking((current) => ({ ...current, [slot]: event.target.value }))}
className="min-w-36 rounded-lg border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
<option value="">Default</option>
{thinkingLevels.map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
</select>
</label>
)}
</div>
);
})}
<p className="text-xs text-text-muted">Choose a model for each Claude Code alias. Empty fields use <code>{DEFAULT_MODEL}</code> as a placeholder.</p>
</div>
) : toolId === "codex" ? (
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
Default model <code className="font-normal">(model)</code>
<div className="flex gap-2">
<input
type="text"
readOnly
value={codexModel}
placeholder={DEFAULT_MODEL}
aria-label="Default Codex model"
onClick={() => setModelModalOpen(true)}
className="min-w-0 flex-1 cursor-pointer rounded-lg border border-border bg-bg-secondary px-3 py-2 text-xs text-text-main outline-none transition-colors hover:border-primary/60 focus:border-primary"
/>
<Button type="button" variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>Select</Button>
{codexModel && <Button type="button" variant="ghost" size="sm" onClick={() => { setCodexModel(""); setCodexThinking(""); }}>Clear</Button>}
</div>
</label>
{getThinkingLevelsForModel(codexModel) && (
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking
<select
value={codexThinking}
onChange={(event) => setCodexThinking(event.target.value)}
className="min-w-36 rounded-lg border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
<option value="">Default</option>
{getThinkingLevelsForModel(codexModel).map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
</select>
</label>
)}
<p className="text-xs text-text-muted">The selected reasoning level is appended to the model ID, for example <code>cx/gpt-5.6-sol(high)</code>.</p>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-text-muted">Models</span>
<Button variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>
<span className="material-symbols-outlined mr-1 text-[16px]">add</span>
Add model
</Button>
</div>
{selectedModels.length ? (
<div className="flex flex-wrap gap-2">
{selectedModels.map((model) => (
<button key={model} type="button" onClick={() => setSelectedModels((current) => current.filter((item) => item !== model))} className="inline-flex items-center gap-1 rounded-full border border-border bg-bg-secondary px-2 py-1 text-xs text-text-main hover:border-red-500/50" title="Remove model">
{model}<span className="material-symbols-outlined text-[14px]">close</span>
</button>
))}
</div>
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
</div>
)}
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto sm:self-start">
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
Show configuration file
</Button>
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
</div>
<ModelSelectModal
isOpen={modelModalOpen}
onClose={() => { 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}
/>
<ManualConfigModal isOpen={configModalOpen} onClose={() => setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} />
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Copilot config...</span>
</div>
)}
{!checking && (
<>
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
<div className="text-xs text-blue-700 dark:text-blue-300">
<p className="font-medium">Writes to <code className="px-1 bg-black/5 dark:bg-white/10 rounded">chatLanguageModels.json</code></p>
<p className="mt-0.5 opacity-80">Reload VS Code after applying for changes to take effect.</p>
</div>
</div>
<div className="flex flex-col gap-2">
{/* Endpoint */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((model) => (
<span key={model} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
{model}
<button onClick={(e) => { e.stopPropagation(); removeModel(model); }} className="ml-0.5 hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
</div>
</div>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} disabled={selectedModels.length === 0}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
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"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="GitHub Copilot - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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/<appliedId>.json",
content: JSON.stringify(cfg, null, 2),
}];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude Cowork...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude Desktop (Cowork mode) not detected</p>
<p className="text-sm text-text-muted">Open Claude Desktop Help Troubleshooting Enable Developer mode Configure third-party inference, then return here.</p>
</div>
</div>
<div className="pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={getEffectiveBaseUrl()}
onChange={(url) => setCustomBaseUrl(stripV1(url))}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={cloudUrl}
/>
</div>
{status?.cowork?.baseUrl && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.cowork.baseUrl}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<div className="flex-1 flex items-center gap-2">
<div className="flex-1 flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((m) => (
<span key={m} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
{m}
<button onClick={() => handleRemoveModel(m)} className="ml-0.5 hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Combo</button>
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-2">MCP</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-2">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1">
{/* Preset plugins */}
{plugins.filter((p) => p.name !== "exa").map((p) => (
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.title || p.name}</span>
{p.oauth && <span className="text-[8px] text-amber-600 shrink-0">OAuth</span>}
<div className="flex-1 flex flex-wrap gap-1 overflow-hidden" style={{ maxHeight: "1.5rem" }}>
{Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => (
<span key={t} className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">{t}</span>
))}
{Array.isArray(p.toolNames) && p.toolNames.length > 6 && (
<span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">+{p.toolNames.length - 6}</span>
)}
</div>
<button onClick={() => removePlugin(p.name)} className="shrink-0 hover:text-red-500 ml-auto">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
{/* Custom plugins */}
{customPlugins.map((p) => (
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url}</span>
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
{plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && (
<div className="px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-muted">No MCPs added</div>
)}
{/* Actions row */}
<div className="flex items-center gap-2 mt-0.5">
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
+ Browse
</button>
<button onClick={() => { setAddMcpForm({ name: "", url: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
+ Custom
</button>
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs </a>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Tools</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1.5">
{(() => {
const exaEnabled = plugins.some((p) => p.name === "exa");
const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa");
return (
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
<input
type="checkbox"
checked={exaEnabled}
onChange={(e) => {
if (e.target.checked && exaDef) setPlugins([...plugins.filter((p) => p.name !== "exa"), exaDef]);
else setPlugins(plugins.filter((p) => p.name !== "exa"));
}}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Web Search & Fetch (Exa)</div>
<p className="text-[10px] text-text-muted leading-snug">Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.</p>
</div>
</label>
);
})()}
{(() => {
const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp");
if (!browserDef) return null;
const browserEnabled = localPlugins.includes("browsermcp");
return (
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
<input
type="checkbox"
checked={browserEnabled}
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, "browsermcp"] : localPlugins.filter((n) => n !== "browsermcp"))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Browser Control (Browser MCP)</div>
<p className="text-[10px] text-text-muted leading-snug">
Controls your running Chrome. Auto-strips Cowork&apos;s built-in browser tools.{" "}
<a href={browserDef.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">Install Chrome extension</a>
</p>
</div>
</label>
);
})()}
</div>
</div>
{Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Local Plugins</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-col gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
{status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => {
const enabled = localPlugins.includes(p.name);
return (
<label key={p.name} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, p.name] : localPlugins.filter((n) => n !== p.name))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium">{p.title}</span>
<span className="text-[8px] text-amber-600">stdio</span>
</div>
<p className="text-[10px] text-text-muted leading-snug">{p.description}</p>
{p.extensionUrl && (
<a href={p.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-[10px] text-primary underline">Install Chrome extension</a>
)}
</div>
</label>
);
})}
</div>
<p className="text-[10px] text-text-muted leading-snug">
Local plugins run as subprocess via <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">npx</code>. Requires Node.js installed.
</p>
</div>
</div>
)}
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude Cowork - Manual Configuration"
configs={getManualConfigs()}
/>
<ComboFormModal
isOpen={comboModalOpen}
combo={null}
onClose={() => setComboModalOpen(false)}
onSave={handleCreateCombo}
activeProviders={activeProviders}
forcePrefix="claude-"
title="Create Cowork Combo"
/>
<ModelSelectModal
isOpen={modelSelectOpen}
onClose={() => setModelSelectOpen(false)}
onSelect={handleAddModel}
onDeselect={handleRemoveModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Cowork Model"
addedModelValues={selectedModels}
closeOnSelect={false}
/>
<McpMarketplaceModal
isOpen={marketplaceOpen}
onClose={() => setMarketplaceOpen(false)}
onAdd={addPlugin}
addedNames={plugins.map((p) => p.name)}
/>
{/* Add Custom MCP modal */}
{addMcpOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setAddMcpOpen(false)}>
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Add Custom MCP</h3>
<button onClick={() => setAddMcpOpen(false)} className="text-text-muted hover:text-text-main">
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">Name</label>
<input
type="text"
placeholder="my-mcp"
value={addMcpForm.name}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
<input
type="text"
placeholder="https://your-mcp-server.com/sse"
value={addMcpForm.url}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setAddMcpOpen(false)} className="px-3 py-1.5 rounded border border-border text-xs text-text-muted hover:bg-surface cursor-pointer">Cancel</button>
<button
onClick={() => {
const name = addMcpForm.name.trim();
if (!name || !addMcpForm.url.trim()) return;
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
setAddMcpOpen(false);
}}
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
>Add</button>
</div>
</div>
</div>
)}
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
const tomlContent = `[providers.openai]
base_url = "${getEffectiveBaseUrl()}"
api_key = "${keyToUse}"
model = "${selectedModel || "provider/model-id"}"
`;
return [
{ filename: "~/.deepseek/config.toml", content: tomlContent },
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image || "/providers/deepseek-tui.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking DeepSeek TUI...</span>
</div>
)}
{!checking && deepseekStatus && !deepseekStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">DeepSeek TUI not detected locally</p>
<p className="text-sm text-text-muted mt-1">Install via npm:</p>
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">npm install -g deepseek-tui</code>
<p className="text-sm text-text-muted mt-2">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checking && deepseekStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{tool.notes && tool.notes.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{tool.notes.map((note, idx) => (
<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
note.type === "error" ? "bg-red-500/10 text-red-600 dark:text-red-400" :
"bg-blue-500/10 text-blue-600 dark:text-blue-400"
}`}>
<span className="material-symbols-outlined text-[14px] mt-0.5">
{note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"}
</span>
<span>{note.text}</span>
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{deepseekStatus?.settings?.["providers.openai"]?.base_url && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{deepseekStatus.settings["providers.openai"].base_url}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!deepseekStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for DeepSeek TUI"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="DeepSeek TUI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/droid.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingDroid && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Factory Droid CLI...</span>
</div>
)}
{!checkingDroid && droidStatus && !droidStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Factory Droid CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">curl -fsSL https://app.factory.ai/cli | sh</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">droid</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checkingDroid && droidStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
</span>
</div>
)}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">
Models {modelList.length > 0 && <span className="text-primary">({modelList.length})</span>}
</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1">
{/* Model list */}
{modelList.length > 0 && (
<div className="flex flex-col gap-0.5 mb-1">
{modelList.map((id) => (
<div key={id} className="flex items-center gap-1.5 px-2 py-1 bg-bg-secondary rounded border border-border">
<span className="flex-1 text-xs font-mono truncate">{id}</span>
<button onClick={() => removeModel(id)} className="text-text-muted hover:text-red-500 transition-colors shrink-0" title="Remove">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
</div>
)}
{/* Model input row */}
<div className="flex items-center gap-1.5">
<input
type="text"
value={modelInput}
onChange={(e) => 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"
/>
<button
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs shrink-0 ${hasActiveProviders ? "bg-surface border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
<button onClick={addModel} disabled={!modelInput.trim()} className="px-2 py-1.5 rounded border bg-surface border-border hover:border-primary text-xs shrink-0 disabled:opacity-50" title="Add model">
<span className="material-symbols-outlined text-[14px]">add</span>
</button>
</div>
</div>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={modelList.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Factory Droid - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/hermes.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Hermes Agent...</span>
</div>
)}
{!checking && hermesStatus && !hermesStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Hermes Agent not detected locally</p>
<p className="text-sm text-text-muted">Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash</p>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-0 sm:pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto !bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checking && hermesStatus?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{hermesStatus?.settings?.model?.base_url && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{hermesStatus.settings.model.base_url}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!hermesStatus?.has9Router} loading={restoring} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Hermes Agent"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Hermes Agent - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image || "/providers/jcode.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingJcode && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking jcode CLI...</span>
</div>
)}
{!checkingJcode && jcodeStatus && !jcodeStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">jcode CLI not detected locally</p>
<p className="text-sm text-text-muted mt-1">Install jcode to enable automatic configuration:</p>
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
</code>
<p className="text-sm text-text-muted mt-2">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checkingJcode && jcodeStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Info notes */}
{tool.notes && tool.notes.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{tool.notes.map((note, idx) => (
<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${
note.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
"bg-gray-500/10 text-text-muted"
}`}>
<span className="material-symbols-outlined text-[14px] mt-0.5">
{note.type === "info" ? "info" : note.type === "warning" ? "warning" : "help"}
</span>
<span>{note.text}</span>
</div>
))}
</div>
)}
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{jcodeStatus?.config?.providers?.["9router"]?.base_url && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{jcodeStatus.config.providers["9router"].base_url}
</span>
</div>
)}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Default Model */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
{/* Usage hint */}
<div className="flex flex-col gap-1 p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
<p className="text-xs font-medium text-blue-600 dark:text-blue-400">Usage:</p>
<code className="text-xs font-mono text-text-muted">jcode --provider-profile 9router</code>
<code className="text-xs font-mono text-text-muted">jcode --provider-profile 9router --model {selectedModel || "cc/claude-opus-4-7"}</code>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!jcodeStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for jcode"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="jcode - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Kilo Code...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Kilo Code not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<p className="text-sm text-text-muted">Install Kilo Code from <a className="text-primary underline" href="https://kilocode.ai" target="_blank" rel="noreferrer">kilocode.ai</a> or VS Code extension marketplace.</p>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Kilo Code"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/openclaw.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingOpenclaw && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Open Claw CLI...</span>
</div>
)}
{!checkingOpenclaw && openclawStatus && !openclawStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Open Claw CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checkingOpenclaw && openclawStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{openclawStatus.settings.models.providers["9router"].baseUrl}
</span>
</div>
)}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Default Model */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => 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 && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => { setAgentModalFor(null); setModalOpen(true); }} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
{/* Per-agent model overrides */}
{(openclawStatus.agents || []).filter(a => a.agentDir).map((agent) => (
<div key={agent.id} className="flex items-center gap-2 pl-4">
<span className="w-32 shrink-0 text-xs text-primary text-right truncate" title={agent.name || agent.id}>Agent {agent.name || agent.id}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={agentModels[agent.id] || ""}
onChange={(e) => 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] && <button onClick={() => setAgentModels(prev => ({ ...prev, [agent.id]: "" }))} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => { setAgentModalFor(agent.id); setModalOpen(true); }} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
))}
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!openclawStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Open Claw"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Open Claw - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
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 (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/opencode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking OpenCode CLI...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">OpenCode CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g opencode-ai</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">opencode</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Current base URL */}
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{status?.config?.provider?.["9router"]?.options?.baseURL && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.config.provider["9router"].options.baseURL}
</span>
</div>
)}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((model) => (
<span
key={model}
onClick={async () => {
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 && <span className="material-symbols-outlined text-[10px]">star</span>}
{model}
<button
onClick={async (e) => {
e.stopPropagation();
try {
const res = await fetch(`/api/cli-tools/opencode-settings?model=${encodeURIComponent(model)}`, { method: "DELETE" });
if (res.ok) {
const newModels = selectedModels.filter((m) => m !== model);
setSelectedModels(newModels);
if (activeModel === model) {
setActiveModel("");
}
checkStatus();
}
} catch (error) {
console.log("Error removing model:", error);
}
}}
className="ml-0.5 hover:text-red-500"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
<span className="text-xs text-text-muted">
{selectedModels.length > 0 && activeModel ? (
<>Active: <span className="text-primary">{activeModel}</span></>
) : selectedModels.length > 0 ? (
<span className="text-yellow-500">Click a model to set/clear active</span>
) : (
"Select models to add"
)}
</span>
</div>
</div>
</div>
{/* Subagent Model */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Subagent Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<input
type="text"
value={subagentModel}
onChange={(e) => 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"
/>
<button
onClick={() => setSubagentModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
</button>
{subagentModel && (
<button
onClick={() => setSubagentModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (will use main model)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
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"
/>
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for OpenCode"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="OpenCode - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -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 (
<Link href={`/dashboard/cli-tools/${toolId}`} className="block">
<Card padding="sm" className="h-full overflow-hidden hover:border-primary/50 transition-colors cursor-pointer">
@@ -28,7 +19,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
<span className={`inline-block mt-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full ${s.cls}`}>{s.label}</span>
<p className="mt-1 truncate text-xs text-text-muted">Generate a copyable configuration file</p>
</div>
<span className="material-symbols-outlined text-text-muted text-[18px] shrink-0">chevron_right</span>
</div>
@@ -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;
}
@@ -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";
@@ -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));
}
@@ -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 }
);
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 }
);
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
+6 -11
View File
@@ -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 });
}
}
+45 -3
View File
@@ -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,
}),
})),
};
+6 -7
View File
@@ -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 () => {