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 (
+
+
+
+
+ { 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" },