diff --git a/public/providers/opencode.png b/public/providers/opencode.png new file mode 100644 index 00000000..2e709e1c Binary files /dev/null and b/public/providers/opencode.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 2ed47971..51e11c32 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,13 +4,14 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { PROVIDER_MODELS, getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard, OpenCodeToolCard } from "./components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; const STATUS_ENDPOINTS = { claude: "/api/cli-tools/claude-settings", codex: "/api/cli-tools/codex-settings", + opencode: "/api/cli-tools/opencode-settings", droid: "/api/cli-tools/droid-settings", openclaw: "/api/cli-tools/openclaw-settings", antigravity: "/api/cli-tools/antigravity-mitm", @@ -199,6 +200,8 @@ export default function CLIToolsPageClient({ machineId }) { ); case "codex": return ; + case "opencode": + return ; case "droid": return ; case "openclaw": @@ -206,7 +209,7 @@ export default function CLIToolsPageClient({ machineId }) { case "antigravity": return ; default: - return ; + return ; } }; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index c9322880..d2114993 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -209,7 +209,7 @@ export default function AntigravityToolCard({ const isRunning = status?.running; return ( - +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 6d448413..2db68c98 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -206,7 +206,7 @@ export default function ClaudeToolCard({ }; return ( - +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index aac93462..edfe98c2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -169,7 +169,7 @@ wire_api = "responses" }; return ( - +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index 6d643ff5..5f00e3f0 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -4,7 +4,7 @@ import { useState } from "react"; import { Card, ModelSelectModal } from "@/shared/components"; import Image from "next/image"; -export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false }) { +export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) { const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); const [modelValue, setModelValue] = useState(""); @@ -125,11 +125,11 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba return (
{tool.notes.map((note, index) => { - // Skip cloudCheck note if cloud is enabled - if (note.type === "cloudCheck" && cloudEnabled) return null; + // Skip cloudCheck note if tunnel or cloud is enabled + if (note.type === "cloudCheck" && (cloudEnabled || tunnelEnabled)) return null; const isWarning = note.type === "warning"; - const isError = note.type === "cloudCheck" && !cloudEnabled; + const isError = note.type === "cloudCheck" && !cloudEnabled && !tunnelEnabled; let bgClass = "bg-blue-500/10 border-blue-500/30"; let textClass = "text-blue-600 dark:text-blue-400"; @@ -160,6 +160,7 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba }; const canShowGuide = () => { + if (tool.requiresExternalUrl && !cloudEnabled && !tunnelEnabled) return false; if (tool.requiresCloud && !cloudEnabled) return false; return true; }; @@ -258,7 +259,7 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba }; return ( - +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 799f44f0..6282ba2c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -200,7 +200,7 @@ export default function DroidToolCard({ }; return ( - +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 1aa5af56..ac08e747 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -213,7 +213,7 @@ export default function OpenClawToolCard({ }; return ( - +
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js new file mode 100644 index 00000000..4ab16783 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -0,0 +1,308 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, 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 [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]); + + // Sync model from existing config + useEffect(() => { + if (status?.config?.model?.startsWith("9router/")) { + setSelectedModel(status.config.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 getConfigStatus = () => { + if (!status?.installed) return null; + if (!status.config) return "not_configured"; + const url = status.config?.provider?.["9router"]?.options?.baseURL || ""; + const isLocal = url.includes("localhost") || url.includes("127.0.0.1"); + return status.has9Router && (isLocal || url.includes(baseUrl)) ? "configured" : status.has9Router ? "other" : "not_configured"; + }; + + 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, 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/opencode-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + return [{ + filename: "~/.config/opencode/opencode.json", + content: JSON.stringify({ + provider: { + "9router": { + npm: "@ai-sdk/openai-compatible", + options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse }, + models: { [selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" } }, + }, + }, + model: `9router/${selectedModel || "provider/model-id"}`, + }, null, 2), + }]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

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

{tool.description}

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

OpenCode CLI not installed

+

Please install OpenCode CLI to use auto-apply feature.

+
+ +
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

macOS / Linux:

+ npm install -g opencode-ai +
+

After installation, run opencode to verify.

+
+
+ )} +
+ )} + + {!checking && status?.installed && ( + <> +
+ {/* Current base URL */} + {status?.config?.provider?.["9router"]?.options?.baseURL && ( +
+ Current + arrow_forward + + {status.config.provider["9router"].options.baseURL} + +
+ )} + + {/* Base URL */} +
+ Base URL + arrow_forward + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== `${baseUrl}/v1` && ( + + )} +
+ + {/* API Key */} +
+ API Key + arrow_forward + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ + {/* Model */} +
+ Model + arrow_forward + setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> + + {selectedModel && } +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for OpenCode" + /> + + setShowManualConfigModal(false)} + title="OpenCode - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 5aed48d8..078970f4 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -4,4 +4,5 @@ export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; export { default as AntigravityToolCard } from "./AntigravityToolCard"; +export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; diff --git a/src/app/api/cli-tools/opencode-settings/route.js b/src/app/api/cli-tools/opencode-settings/route.js new file mode 100644 index 00000000..bf140df0 --- /dev/null +++ b/src/app/api/cli-tools/opencode-settings/route.js @@ -0,0 +1,153 @@ +"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 getConfigDir = () => path.join(os.homedir(), ".config", "opencode"); +const getConfigPath = () => path.join(getConfigDir(), "opencode.json"); + +const checkOpenCodeInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where opencode" : "command -v opencode"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + return false; + } +}; + +const readConfig = async () => { + try { + const content = await fs.readFile(getConfigPath(), "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +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(); + + return NextResponse.json({ + installed: true, + config, + has9Router: has9RouterConfig(config), + configPath: getConfigPath(), + }); + } 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 +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 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"; + + // Merge 9router provider + if (!config.provider) config.provider = {}; + config.provider["9router"] = { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: normalizedBaseUrl, + apiKey: keyToUse, + }, + models: { + [model]: { name: model }, + }, + }; + + // Set as active model + config.model = `9router/${model}`; + + 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 updating opencode settings:", error); + return NextResponse.json({ error: "Failed to update opencode settings" }, { status: 500 }); + } +} + +// DELETE - Remove 9Router provider from config +export async function DELETE() { + try { + 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; + } + + // Remove 9router provider + if (config.provider) delete config.provider["9router"]; + + // Reset model if it was pointing to 9router + if (config.model?.startsWith("9router/")) delete config.model; + + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + + return NextResponse.json({ + success: true, + message: "9Router settings removed from OpenCode", + }); + } catch (error) { + console.log("Error resetting opencode settings:", error); + return NextResponse.json({ error: "Failed to reset opencode settings" }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index da5bf28d..ae8a8d43 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -22,6 +22,14 @@ export const CLI_TOOLS = { { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, ], }, + openclaw: { + id: "openclaw", + name: "Open Claw", + image: "/providers/openclaw.png", + color: "#FF6B35", + description: "Open Claw AI Assistant", + configType: "custom", + }, codex: { id: "codex", name: "OpenAI Codex CLI", @@ -30,6 +38,14 @@ export const CLI_TOOLS = { description: "OpenAI Codex CLI", configType: "custom", }, + opencode: { + id: "opencode", + name: "OpenCode", + image: "/providers/opencode.png", + color: "#E87040", + description: "OpenCode AI Terminal Assistant", + configType: "custom", + }, antigravity: { id: "antigravity", name: "Antigravity", @@ -55,14 +71,6 @@ export const CLI_TOOLS = { description: "Factory Droid AI Assistant", configType: "custom", }, - openclaw: { - id: "openclaw", - name: "Open Claw", - image: "/providers/openclaw.png", - color: "#FF6B35", - description: "Open Claw AI Assistant", - configType: "custom", - }, cursor: { id: "cursor", name: "Cursor", @@ -70,10 +78,10 @@ export const CLI_TOOLS = { color: "#000000", description: "Cursor AI Code Editor", configType: "guide", - requiresCloud: true, + requiresExternalUrl: true, notes: [ { type: "warning", text: "Requires Cursor Pro account to use this feature." }, - { type: "cloudCheck", text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Cloud Endpoint in Settings." }, + { type: "cloudCheck", text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings." }, ], guideSteps: [ { step: 1, title: "Open Settings", desc: "Go to Settings → Models" },