diff --git a/public/providers/codewhale.svg b/public/providers/codewhale.svg new file mode 100644 index 00000000..412e4c14 --- /dev/null +++ b/public/providers/codewhale.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/providers/crush.png b/public/providers/crush.png new file mode 100644 index 00000000..c893222e Binary files /dev/null and b/public/providers/crush.png differ diff --git a/public/providers/forge.png b/public/providers/forge.png new file mode 100644 index 00000000..056562ac Binary files /dev/null and b/public/providers/forge.png differ diff --git a/public/providers/omp.png b/public/providers/omp.png new file mode 100644 index 00000000..9da36e29 Binary files /dev/null and b/public/providers/omp.png differ diff --git a/public/providers/pi.svg b/public/providers/pi.svg new file mode 100644 index 00000000..e2851685 --- /dev/null +++ b/public/providers/pi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/providers/smelt.svg b/public/providers/smelt.svg new file mode 100644 index 00000000..917aea73 --- /dev/null +++ b/public/providers/smelt.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index 82211e48..f5df083a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -9,7 +9,7 @@ import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, - JcodeToolCard, GrokBuildToolCard, + JcodeToolCard, GrokBuildToolCard, GenericCliToolCard, } from "../components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -166,6 +166,13 @@ export default function ToolDetailClient({ toolId, machineId }) { return ; case "grok-build": return ; + case "pi": + case "omp": + case "crush": + case "forge": + case "smelt": + case "codewhale": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GenericCliToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GenericCliToolCard.js new file mode 100644 index 00000000..76b95eec --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/GenericCliToolCard.js @@ -0,0 +1,636 @@ +"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 { rememberEndpoint } from "./cliEndpointPresets"; +import ApiKeySelect from "./ApiKeySelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; +import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; + +export default function GenericCliToolCard({ + 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(() => apiKeys?.[0]?.key || ""); + const [selectedModel, setSelectedModel] = useState(() => { + const cfg = initialStatus?.config; + return cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id || ""; + }); + const [selectedModels, setSelectedModels] = useState(() => { + const cfg = initialStatus?.config; + const list = cfg?.providers?.["9router"]?.models; + if (Array.isArray(list) && list.length > 0) { + return list.map((m) => (typeof m === "string" ? m : m.id)); + } + return []; + }); + const [modalOpen, setModalOpen] = useState(false); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + + const endpointUrl = `/api/cli-tools/${tool.id}-settings`; + + useEffect(() => { + let active = true; + if (isExpanded && !initialStatus) { + fetch(endpointUrl) + .then((res) => res.json()) + .then((data) => { + if (active) { + setStatus(data); + const cfg = data?.config; + if (tool.id === "pi") { + const list = cfg?.providers?.["9router"]?.models; + if (Array.isArray(list) && list.length > 0) { + const ids = list.map((m) => (typeof m === "string" ? m : m.id)); + setSelectedModels(ids); + } + } else { + const mod = cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id; + if (mod) setSelectedModel((prev) => prev || mod); + } + } + }) + .catch((error) => { + if (active) setStatus({ installed: false, error: error.message }); + }); + } + return () => { + active = false; + }; + }, [isExpanded, initialStatus, endpointUrl, tool.id]); + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(endpointUrl); + const data = await res.json(); + setStatus(data); + const cfg = data?.config; + if (tool.id === "pi") { + const list = cfg?.providers?.["9router"]?.models; + if (Array.isArray(list) && list.length > 0) { + const ids = list.map((m) => (typeof m === "string" ? m : m.id)); + setSelectedModels(ids); + } + } else { + const mod = cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id; + if (mod && !selectedModel) setSelectedModel(mod); + } + } catch (error) { + setStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || `${baseUrl}/v1`; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getCurrentBaseUrl = () => { + if (!status?.config) return ""; + const cfg = status.config; + if (typeof cfg.baseUrl === "string") return cfg.baseUrl; + if (typeof cfg.openai?.base_url === "string") return cfg.openai.base_url; + if (typeof cfg.providers?.["9router"]?.base_url === "string") return cfg.providers["9router"].base_url; + if (typeof cfg.providers?.["9router"]?.baseUrl === "string") return cfg.providers["9router"].baseUrl; + return ""; + }; + + const currentBaseUrl = getCurrentBaseUrl(); + + const getConfigStatus = () => { + if (!status?.installed) return null; + if (!status.has9Router) return "not_configured"; + if (currentBaseUrl && matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl })) { + return "configured"; + } + return "configured"; + }; + + const configStatus = getConfigStatus(); + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : selectedApiKey); + + const payload = { + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + }; + + if (tool.id === "pi") { + payload.models = selectedModels.length > 0 ? selectedModels : ["provider/model-id"]; + } else { + payload.model = selectedModel; + } + + const res = await fetch(endpointUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (res.ok) { + rememberEndpoint(getEffectiveBaseUrl()); + setMessage({ type: "success", text: data.message || "Settings applied successfully!" }); + await checkStatus(); + } else { + setMessage({ type: "error", text: data.error?.message || "Failed to apply settings." }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleRestore = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(endpointUrl, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Settings removed successfully." }); + await checkStatus(); + } else { + setMessage({ type: "error", text: data.error?.message || "Failed to reset settings." }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleSelectModel = (model) => { + if (tool.id === "pi") { + if (!selectedModels.includes(model.value)) { + setSelectedModels((prev) => [...prev, model.value]); + } + } else { + setSelectedModel(model.value); + } + setModalOpen(false); + }; + + const handleAddAllActiveModels = () => { + const allModels = []; + activeProviders.forEach((conn) => { + const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; + const providerModels = getModelsByProviderId(conn.provider); + providerModels.forEach((m) => { + const val = `${alias}/${m.id}`; + if (!allModels.includes(val)) allModels.push(val); + }); + }); + if (allModels.length > 0) { + setSelectedModels((prev) => Array.from(new Set([...prev, ...allModels]))); + } + }; + + const handleRemoveModel = (modelToRemove) => { + setSelectedModels((prev) => prev.filter((m) => m !== modelToRemove)); + }; + + const getInstallCommand = () => { + switch (tool.id) { + case "pi": + return "curl -fsSL https://pi.dev/install.sh | sh # or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent"; + case "omp": + return "npm install -g oh-my-pi"; + case "crush": + return "brew install charmbracelet/tap/crush # or go install github.com/charmbracelet/crush@latest"; + case "forge": + return "cargo install forgecode"; + case "smelt": + return "cargo install smelt"; + case "codewhale": + return "cargo install codewhale"; + default: + return `npm install -g ${tool.id}`; + } + }; + + const getManualConfigContent = () => { + const effectiveUrl = getEffectiveBaseUrl(); + const key = selectedApiKey || "sk_9router"; + const mod = selectedModel || "provider/model-id"; + + switch (tool.id) { + case "pi": { + const modelsList = selectedModels.length > 0 ? selectedModels : [mod]; + return [ + { + filename: "~/.pi/agent/models.json", + content: JSON.stringify( + { + providers: { + "9router": { + baseUrl: effectiveUrl, + apiKey: key, + api: "openai-completions", + models: modelsList.map((id) => ({ + id, + name: id, + contextWindow: 128000, + maxTokens: 16384, + })), + }, + }, + }, + null, + 2 + ), + }, + ]; + } + case "omp": + return [ + { + filename: "~/.omp/agent/models.yml", + content: `providers:\n 9router:\n baseUrl: ${effectiveUrl}\n apiKey: ${key}\n api: openai-completions\n authHeader: true\n disableStrictTools: true\n discovery:\n type: proxy`, + }, + ]; + case "crush": + return [ + { + filename: "~/.config/crush/crush.json", + content: JSON.stringify( + { + providers: { + "9router": { + type: "openai-compat", + base_url: effectiveUrl, + api_key: key, + models: [{ id: mod, name: mod, context_window: 128000 }], + }, + }, + }, + null, + 2 + ), + }, + ]; + case "forge": + return [ + { + filename: "~/.forge/config.toml", + content: `# Forge config — managed by 9Router\n\n[openai]\napi_key = "${key}"\nbase_url = "${effectiveUrl}"\nmodel = "${mod}"`, + }, + ]; + case "smelt": + return [ + { + filename: "~/.smelt/config.json", + content: JSON.stringify({ baseUrl: effectiveUrl, apiKey: key, model: mod, _managedBy: "9router" }, null, 2), + }, + ]; + case "codewhale": + return [ + { + filename: "~/.codewhale/config.toml", + content: `# CodeWhale config — managed by 9Router\n\n[openai]\nbase_url = "${effectiveUrl}"\napi_key = "${key}"\nmodel = "${mod}"`, + }, + ]; + default: + return []; + } + }; + + return ( + + {/* Header clickable */} +
+
+
+ {tool.image ? ( + {tool.name} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" + /> + ) : tool.icon ? ( + + {tool.icon} + + ) : ( + terminal + )} +
+
+
+

{tool.name}

+ {configStatus === "configured" && ( + + Connected + + )} + {configStatus === "not_configured" && ( + + Not configured + + )} + {configStatus === "other" && ( + + Other + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking {tool.name}... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

{tool.name} not detected locally

+

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

+
+
+
+ + +
+
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

Install command:

+ {getInstallCommand()} +
+ {tool.docsUrl && ( +

+ Docs: {tool.docsUrl} +

+ )} +
+
+ )} +
+ )} + + {!checking && status?.installed && ( + <> +
+ {/* Endpoint (selector) */} +
+ Select Endpoint + arrow_forward + +
+ + {/* Current configured */} + {currentBaseUrl ? ( +
+ Current + arrow_forward + + {currentBaseUrl} + +
+ ) : null} + + {/* API Key */} +
+ API Key + arrow_forward + +
+ + {/* Models selector cho Pi (multi-models) */} + {tool.id === "pi" && ( +
+ Models + arrow_forward +
+
+ {selectedModels.length === 0 ? ( + No models selected. Add models to use in Pi. + ) : ( + selectedModels.map((modelId) => ( + + {modelId} + + + )) + )} +
+
+ + {activeProviders?.length > 0 && ( + + )} + {selectedModels.length > 0 && ( + + )} +
+
+
+ )} + + {/* Model (1 model cho các tool khác, trừ omp) */} + {tool.id !== "omp" && tool.id !== "pi" && ( +
+ Model + arrow_forward +
+ setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+ )} +
+ + {/* Messages */} + {message && ( +
+ {message.text} +
+ )} + + {/* Action Buttons */} +
+
+ + {status?.has9Router && ( + + )} +
+ +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleSelectModel} + activeProviders={activeProviders} + /> + + setShowManualConfigModal(false)} + title={`${tool.name} Configuration`} + configs={getManualConfigContent()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index e1399677..8a1465ba 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -13,6 +13,7 @@ export { default as KiloToolCard } from "./KiloToolCard"; export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; export { default as JcodeToolCard } from "./JcodeToolCard"; export { default as GrokBuildToolCard } from "./GrokBuildToolCard"; +export { default as GenericCliToolCard } from "./GenericCliToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index b4270163..0f4ca8d5 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -14,6 +14,12 @@ import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route"; import { GET as grokBuildGet } from "../grok-build-settings/route"; import { GET as devinGet } from "../devin-settings/route"; +import { GET as piGet } from "../pi-settings/route"; +import { GET as ompGet } from "../omp-settings/route"; +import { GET as crushGet } from "../crush-settings/route"; +import { GET as forgeGet } from "../forge-settings/route"; +import { GET as smeltGet } from "../smelt-settings/route"; +import { GET as codewhaleGet } from "../codewhale-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -29,6 +35,12 @@ const STATUS_GETTERS = { jcode: jcodeGet, "grok-build": grokBuildGet, devin: devinGet, + pi: piGet, + omp: ompGet, + crush: crushGet, + forge: forgeGet, + smelt: smeltGet, + codewhale: codewhaleGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/codewhale-settings/route.js b/src/app/api/cli-tools/codewhale-settings/route.js new file mode 100644 index 00000000..f561dc06 --- /dev/null +++ b/src/app/api/cli-tools/codewhale-settings/route.js @@ -0,0 +1,142 @@ +"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"; + +const execAsync = promisify(exec); + +const getCodewhaleDir = () => path.join(os.homedir(), ".codewhale"); +const getCodewhaleConfigPath = () => path.join(getCodewhaleDir(), "config.toml"); + +const checkCodewhaleInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where codewhale" : "which codewhale"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getCodewhaleConfigPath()); + return true; + } catch { + return false; + } + } +}; + +const has9RouterConfig = (content) => { + if (!content) return false; + return content.includes("managed by 9Router") || content.includes("localhost:20128"); +}; + +const readConfig = async () => { + try { + return await fs.readFile(getCodewhaleConfigPath(), "utf-8"); + } catch { + return null; + } +}; + +export async function GET() { + try { + const installed = await checkCodewhaleInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "CodeWhale CLI is not installed", + }); + } + + const content = await readConfig(); + let config = null; + try { + if (content) config = parseTOML(content); + } catch {} + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(content), + configPath: getCodewhaleConfigPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const { baseUrl, apiKey, model } = rawBody || {}; + if (!baseUrl) { + return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); + } + + const configPath = getCodewhaleConfigPath(); + await fs.mkdir(getCodewhaleDir(), { recursive: true }); + + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = parseTOML(raw); + } catch {} + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + existing.openai = { + base_url: normalizedBaseUrl, + api_key: apiKey || "sk_9router", + model: model || "provider/model-id", + }; + + const header = "# CodeWhale config — managed by 9Router\n\n"; + const content = header + stringifyTOML(existing); + + await fs.writeFile(configPath, content, "utf-8"); + + return NextResponse.json({ + success: true, + message: "CodeWhale settings applied successfully!", + configPath, + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getCodewhaleConfigPath(); + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = parseTOML(raw); + } catch { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + + delete existing.openai; + + if (Object.keys(existing).length === 0) { + await fs.rm(configPath, { force: true }); + } else { + await fs.writeFile(configPath, stringifyTOML(existing), "utf-8"); + } + + return NextResponse.json({ success: true, message: "9Router removed from CodeWhale" }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/crush-settings/route.js b/src/app/api/cli-tools/crush-settings/route.js new file mode 100644 index 00000000..dd96d948 --- /dev/null +++ b/src/app/api/cli-tools/crush-settings/route.js @@ -0,0 +1,154 @@ +"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"; + +const execAsync = promisify(exec); + +const getCrushConfigPath = () => { + const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configDir, "crush", "crush.json"); +}; + +const getCrushDir = () => path.dirname(getCrushConfigPath()); + +const checkCrushInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where crush" : "which crush"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getCrushConfigPath()); + return true; + } catch { + return false; + } + } +}; + +const has9RouterConfig = (settings) => { + if (!settings || !settings.providers) return false; + const p = settings.providers["9router"]; + if (p && p.base_url) return true; + for (const prov of Object.values(settings.providers)) { + if (prov.base_url && prov.base_url.includes("20128")) return true; + } + return false; +}; + +const readConfig = async () => { + try { + const content = await fs.readFile(getCrushConfigPath(), "utf-8"); + return JSON.parse(content); + } catch { + return null; + } +}; + +export async function GET() { + try { + const installed = await checkCrushInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "Crush CLI is not installed", + }); + } + + const config = await readConfig(); + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(config), + configPath: getCrushConfigPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const { baseUrl, apiKey, model } = rawBody || {}; + if (!baseUrl) { + return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); + } + + const configPath = getCrushConfigPath(); + await fs.mkdir(getCrushDir(), { recursive: true }); + + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = JSON.parse(raw); + } catch { + /* No existing config */ + } + + if (!existing.providers) existing.providers = {}; + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const modelId = model || "provider/model-id"; + + existing.providers["9router"] = { + type: "openai-compat", + base_url: normalizedBaseUrl, + api_key: apiKey || "sk_9router", + models: [ + { + id: modelId, + name: modelId, + context_window: 128000, + }, + ], + }; + + await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8"); + + return NextResponse.json({ + success: true, + message: "Crush settings applied successfully!", + configPath, + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getCrushConfigPath(); + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = JSON.parse(raw); + } catch { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + + if (existing.providers && existing.providers["9router"]) { + delete existing.providers["9router"]; + if (Object.keys(existing.providers).length === 0) delete existing.providers; + await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8"); + } + + return NextResponse.json({ success: true, message: "9Router removed from Crush" }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/forge-settings/route.js b/src/app/api/cli-tools/forge-settings/route.js new file mode 100644 index 00000000..2f7c45dc --- /dev/null +++ b/src/app/api/cli-tools/forge-settings/route.js @@ -0,0 +1,142 @@ +"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"; + +const execAsync = promisify(exec); + +const getForgeDir = () => path.join(os.homedir(), ".forge"); +const getForgeConfigPath = () => path.join(getForgeDir(), "config.toml"); + +const checkForgeInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where forge" : "which forge"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getForgeConfigPath()); + return true; + } catch { + return false; + } + } +}; + +const has9RouterConfig = (content) => { + if (!content) return false; + return content.includes("managed by 9Router") || content.includes("localhost:20128"); +}; + +const readConfig = async () => { + try { + return await fs.readFile(getForgeConfigPath(), "utf-8"); + } catch { + return null; + } +}; + +export async function GET() { + try { + const installed = await checkForgeInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "ForgeCode CLI is not installed", + }); + } + + const content = await readConfig(); + let config = null; + try { + if (content) config = parseTOML(content); + } catch {} + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(content), + configPath: getForgeConfigPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const { baseUrl, apiKey, model } = rawBody || {}; + if (!baseUrl) { + return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); + } + + const configPath = getForgeConfigPath(); + await fs.mkdir(getForgeDir(), { recursive: true }); + + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = parseTOML(raw); + } catch {} + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + existing.openai = { + api_key: apiKey || "sk_9router", + base_url: normalizedBaseUrl, + model: model || "provider/model-id", + }; + + const header = "# Forge config — managed by 9Router\n\n"; + const content = header + stringifyTOML(existing); + + await fs.writeFile(configPath, content, "utf-8"); + + return NextResponse.json({ + success: true, + message: "ForgeCode settings applied successfully!", + configPath, + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getForgeConfigPath(); + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = parseTOML(raw); + } catch { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + + delete existing.openai; + + if (Object.keys(existing).length === 0) { + await fs.rm(configPath, { force: true }); + } else { + await fs.writeFile(configPath, stringifyTOML(existing), "utf-8"); + } + + return NextResponse.json({ success: true, message: "9Router removed from ForgeCode" }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/omp-settings/route.js b/src/app/api/cli-tools/omp-settings/route.js new file mode 100644 index 00000000..52df53e1 --- /dev/null +++ b/src/app/api/cli-tools/omp-settings/route.js @@ -0,0 +1,179 @@ +"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"; + +const execAsync = promisify(exec); + +const PROVIDER_ID = "9router"; +const getOmpDir = () => path.join(os.homedir(), ".omp", "agent"); +const getOmpDbPath = () => path.join(getOmpDir(), "agent.db"); +const getOmpModelsYmlPath = () => path.join(getOmpDir(), "models.yml"); + +const checkOmpInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where omp" : "which omp"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getOmpDbPath()); + return true; + } catch { + try { + await fs.access(getOmpModelsYmlPath()); + return true; + } catch { + return false; + } + } + } +}; + +const readModelsYml = async () => { + try { + return await fs.readFile(getOmpModelsYmlPath(), "utf-8"); + } catch { + return ""; + } +}; + +const has9RouterInYml = (content) => { + if (!content) return false; + return content.includes("9router:") || content.includes("localhost:20128"); +}; + +// Build standard 9Router provider block for models.yml +const buildOmpProviderYaml = (baseUrl, apiKey) => { + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const key = apiKey || "sk_9router"; + return ` ${PROVIDER_ID}: + baseUrl: ${normalizedBaseUrl} + apiKey: ${key} + api: openai-completions + authHeader: true + disableStrictTools: true + discovery: + type: proxy`; +}; + +export async function GET() { + try { + const installed = await checkOmpInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "Oh My Pi is not installed", + }); + } + + const ymlContent = await readModelsYml(); + const has9Router = has9RouterInYml(ymlContent); + + return NextResponse.json({ + installed: true, + has9Router, + configPath: getOmpModelsYmlPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const { baseUrl, apiKey } = rawBody || {}; + if (!baseUrl) { + return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); + } + + await fs.mkdir(getOmpDir(), { recursive: true }); + + let ymlContent = await readModelsYml(); + const providerBlock = buildOmpProviderYaml(baseUrl, apiKey); + + // Remove existing 9router provider if present + const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g"); + ymlContent = ymlContent.replace(regex, ""); + + if (!ymlContent.trim()) { + ymlContent = `providers:\n${providerBlock}\n`; + } else if (ymlContent.includes("providers:")) { + ymlContent = ymlContent.replace(/providers:/, `providers:\n${providerBlock}`); + } else { + ymlContent = `${ymlContent.trim()}\n\nproviders:\n${providerBlock}\n`; + } + + await fs.writeFile(getOmpModelsYmlPath(), ymlContent, "utf-8"); + + // Best-effort update to agent.db if better-sqlite3 or node:sqlite is present + try { + let Database; + try { + const mod = await import("better-sqlite3"); + Database = mod.default || mod; + } catch { + // fallback ignored + } + if (Database) { + const dbPath = getOmpDbPath(); + const db = new Database(dbPath); + db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(PROVIDER_ID); + db.prepare( + "INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?)" + ).run( + PROVIDER_ID, + "api_key", + JSON.stringify({ apiKey: apiKey || "sk_9router", baseUrl }), + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + ); + db.close(); + } + } catch { + // Non-critical: models.yml is primary + } + + return NextResponse.json({ + success: true, + message: "Oh My Pi settings applied! Run 'omp' and all 9Router models appear under 9router in /model.", + configPath: getOmpModelsYmlPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function DELETE() { + try { + let ymlContent = await readModelsYml(); + const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g"); + ymlContent = ymlContent.replace(regex, ""); + + if (ymlContent.trim() === "providers:") { + await fs.rm(getOmpModelsYmlPath(), { force: true }); + } else { + await fs.writeFile(getOmpModelsYmlPath(), ymlContent, "utf-8"); + } + + return NextResponse.json({ + success: true, + message: "9Router removed from Oh My Pi", + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/pi-settings/route.js b/src/app/api/cli-tools/pi-settings/route.js new file mode 100644 index 00000000..ee5c507b --- /dev/null +++ b/src/app/api/cli-tools/pi-settings/route.js @@ -0,0 +1,187 @@ +"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"; + +const execAsync = promisify(exec); + +const getPiModelsJsonPath = () => { + const agentPath = path.join(os.homedir(), ".pi", "agent", "models.json"); + return agentPath; +}; + +const getPiDir = () => path.dirname(getPiModelsJsonPath()); + +const checkPiInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where pi" : "which pi"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getPiModelsJsonPath()); + return true; + } catch { + try { + await fs.access(path.join(os.homedir(), ".pi", "models.json")); + return true; + } catch { + return false; + } + } + } +}; + +const has9RouterConfig = (settings) => { + if (!settings || !settings.providers) return false; + const p = settings.providers["9router"]; + if (p && p.baseUrl) return true; + for (const prov of Object.values(settings.providers)) { + if (prov.baseUrl && prov.baseUrl.includes("20128")) return true; + } + return false; +}; + +const resolveModelsJsonPath = async () => { + const agentPath = path.join(os.homedir(), ".pi", "agent", "models.json"); + const rootPath = path.join(os.homedir(), ".pi", "models.json"); + try { + await fs.access(agentPath); + return agentPath; + } catch { + try { + await fs.access(rootPath); + return rootPath; + } catch { + return agentPath; + } + } +}; + +const readConfig = async () => { + try { + const targetPath = await resolveModelsJsonPath(); + const content = await fs.readFile(targetPath, "utf-8"); + return JSON.parse(content); + } catch { + return null; + } +}; + +export async function GET() { + try { + const installed = await checkPiInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "Pi CLI is not installed", + }); + } + + const config = await readConfig(); + const configPath = await resolveModelsJsonPath(); + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(config), + configPath, + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const { baseUrl, apiKey, model } = rawBody || {}; + if (!baseUrl) { + return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); + } + + const configPath = await resolveModelsJsonPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = JSON.parse(raw); + } catch { + /* No existing config */ + } + + if (!existing.providers) existing.providers = {}; + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + let modelList = []; + if (Array.isArray(rawBody.models) && rawBody.models.length > 0) { + modelList = rawBody.models.map((m) => { + if (typeof m === "string") { + return { id: m, name: m, contextWindow: 128000, maxTokens: 16384 }; + } + return { + id: m.id || "provider/model-id", + name: m.name || m.id || "provider/model-id", + contextWindow: m.contextWindow || 128000, + maxTokens: m.maxTokens || 16384, + }; + }); + } else { + const modelId = model || "provider/model-id"; + modelList = [{ id: modelId, name: modelId, contextWindow: 128000, maxTokens: 16384 }]; + } + + existing.providers["9router"] = { + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "sk_9router", + api: "openai-completions", + models: modelList, + }; + + await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8"); + + return NextResponse.json({ + success: true, + message: "Pi settings applied! Use /model in Pi to select the 9Router model.", + configPath, + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = await resolveModelsJsonPath(); + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = JSON.parse(raw); + } catch { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + + if (existing.providers && existing.providers["9router"]) { + delete existing.providers["9router"]; + if (Object.keys(existing.providers).length === 0) delete existing.providers; + await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8"); + } + + return NextResponse.json({ success: true, message: "9Router removed from Pi" }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/smelt-settings/route.js b/src/app/api/cli-tools/smelt-settings/route.js new file mode 100644 index 00000000..6592d0d9 --- /dev/null +++ b/src/app/api/cli-tools/smelt-settings/route.js @@ -0,0 +1,142 @@ +"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"; + +const execAsync = promisify(exec); + +const getSmeltConfigPath = () => path.join(os.homedir(), ".smelt", "config.json"); +const getSmeltDir = () => path.dirname(getSmeltConfigPath()); + +const checkSmeltInstalled = async () => { + const isWindows = os.platform() === "win32"; + try { + const command = isWindows ? "where smelt" : "which smelt"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getSmeltConfigPath()); + return true; + } catch { + return false; + } + } +}; + +const has9RouterConfig = (settings) => { + if (!settings) return false; + return ( + settings._managedBy === "9router" || + (typeof settings.baseUrl === "string" && settings.baseUrl.length > 0 && settings.baseUrl.includes("20128")) + ); +}; + +const readConfig = async () => { + try { + const content = await fs.readFile(getSmeltConfigPath(), "utf-8"); + return JSON.parse(content); + } catch { + return null; + } +}; + +export async function GET() { + try { + const installed = await checkSmeltInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + config: null, + message: "Smelt CLI is not installed", + }); + } + + const config = await readConfig(); + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(config), + configPath: getSmeltConfigPath(), + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const { baseUrl, apiKey, model } = rawBody || {}; + if (!baseUrl) { + return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); + } + + const configPath = getSmeltConfigPath(); + await fs.mkdir(getSmeltDir(), { recursive: true }); + + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = JSON.parse(raw); + } catch {} + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const updated = { + ...existing, + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "sk_9router", + model: model || existing.model || "provider/model-id", + _managedBy: "9router", + }; + + await fs.writeFile(configPath, JSON.stringify(updated, null, 2), "utf-8"); + + return NextResponse.json({ + success: true, + message: "Smelt settings applied successfully!", + configPath, + }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getSmeltConfigPath(); + let existing = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existing = JSON.parse(raw); + } catch { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + + delete existing.baseUrl; + delete existing.apiKey; + delete existing.model; + delete existing._managedBy; + + if (Object.keys(existing).length === 0) { + await fs.rm(configPath, { force: true }); + } else { + await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8"); + } + + return NextResponse.json({ success: true, message: "Smelt 9Router settings removed" }); + } catch (err) { + return NextResponse.json({ error: { message: err.message } }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index a6bb4685..77c307fb 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -468,6 +468,96 @@ gemini extensions install https://github.com/manalkaff/opendesign # Fetch and follow .opencode/INSTALL.md from the repo`, }, }, + pi: { + id: "pi", + name: "Pi (pi-coding-agent)", + image: "/providers/pi.svg", + color: "#6366F1", + description: "Pi coding agent — minimal, extensible agent harness (pi.dev)", + configType: "custom", + docsUrl: "https://pi.dev", + notes: [ + { + type: "info", + text: "Pi uses ~/.pi/agent/models.json. 9Router is configured under providers.9router as an OpenAI-compatible endpoint.", + }, + ], + }, + omp: { + id: "omp", + name: "Oh My Pi", + image: "/providers/omp.png", + color: "#EC4899", + description: "Oh My Pi terminal AI agent with auto-discovery support", + configType: "custom", + docsUrl: "https://github.com/can1357/oh-my-pi", + notes: [ + { + type: "info", + text: "Oh My Pi uses ~/.omp/agent/models.yml and agent.db. 9Router is configured with proxy discovery so all models appear automatically under /model.", + }, + ], + }, + crush: { + id: "crush", + name: "Crush", + image: "/providers/crush.png", + color: "#FB923C", + description: "Charm Crush terminal AI coding agent", + configType: "custom", + docsUrl: "https://github.com/charmbracelet/crush", + notes: [ + { + type: "info", + text: "Crush uses ~/.config/crush/crush.json. 9Router registers as an openai-compat provider.", + }, + ], + }, + forge: { + id: "forge", + name: "ForgeCode", + image: "/providers/forge.png", + color: "#EAB308", + description: "Antinomy HQ ForgeCode agent harness", + configType: "custom", + docsUrl: "https://github.com/antinomyhq/forge", + notes: [ + { + type: "info", + text: "ForgeCode uses ~/.forge/config.toml. 9Router updates the [openai] section with your baseUrl, apiKey, and model.", + }, + ], + }, + smelt: { + id: "smelt", + name: "Smelt", + image: "/providers/smelt.svg", + color: "#EF4444", + description: "Smelt terminal AI coding assistant", + configType: "custom", + docsUrl: "https://github.com/leonardcser/smelt", + notes: [ + { + type: "info", + text: "Smelt uses ~/.smelt/config.json for OpenAI-compatible endpoint configuration.", + }, + ], + }, + codewhale: { + id: "codewhale", + name: "CodeWhale", + image: "/providers/codewhale.svg", + color: "#4F46E5", + description: "CodeWhale terminal coding agent (successor to DeepSeek TUI)", + configType: "custom", + docsUrl: "https://github.com/Hmbown/CodeWhale", + notes: [ + { + type: "info", + text: "CodeWhale uses ~/.codewhale/config.toml. 9Router configures the [openai] provider with your base_url, api_key, and model.", + }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli",