diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js
index 8595d42..28d47d2 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Card, ModelSelectModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Image from "next/image";
+import ApiKeySelect from "./ApiKeySelect";
export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) {
const [copiedField, setCopiedField] = useState(null);
@@ -46,37 +47,11 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
const hasActiveProviders = activeProviders.length > 0;
- const renderApiKeySelector = () => {
- return (
-
- {apiKeys && apiKeys.length > 0 ? (
- <>
-
-
- >
- ) : (
-
- {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
-
- )}
-
- );
- };
+ const renderApiKeySelector = () => (
+
+ );
const renderModelSelector = () => {
return (
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
index b45b3b2..7e8e2ea 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
@@ -4,6 +4,7 @@ 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;
@@ -118,7 +119,6 @@ export default function DroidToolCard({
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
- const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
const addModel = () => {
const val = modelInput.trim();
@@ -318,16 +318,7 @@ export default function DroidToolCard({
API Key
arrow_forward
- {apiKeys.length > 0 || selectedApiKey ? (
-
- ) : (
-
- {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
-
- )}
+
{/* Models */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
index df9c115..806be6b 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
@@ -4,6 +4,7 @@ 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";
@@ -108,7 +109,6 @@ export default function HermesToolCard({
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
- const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
const handleApply = async () => {
setApplying(true);
@@ -259,16 +259,7 @@ export default function HermesToolCard({
API Key
arrow_forward
- {apiKeys.length > 0 || selectedApiKey ? (
-
- ) : (
-
- {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
-
- )}
+
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
new file mode 100644
index 0000000..2a8a074
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
@@ -0,0 +1,275 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
+import Image from "next/image";
+import BaseUrlSelect from "./BaseUrlSelect";
+import ApiKeySelect from "./ApiKeySelect";
+import { matchKnownEndpoint } from "./cliEndpointMatch";
+
+export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
+ const [status, setStatus] = useState(initialStatus || null);
+ const [checking, setChecking] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [restoring, setRestoring] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [showInstallGuide, setShowInstallGuide] = useState(false);
+ const [selectedApiKey, setSelectedApiKey] = useState("");
+ const [selectedModel, setSelectedModel] = useState("");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modelAliases, setModelAliases] = useState({});
+ const [showManualConfigModal, setShowManualConfigModal] = useState(false);
+ const [customBaseUrl, setCustomBaseUrl] = useState("");
+
+ useEffect(() => {
+ if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key);
+ }, [apiKeys, selectedApiKey]);
+
+ useEffect(() => {
+ if (initialStatus) setStatus(initialStatus);
+ }, [initialStatus]);
+
+ useEffect(() => {
+ if (isExpanded && !status) {
+ checkStatus();
+ fetchModelAliases();
+ }
+ if (isExpanded) fetchModelAliases();
+ }, [isExpanded]);
+
+ const fetchModelAliases = async () => {
+ try {
+ const res = await fetch("/api/models/alias");
+ const data = await res.json();
+ if (res.ok) setModelAliases(data.aliases || {});
+ } catch (error) {
+ console.log("Error fetching model aliases:", error);
+ }
+ };
+
+ const getConfigStatus = () => {
+ if (!status?.installed) return null;
+ return status.has9Router ? "configured" : "not_configured";
+ };
+
+ const configStatus = getConfigStatus();
+
+ const getEffectiveBaseUrl = () => {
+ const url = customBaseUrl || `${baseUrl}/v1`;
+ return url.endsWith("/v1") ? url : `${url}/v1`;
+ };
+
+ const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
+
+ const checkStatus = async () => {
+ setChecking(true);
+ try {
+ const res = await fetch("/api/cli-tools/kilo-settings");
+ const data = await res.json();
+ setStatus(data);
+ } catch (error) {
+ setStatus({ installed: false, error: error.message });
+ } finally {
+ setChecking(false);
+ }
+ };
+
+ const handleApply = async () => {
+ setApplying(true);
+ setMessage(null);
+ try {
+ const keyToUse = (selectedApiKey && selectedApiKey.trim())
+ ? selectedApiKey
+ : (!cloudEnabled ? "sk_9router" : selectedApiKey);
+
+ const res = await fetch("/api/cli-tools/kilo-settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({ type: "success", text: "Settings applied successfully!" });
+ checkStatus();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to apply settings" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setApplying(false);
+ }
+ };
+
+ const handleReset = async () => {
+ setRestoring(true);
+ setMessage(null);
+ try {
+ const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({ type: "success", text: "Settings reset successfully!" });
+ setSelectedModel("");
+ checkStatus();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to reset settings" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setRestoring(false);
+ }
+ };
+
+ const getManualConfigs = () => {
+ const keyToUse = (selectedApiKey && selectedApiKey.trim())
+ ? selectedApiKey
+ : (!cloudEnabled ? "sk_9router" : "
");
+
+ return [{
+ filename: "~/.local/share/kilo/auth.json",
+ content: JSON.stringify({
+ "openai-compatible": {
+ type: "api-key",
+ apiKey: keyToUse,
+ baseUrl: getEffectiveBaseUrl(),
+ model: selectedModel || "provider/model-id",
+ },
+ }, null, 2),
+ }];
+ };
+
+ return (
+
+
+
+
+ { e.target.style.display = "none"; }} />
+
+
+
+
{tool.name}
+ {configStatus === "configured" && Connected}
+ {configStatus === "not_configured" && Not configured}
+
+
{tool.description}
+
+
+
expand_more
+
+
+ {isExpanded && (
+
+ {checking && (
+
+ progress_activity
+ Checking Kilo Code...
+
+ )}
+
+ {!checking && status && !status.installed && (
+
+
+
+
warning
+
+
Kilo Code not detected locally
+
Manual configuration is still available if 9router is deployed on a remote server.
+
+
+
+
+
+
+
+ {showInstallGuide && (
+
+
Installation Guide
+
Install Kilo Code from kilocode.ai or VS Code extension marketplace.
+
+ )}
+
+ )}
+
+ {!checking && status?.installed && (
+ <>
+
+
+ Select Endpoint
+ arrow_forward
+
+
+
+
+
API Key
+
arrow_forward
+
+
+
+
+
Model
+
arrow_forward
+
+ setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
+ {selectedModel && }
+
+
+
+
+
+ {message && (
+
+ {message.type === "success" ? "check_circle" : "error"}
+ {message.text}
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+ setModalOpen(false)}
+ onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
+ selectedModel={selectedModel}
+ activeProviders={activeProviders}
+ modelAliases={modelAliases}
+ title="Select Model for Kilo Code"
+ />
+
+ setShowManualConfigModal(false)}
+ title="Kilo Code - Manual Configuration"
+ configs={getManualConfigs()}
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js
index 51fab54..8b2ab41 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js
@@ -306,11 +306,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
warning
-
Port 443 đang bị process khác chiếm:
+
Port 443 is currently used by another process:
{port443Conflict.owner.name} (PID {port443Conflict.owner.pid})
-
Kill process này để chạy MITM Server?
+
Kill this process to start MITM Server?
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
index 32c1b15..88a73c6 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
@@ -4,6 +4,7 @@ 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({
@@ -125,7 +126,6 @@ export default function OpenClawToolCard({
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
- const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
const handleApplySettings = async () => {
setApplying(true);
@@ -310,16 +310,7 @@ export default function OpenClawToolCard({
API Key
arrow_forward
- {apiKeys.length > 0 || selectedApiKey ? (
-
- ) : (
-
- {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
-
- )}
+
{/* Default Model */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
index 5d8a34c..0ffee80 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
@@ -4,6 +4,7 @@ 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 OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
@@ -83,7 +84,6 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
};
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
- const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey);
const checkStatus = async () => {
setChecking(true);
@@ -289,16 +289,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
API Key
arrow_forward
- {apiKeys.length > 0 || selectedApiKey ? (
-
- ) : (
-
- {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
-
- )}
+
{/* Models */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js
index 1856560..5b0a312 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js
@@ -8,6 +8,8 @@ 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 MitmServerCard } from "./MitmServerCard";
export { default as MitmToolCard } from "./MitmToolCard";
export { default as MitmLinkCard } from "./MitmLinkCard";
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
index cb18377..38fb118 100644
--- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
+++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
@@ -466,6 +466,8 @@ export default function APIPageClient({ machineId }) {
} else if (event === "done") {
setTsInstalled(true);
setTsInstalling(false);
+ setShowTsModal(false);
+ handleConnectTailscale();
return;
} else if (event === "error") {
setTsStatus({ type: "error", message: data.error || "Install failed" });
@@ -628,8 +630,7 @@ export default function APIPageClient({ machineId }) {
setTsStatus(null);
setTsInstallLog([]);
const data = await checkTailscaleInstalled();
- if (data?.installed) {
- // Skip modal, connect directly when already installed
+ if (data?.installed && data?.hasCachedPassword) {
handleConnectTailscale();
} else {
setShowTsModal(true);
diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js
index bc5fed4..4f77a8b 100644
--- a/src/app/(dashboard)/dashboard/providers/page.js
+++ b/src/app/(dashboard)/dashboard/providers/page.js
@@ -94,10 +94,13 @@ function getConnectionErrorTag(connection) {
return "ERR";
}
+const APIKEY_INITIAL_VISIBLE = 20;
+
export default function ProvidersPage() {
const [connections, setConnections] = useState([]);
const [providerNodes, setProviderNodes] = useState([]);
const [loading, setLoading] = useState(true);
+ const [showAllApikey, setShowAllApikey] = useState(false);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] =
useState(false);
@@ -117,6 +120,13 @@ export default function ProvidersPage() {
!searchQuery.trim() ||
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
+ const sortByConnections = (entries, authType) =>
+ [...entries].sort(
+ (a, b) =>
+ getProviderStats(b[0], authType).total -
+ getProviderStats(a[0], authType).total,
+ );
+
useEffect(() => {
const fetchData = async () => {
try {
@@ -259,10 +269,19 @@ export default function ProvidersPage() {
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
([, info]) => matchSearch(info.name),
);
- const apikeyEntries = Object.entries(APIKEY_PROVIDERS).filter(
- ([, info]) =>
- (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
+ const apikeyEntries = sortByConnections(
+ Object.entries(APIKEY_PROVIDERS).filter(
+ ([, info]) =>
+ (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
+ ),
+ "apikey",
);
+ const isApikeySearching = !!searchQuery.trim();
+ const visibleApikeyEntries =
+ isApikeySearching || showAllApikey
+ ? apikeyEntries
+ : apikeyEntries.slice(0, APIKEY_INITIAL_VISIBLE);
+ const hiddenApikeyCount = apikeyEntries.length - APIKEY_INITIAL_VISIBLE;
if (loading) {
return (
@@ -466,7 +485,7 @@ export default function ProvidersPage() {
- {apikeyEntries.map(([key, info]) => (
+ {visibleApikeyEntries.map(([key, info]) => (
))}
+ {!isApikeySearching && !showAllApikey && hiddenApikeyCount > 0 && (
+
+ )}
)}
diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js
index 33b1b7f..bea3708 100644
--- a/src/app/api/cli-tools/all-statuses/route.js
+++ b/src/app/api/cli-tools/all-statuses/route.js
@@ -9,6 +9,8 @@ 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";
const STATUS_GETTERS = {
claude: claudeGet,
@@ -19,6 +21,8 @@ const STATUS_GETTERS = {
hermes: hermesGet,
cowork: coworkGet,
copilot: copilotGet,
+ cline: clineGet,
+ kilo: kiloGet,
};
// Batch endpoint: gather all CLI tool statuses in one round-trip
diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.js b/src/app/api/cli-tools/antigravity-mitm/alias/route.js
index de78ba5..3814a34 100644
--- a/src/app/api/cli-tools/antigravity-mitm/alias/route.js
+++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.js
@@ -3,6 +3,7 @@
import { NextResponse } from "next/server";
import { getMitmAlias, setMitmAliasAll } from "@/models";
import { getMitmStatus } from "@/mitm/manager";
+import { writeAliasForTool } from "@/lib/mitmAliasCache";
// GET - Get MITM aliases for a tool
export async function GET(request) {
@@ -43,6 +44,7 @@ export async function PUT(request) {
}
await setMitmAliasAll(tool, filtered);
+ writeAliasForTool(tool, filtered);
return NextResponse.json({ success: true, aliases: filtered });
} catch (error) {
console.log("Error saving MITM aliases:", error.message);
diff --git a/src/app/api/cli-tools/cline-settings/route.js b/src/app/api/cli-tools/cline-settings/route.js
new file mode 100644
index 0000000..cc72397
--- /dev/null
+++ b/src/app/api/cli-tools/cline-settings/route.js
@@ -0,0 +1,133 @@
+"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");
+ return JSON.parse(content);
+ } catch (error) {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ }
+};
+
+const has9RouterConfig = (globalState) => {
+ if (!globalState) return false;
+ const isOpenAi =
+ globalState.actModeApiProvider === "openai" || globalState.planModeApiProvider === "openai";
+ const baseUrl = globalState.openAiBaseUrl || "";
+ return isOpenAi && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router"));
+};
+
+export async function GET() {
+ try {
+ const installed = await checkInstalled();
+ if (!installed) {
+ return NextResponse.json({ installed: false, settings: null, message: "Cline CLI is not installed" });
+ }
+ const globalState = await readJson(getGlobalStatePath());
+ return NextResponse.json({
+ installed: true,
+ settings: {
+ actModeApiProvider: globalState?.actModeApiProvider,
+ planModeApiProvider: globalState?.planModeApiProvider,
+ openAiBaseUrl: globalState?.openAiBaseUrl,
+ openAiModelId: globalState?.openAiModelId,
+ },
+ has9Router: has9RouterConfig(globalState),
+ globalStatePath: getGlobalStatePath(),
+ });
+ } catch (error) {
+ console.log("Error checking cline settings:", error);
+ return NextResponse.json({ error: "Failed to check cline settings" }, { status: 500 });
+ }
+}
+
+export async function POST(request) {
+ try {
+ const { baseUrl, apiKey, model } = await request.json();
+ if (!baseUrl || !apiKey || !model) {
+ return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
+ }
+
+ await fs.mkdir(getDataDir(), { recursive: true });
+
+ // Cline expects base WITHOUT /v1
+ const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
+
+ const globalState = (await readJson(getGlobalStatePath())) || {};
+ globalState.actModeApiProvider = "openai";
+ globalState.planModeApiProvider = "openai";
+ globalState.openAiBaseUrl = normalizedBaseUrl;
+ globalState.openAiModelId = model;
+ globalState.planModeOpenAiModelId = model;
+ await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2));
+
+ const secrets = (await readJson(getSecretsPath())) || {};
+ secrets.openAiApiKey = apiKey;
+ await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2));
+
+ return NextResponse.json({ success: true, message: "Cline settings applied successfully!", globalStatePath: getGlobalStatePath() });
+ } catch (error) {
+ console.log("Error updating cline settings:", error);
+ return NextResponse.json({ error: "Failed to update cline settings" }, { status: 500 });
+ }
+}
+
+export async function DELETE() {
+ try {
+ const globalState = await readJson(getGlobalStatePath());
+ if (!globalState) {
+ return NextResponse.json({ success: true, message: "No settings file to reset" });
+ }
+
+ if (globalState.actModeApiProvider === "openai") {
+ delete globalState.openAiBaseUrl;
+ delete globalState.openAiModelId;
+ delete globalState.planModeOpenAiModelId;
+ globalState.actModeApiProvider = "cline";
+ globalState.planModeApiProvider = "cline";
+ }
+ await fs.writeFile(getGlobalStatePath(), JSON.stringify(globalState, null, 2));
+
+ const secrets = (await readJson(getSecretsPath())) || {};
+ delete secrets.openAiApiKey;
+ await fs.writeFile(getSecretsPath(), JSON.stringify(secrets, null, 2));
+
+ return NextResponse.json({ success: true, message: "9Router settings removed from Cline" });
+ } catch (error) {
+ console.log("Error resetting cline settings:", error);
+ return NextResponse.json({ error: "Failed to reset cline settings" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/cli-tools/kilo-settings/route.js b/src/app/api/cli-tools/kilo-settings/route.js
new file mode 100644
index 0000000..6802adc
--- /dev/null
+++ b/src/app/api/cli-tools/kilo-settings/route.js
@@ -0,0 +1,131 @@
+"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");
+ return JSON.parse(content);
+ } catch (error) {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ }
+};
+
+const has9RouterConfig = (auth) => {
+ if (!auth) return false;
+ const entry = auth["openai-compatible"] || auth["9router"];
+ if (!entry) return false;
+ const baseUrl = entry.baseUrl || entry.baseURL || "";
+ return baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("9router");
+};
+
+export async function GET() {
+ try {
+ const installed = await checkInstalled();
+ if (!installed) {
+ return NextResponse.json({ installed: false, settings: null, message: "Kilo Code CLI is not installed" });
+ }
+ const auth = await readJson(getAuthPath());
+ return NextResponse.json({
+ installed: true,
+ settings: { auth: auth ? Object.keys(auth) : [] },
+ has9Router: has9RouterConfig(auth),
+ authPath: getAuthPath(),
+ });
+ } catch (error) {
+ console.log("Error checking kilo settings:", error);
+ return NextResponse.json({ error: "Failed to check kilo settings" }, { status: 500 });
+ }
+}
+
+export async function POST(request) {
+ try {
+ const { baseUrl, apiKey, model } = await request.json();
+ if (!baseUrl || !apiKey || !model) {
+ return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
+ }
+
+ await fs.mkdir(getDataDir(), { recursive: true });
+
+ const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
+
+ const auth = (await readJson(getAuthPath())) || {};
+ auth["openai-compatible"] = {
+ type: "api-key",
+ apiKey,
+ baseUrl: normalizedBaseUrl,
+ model,
+ };
+ await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2));
+
+ // Best-effort: update VS Code extension settings
+ try {
+ const vscode = (await readJson(getVscodeSettingsPath())) || {};
+ vscode["kilocode.customProvider"] = { name: "9Router", baseURL: normalizedBaseUrl, apiKey };
+ vscode["kilocode.defaultModel"] = model;
+ await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2));
+ } catch { /* VS Code settings not writable */ }
+
+ return NextResponse.json({ success: true, message: "Kilo Code settings applied successfully!", authPath: getAuthPath() });
+ } catch (error) {
+ console.log("Error updating kilo settings:", error);
+ return NextResponse.json({ error: "Failed to update kilo settings" }, { status: 500 });
+ }
+}
+
+export async function DELETE() {
+ try {
+ const auth = await readJson(getAuthPath());
+ if (!auth) {
+ return NextResponse.json({ success: true, message: "No settings file to reset" });
+ }
+ delete auth["openai-compatible"];
+ delete auth["9router"];
+ await fs.writeFile(getAuthPath(), JSON.stringify(auth, null, 2));
+
+ try {
+ const vscode = await readJson(getVscodeSettingsPath());
+ if (vscode) {
+ delete vscode["kilocode.customProvider"];
+ delete vscode["kilocode.defaultModel"];
+ await fs.writeFile(getVscodeSettingsPath(), JSON.stringify(vscode, null, 2));
+ }
+ } catch { /* ignore */ }
+
+ return NextResponse.json({ success: true, message: "9Router settings removed from Kilo Code" });
+ } catch (error) {
+ console.log("Error resetting kilo settings:", error);
+ return NextResponse.json({ error: "Failed to reset kilo settings" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js
index 5fe339a..57cfd0c 100644
--- a/src/app/api/models/test/route.js
+++ b/src/app/api/models/test/route.js
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getApiKeys } from "@/lib/localDb";
+import { UPDATER_CONFIG } from "@/shared/constants/config";
// POST /api/models/test - Ping a single model via internal completions or embeddings
export async function POST(request) {
@@ -7,8 +8,7 @@ export async function POST(request) {
const { model, kind } = await request.json();
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
- const baseUrl = process.env.BASE_URL ||
- (() => { const u = new URL(request.url); return `${u.protocol}//${u.host}`; })();
+ const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`;
// Get an active internal API key for auth (if requireApiKey is enabled)
let apiKey = null;
diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js
index e71327e..6ffa11f 100644
--- a/src/app/api/providers/[id]/test-models/route.js
+++ b/src/app/api/providers/[id]/test-models/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getProviderConnectionById, getApiKeys } from "@/lib/localDb";
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
+import { UPDATER_CONFIG } from "@/shared/constants/config";
/**
* Get an active API key to pass through auth when requireApiKey is enabled.
@@ -64,10 +65,12 @@ export async function POST(request, { params }) {
let models = getProviderModels(alias);
+ const baseUrl = `http://127.0.0.1:${UPDATER_CONFIG.appPort}`;
+
// Compatible providers: fetch live model list
if (isCompatible && models.length === 0) {
try {
- const modelsRes = await fetch(`${getBaseUrl(request)}/api/providers/${id}/models`);
+ const modelsRes = await fetch(`${baseUrl}/api/providers/${id}/models`);
if (modelsRes.ok) {
const data = await modelsRes.json();
models = (data.models || []).map((m) => ({ id: m.id || m.name, name: m.name || m.id }));
@@ -79,7 +82,6 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 });
}
- const baseUrl = getBaseUrl(request);
const apiKey = await getInternalApiKey();
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
@@ -104,8 +106,3 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Test failed" }, { status: 500 });
}
}
-
-function getBaseUrl(request) {
- const url = new URL(request.url);
- return `${url.protocol}//${url.host}`;
-}
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 72b34a8..086bba3 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -551,6 +551,11 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
const res = await fetchWithConnectionProxy("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
+ case "fal-ai": {
+ const res = await fetchWithConnectionProxy("https://api.fal.ai/v1/models?limit=1", { headers: { Authorization: `Key ${connection.apiKey}` } }, effectiveProxy);
+ const valid = res.status !== 401 && res.status !== 403;
+ return { valid, error: valid ? null : "Invalid API key" };
+ }
case "chutes": {
const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js
index b33757f..f751921 100644
--- a/src/app/api/tunnel/tailscale-check/route.js
+++ b/src/app/api/tunnel/tailscale-check/route.js
@@ -3,6 +3,7 @@ import { exec } from "child_process";
import { promisify } from "util";
import { NextResponse } from "next/server";
import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale";
+import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
const execAsync = promisify(exec);
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
@@ -41,7 +42,8 @@ export async function GET() {
installed ? isDaemonRunning() : Promise.resolve(false),
]);
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
- return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning });
+ const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword());
+ return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, hasCachedPassword });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 2559939..76051e9 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,8 +1,11 @@
-@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap');
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
+/* Hide icon ligature text until font is ready */
+.material-symbols-outlined { visibility: hidden; }
+.fonts-loaded .material-symbols-outlined { visibility: visible; }
+
/* ============================================================
9Router palette — adopted from 9remote_private/web
Brand orange (dark) / soft coral (light), neutral warm bases
diff --git a/src/app/layout.js b/src/app/layout.js
index 17aecd7..43902e1 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -1,4 +1,5 @@
import { Inter } from "next/font/google";
+import "material-symbols/outlined.css";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
@@ -30,25 +31,11 @@ export default function RootLayout({ children }) {
return (
-
- {/* Non-blocking icon font: preload + inject stylesheet via script */}
-
-
diff --git a/src/lib/appUpdater.js b/src/lib/appUpdater.js
index 6357341..e1e270c 100644
--- a/src/lib/appUpdater.js
+++ b/src/lib/appUpdater.js
@@ -62,7 +62,7 @@ function collectAppPids() {
});
} catch { /* no processes */ }
- // Kill cloudflared + tray binaries (giữ lock app dir)
+ // Kill cloudflared + tray binaries (hold app dir lock)
for (const procName of ["cloudflared", "tray_windows_release"]) {
try {
const cmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-Process ${procName} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"`;
diff --git a/src/lib/mitmAliasCache.js b/src/lib/mitmAliasCache.js
new file mode 100644
index 0000000..b6ae23c
--- /dev/null
+++ b/src/lib/mitmAliasCache.js
@@ -0,0 +1,46 @@
+// JSON cache for mitmAlias — read by standalone MITM server (no SQLite native binding).
+// Source of truth = SQLite kv['mitmAlias']. JSON is a read-replica synced on app start
+// and after every UI write.
+import fs from "fs";
+import path from "path";
+import os from "os";
+
+const DATA_DIR = process.env.DATA_DIR
+ || (process.platform === "win32"
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router")
+ : path.join(os.homedir(), ".9router"));
+
+const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json");
+
+function writeAtomic(data) {
+ const dir = path.dirname(CACHE_FILE);
+ fs.mkdirSync(dir, { recursive: true });
+ const tmp = `${CACHE_FILE}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
+ fs.renameSync(tmp, CACHE_FILE);
+}
+
+// Sync entire mitmAlias map from DB → JSON file
+export async function syncToJson() {
+ try {
+ const { getMitmAlias } = await import("@/lib/db/repos/aliasRepo.js");
+ const all = await getMitmAlias();
+ writeAtomic(all || {});
+ } catch (e) {
+ console.log("[mitmAliasCache] sync failed:", e.message);
+ }
+}
+
+// Update cache for a single tool after UI saves to DB
+export function writeAliasForTool(tool, mappings) {
+ try {
+ let current = {};
+ if (fs.existsSync(CACHE_FILE)) {
+ try { current = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch { /* corrupted → reset */ }
+ }
+ current[tool] = mappings || {};
+ writeAtomic(current);
+ } catch (e) {
+ console.log("[mitmAliasCache] write failed:", e.message);
+ }
+}
diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js
index 0951eab..aab5d72 100644
--- a/src/lib/tunnel/tailscale.js
+++ b/src/lib/tunnel/tailscale.js
@@ -433,8 +433,22 @@ async function ensureUserOwnedDir(dir) {
} catch { /* ignore */ }
}
-/** Start tailscaled in userspace-networking mode (no root, no sudo prompt). */
-export async function startDaemonWithPassword(_sudoPasswordUnused) {
+/** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */
+function isDaemonTunMode() {
+ try {
+ const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
+ if (!ps) return null;
+ return !ps.includes("--tun=userspace-networking");
+ } catch { return null; }
+}
+
+/**
+ * Start tailscaled.
+ * - With sudoPassword: TUN mode (root) → Funnel TLS works
+ * - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable)
+ * State always lives in ~/.9router/tailscale/ via --statedir.
+ */
+export async function startDaemonWithPassword(sudoPassword) {
if (IS_WINDOWS) {
// Windows: tailscale runs as a Windows Service. Start it then poll BackendState
// until daemon finishes init (avoids "NoState" errors when calling funnel/up too early).
@@ -459,64 +473,62 @@ export async function startDaemonWithPassword(_sudoPasswordUnused) {
return;
}
- // Detect unhealthy state: dir/files not owned by current user OR multiple daemons running.
- // Either condition blocks userspace daemon → must kill all + reclaim ownership.
- let needsRestart = false;
- try {
- const st = fs.statSync(TAILSCALE_DIR);
- if (st.uid !== process.getuid()) needsRestart = true;
- // Also check state file (the actual unhealthy resource)
- const stateFile = path.join(TAILSCALE_DIR, "tailscaled.state");
- if (fs.existsSync(stateFile) && fs.statSync(stateFile).uid !== process.getuid()) needsRestart = true;
- } catch { /* dir doesn't exist yet */ }
+ const wantTun = !!sudoPassword;
+ const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running
- // Detect duplicate daemons on same socket → also requires restart
- if (!needsRestart) {
- try {
- const ps = execSync(`pgrep -f "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
- if (ps && ps.split("\n").length > 1) needsRestart = true;
- } catch { /* no match → ok */ }
- }
-
- if (needsRestart) {
- // Kill ALL tailscaled processes (root + user duplicates). Best-effort with/without sudo.
- try { execSync("pkill -9 -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
- try { execSync("sudo -n pkill -9 -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
- await new Promise((r) => setTimeout(r, 1500));
- } else {
- // Check if our userspace daemon already responds
+ // Daemon already running in correct mode → reuse
+ if (currentMode !== null && currentMode === wantTun) {
try {
const bin = getTailscaleBin() || "tailscale";
execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
- stdio: "ignore",
- windowsHide: true,
- env: { ...process.env, PATH: EXTENDED_PATH },
- timeout: 3000
+ stdio: "ignore", windowsHide: true,
+ env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000
});
- return; // Already running and user-owned
- } catch { /* not running, start it */ }
+ return;
+ } catch { /* unresponsive, restart below */ }
}
- // Reclaim folder ownership if a previous root daemon left it locked
+ // Mode mismatch or unresponsive → kill all daemons on our socket
+ try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
+ if (sudoPassword) {
+ try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ }
+ } else {
+ try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
+ }
+ await new Promise((r) => setTimeout(r, 1500));
+
+ // Reclaim folder ownership (previous root daemon may have locked it)
await ensureUserOwnedDir(TAILSCALE_DIR);
- // Userspace-networking mode: no TUN device → no root needed → no sudo prompt
const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled";
- const args = [
+ const daemonArgs = [
`--socket=${TAILSCALE_SOCKET}`,
`--statedir=${TAILSCALE_DIR}`,
- "--tun=userspace-networking",
];
+ if (!wantTun) daemonArgs.push("--tun=userspace-networking");
- const child = spawn(tailscaledBin, args, {
- detached: true,
- stdio: "ignore",
- cwd: os.tmpdir(),
- env: { ...process.env, PATH: EXTENDED_PATH },
- });
- child.unref();
+ if (wantTun) {
+ // TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit.
+ const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], {
+ detached: true,
+ stdio: ["pipe", "ignore", "ignore"],
+ cwd: os.tmpdir(),
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ });
+ child.stdin.write(`${sudoPassword}\n`);
+ child.stdin.end();
+ child.unref();
+ } else {
+ const child = spawn(tailscaledBin, daemonArgs, {
+ detached: true,
+ stdio: "ignore",
+ cwd: os.tmpdir(),
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ });
+ child.unref();
+ }
- // Wait for daemon socket to be ready
+ // Wait for socket ready
await new Promise((r) => setTimeout(r, 3000));
}
diff --git a/src/mitm/dbReader.js b/src/mitm/dbReader.js
index 4ccc1db..7133947 100644
--- a/src/mitm/dbReader.js
+++ b/src/mitm/dbReader.js
@@ -1,49 +1,22 @@
-// CJS reader for MITM standalone process. Reads SQLite mitmAlias scope.
-// Falls back to legacy db.json or db.json.migrated if SQLite unavailable.
+// CJS reader for MITM standalone process. Reads mitmAlias from JSON cache
+// at $DATA_DIR/mitm/aliases.json (synced by app from SQLite on startup + writes).
+// JSON-only: no SQLite native binding required in MITM bundle.
const fs = require("fs");
const path = require("path");
const { DATA_DIR } = require("./paths");
-const DB_FILE = path.join(DATA_DIR, "db", "data.sqlite");
-const LEGACY_JSON = path.join(DATA_DIR, "db.json");
-const LEGACY_MIGRATED = path.join(DATA_DIR, "db.json.migrated");
+const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json");
-let sqliteDb = null;
-let sqliteFailed = false;
-
-function trySqlite() {
- if (sqliteDb) return sqliteDb;
- if (sqliteFailed) return null;
+function readCache() {
try {
- if (!fs.existsSync(DB_FILE)) return null;
- const Database = require("better-sqlite3");
- sqliteDb = new Database(DB_FILE, { readonly: true, fileMustExist: true });
- return sqliteDb;
- } catch {
- sqliteFailed = true;
- return null;
- }
-}
-
-function readLegacyJson() {
- for (const file of [LEGACY_JSON, LEGACY_MIGRATED]) {
- if (!fs.existsSync(file)) continue;
- try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch {}
- }
- return null;
+ if (!fs.existsSync(CACHE_FILE)) return null;
+ return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
+ } catch { return null; }
}
function getMitmAlias(toolName) {
- const db = trySqlite();
- if (db) {
- try {
- const row = db.prepare(`SELECT value FROM kv WHERE scope = 'mitmAlias' AND key = ?`).get(toolName);
- if (row) return JSON.parse(row.value);
- } catch {}
- }
- // Fallback to legacy JSON
- const legacy = readLegacyJson();
- return legacy?.mitmAlias?.[toolName] || null;
+ const all = readCache();
+ return all?.[toolName] || null;
}
module.exports = { getMitmAlias };
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js
index 220829c..00939ab 100644
--- a/src/shared/constants/cliTools.js
+++ b/src/shared/constants/cliTools.js
@@ -165,14 +165,7 @@ export const CLI_TOOLS = {
image: "/providers/cline.png",
color: "#00D1B2",
description: "Cline AI Coding Assistant",
- configType: "guide",
- guideSteps: [
- { step: 1, title: "Open Settings", desc: "Go to Cline Settings panel" },
- { step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" },
- { step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true },
- { step: 4, title: "API Key", type: "apiKeySelector" },
- { step: 5, title: "Select Model", type: "modelSelector" },
- ],
+ configType: "custom",
},
kilo: {
id: "kilo",
@@ -180,14 +173,7 @@ export const CLI_TOOLS = {
image: "/providers/kilocode.png",
color: "#FF6B6B",
description: "Kilo Code AI Assistant",
- configType: "guide",
- guideSteps: [
- { step: 1, title: "Open Settings", desc: "Go to Kilo Code Settings panel" },
- { step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" },
- { step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true },
- { step: 4, title: "API Key", type: "apiKeySelector" },
- { step: 5, title: "Select Model", type: "modelSelector" },
- ],
+ configType: "custom",
},
roo: {
id: "roo",
diff --git a/src/shared/constants/pricing.js b/src/shared/constants/pricing.js
index d2914c0..96027d9 100644
--- a/src/shared/constants/pricing.js
+++ b/src/shared/constants/pricing.js
@@ -147,7 +147,7 @@ export const PATTERN_PRICING = [
{ pattern: "claude-haiku-*", pricing: { input: 1.00, output: 5.00, cached: 0.10, reasoning: 5.00, cache_creation: 1.25 } },
{ pattern: "claude-*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.75 } },
- // --- Gemini (specific trước, chung sau) ---
+ // --- Gemini (specific first, generic last) ---
{ pattern: "gemini-*-flash-lite", pricing: { input: 0.15, output: 1.25, cached: 0.015, reasoning: 1.875, cache_creation: 0.15 } },
{ pattern: "gemini-*-flash", pricing: { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 } },
{ pattern: "gemini-*-pro", pricing: { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 } },
@@ -155,7 +155,7 @@ export const PATTERN_PRICING = [
{ pattern: "gemini-2.5-*", pricing: { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 } },
{ pattern: "gemini-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } },
- // --- GPT (specific trước, chung sau) ---
+ // --- GPT (specific first, generic last) ---
{ pattern: "gpt-5.3-*", pricing: { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 } },
{ pattern: "gpt-5.2-*", pricing: { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 } },
{ pattern: "gpt-5.1-*", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } },
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
index 4b148e7..6706e8d 100644
--- a/src/shared/constants/providers.js
+++ b/src/shared/constants/providers.js
@@ -111,7 +111,7 @@ export const APIKEY_PROVIDERS = {
searchapi: { id: "searchapi", alias: "searchapi", name: "SearchAPI", icon: "search", color: "#0EA5A4", textIcon: "SA", website: "https://www.searchapi.io", notice: { apiKeyUrl: "https://www.searchapi.io/dashboard" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
youcom: { id: "youcom", alias: "youcom", name: "You.com Search", icon: "search", color: "#7C3AED", textIcon: "YC", website: "https://you.com", notice: { apiKeyUrl: "https://api.you.com" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", notice: { apiKeyUrl: "https://www.firecrawl.dev/app/api-keys" }, serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://api.firecrawl.dev/v1/scrape", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.002, freeMonthlyQuota: 500, formats: ["markdown", "html", "text"], maxCharacters: 200000, timeoutMs: 30000 } },
- "fal-ai": { id: "fal-ai", alias: "fal", name: "Fal.ai", icon: "image", color: "#2563EB", textIcon: "FL", website: "https://fal.ai", notice: { apiKeyUrl: "https://fal.ai/dashboard/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://queue.fal.run/fal-ai/flux/schnell", method: "POST", authType: "apikey", authHeader: "key" } },
+ "fal-ai": { id: "fal-ai", alias: "fal", name: "Fal.ai", icon: "image", color: "#2563EB", textIcon: "FL", website: "https://fal.ai", notice: { apiKeyUrl: "https://fal.ai/dashboard/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.fal.ai/v1/models?limit=1", method: "GET", authType: "apikey", authHeader: "key" } },
"stability-ai": { id: "stability-ai", alias: "stability", name: "Stability AI", icon: "image", color: "#8B5CF6", textIcon: "SA", website: "https://stability.ai", notice: { apiKeyUrl: "https://platform.stability.ai/account/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.stability.ai/v1/user/account", method: "GET", authType: "apikey", authHeader: "bearer" } },
"black-forest-labs": { id: "black-forest-labs", alias: "bfl", name: "Black Forest Labs", icon: "image", color: "#111827", textIcon: "BF", website: "https://blackforestlabs.ai", notice: { apiKeyUrl: "https://api.bfl.ai" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.bfl.ai/v1/get_result?id=ping", method: "GET", authType: "apikey", authHeader: "x-key" } },
recraft: { id: "recraft", alias: "recraft", name: "Recraft", icon: "image", color: "#EC4899", textIcon: "RC", website: "https://recraft.ai", notice: { apiKeyUrl: "https://www.recraft.ai/profile/api" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://external.api.recraft.ai/v1/users/me", method: "GET", authType: "apikey", authHeader: "bearer" } },
diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js
index 5c75f31..229501f 100644
--- a/src/shared/services/initializeApp.js
+++ b/src/shared/services/initializeApp.js
@@ -17,6 +17,7 @@ import {
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS,
} from "@/lib/tunnel/tunnelConfig";
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
+import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
(function bootstrapMitm() {
@@ -78,6 +79,9 @@ export async function initializeApp() {
ensureCloudflared().catch(() => {});
+ // Sync mitmAlias DB → JSON cache so standalone MITM server can read it
+ syncMitmAliasCache().catch(() => {});
+
startWatchdog();
startNetworkMonitor();
autoStartMitm();