feat(cli-tools): add dynamic configuration for Pi, OMP, Crush, ForgeCode, Smelt and CodeWhale

- Add dedicated settings API routes for pi, omp, crush, forge, smelt, codewhale
- Integrate GenericCliToolCard with multi-model support for Pi and auto-discovery for OMP
- Register tools in cliTools catalog and all-statuses route
- Add official logos for all new CLI tools

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
decoluaandClaude Code committed 2026-09-22 14:48:04 +07:00
1 parent 44fd69d229
commit 6c9fe6f78a
17 files changed
+1809 -1

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Codewhale">
<defs>
<linearGradient id="codewhale-current" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1E8FD8"/>
<stop offset="1" stop-color="#0B48BB"/>
</linearGradient>
</defs>
<path fill="url(#codewhale-current)" fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 Line truncated
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z"/>
<path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z"/>
<path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

+101
View File
@@ -0,0 +1,101 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 12.1" shape-rendering="crispEdges" role="img" aria-label="smelt logo">
<rect x="6" y="0" width="1" height="1.1" fill="#af0000"/>
<rect x="6" y="1.1" width="1" height="1.1" fill="#ff5f00"/>
<rect x="7" y="1.1" width="1" height="1.1" fill="#ff5f00"/>
<rect x="5" y="2.2" width="1" height="1.1" fill="#af0000"/>
<rect x="6" y="2.2" width="1" height="1.1" fill="#ff5f00"/>
<rect x="7" y="2.2" width="1" height="1.1" fill="#ff8700"/>
<rect x="8" y="2.2" width="1" height="1.1" fill="#ff8700"/>
<rect x="9" y="2.2" width="1" height="1.1" fill="#ff5f00"/>
<rect x="10" y="2.2" width="1" height="1.1" fill="#af0000"/>
<rect x="4" y="3.3" width="1" height="1.1" fill="#af0000"/>
<rect x="5" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
<rect x="6" y="3.3" width="1" height="1.1" fill="#ff8700"/>
<rect x="7" y="3.3" width="1" height="1.1" fill="#ffd700"/>
<rect x="8" y="3.3" width="1" height="1.1" fill="#ffd700"/>
<rect x="9" y="3.3" width="1" height="1.1" fill="#ff8700"/>
<rect x="10" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
<rect x="11" y="3.3" width="1" height="1.1" fill="#af0000"/>
<rect x="13" y="3.3" width="1" height="1.1" fill="#af0000"/>
<rect x="14" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
<rect x="3" y="4.4" width="1" height="1.1" fill="#af0000"/>
<rect x="4" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
<rect x="5" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="6" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="7" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="8" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="9" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="10" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="11" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="12" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
<rect x="13" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="14" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="15" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
<rect x="1" y="5.5" width="1" height="1.1" fill="#af0000"/>
<rect x="2" y="5.5" width="1" height="1.1" fill="#ff5f00"/>
<rect x="3" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="4" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="5" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="6" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="7" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="8" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="9" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="10" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="11" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="12" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="13" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="14" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="15" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="16" y="5.5" width="1" height="1.1" fill="#ff5f00"/>
<rect x="17" y="5.5" width="1" height="1.1" fill="#af0000"/>
<rect x="0" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="1" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="2" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="4" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="5" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="6" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="7" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="8" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="11" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="12" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="14" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="17" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="18" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="19" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="0" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="1" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="2" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="4" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="5" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="6" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="7" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="8" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="11" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="12" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="14" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="17" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="18" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="19" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="2" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="4" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="6" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="8" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="14" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="18" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="0" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="1" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="2" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="4" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="6" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="8" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="11" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="12" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="14" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="15" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="18" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="19" y="11" width="1" height="1.1" fill="#fff6ef"/>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -9,7 +9,7 @@ import {
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
JcodeToolCard, GrokBuildToolCard, JcodeToolCard, GrokBuildToolCard, GenericCliToolCard,
} from "../components"; } from "../components";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -166,6 +166,13 @@ export default function ToolDetailClient({ toolId, machineId }) {
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />; return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "grok-build": case "grok-build":
return <GrokBuildToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />; return <GrokBuildToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "pi":
case "omp":
case "crush":
case "forge":
case "smelt":
case "codewhale":
return <GenericCliToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
default: default:
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />; return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
} }
@@ -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 (
<Card padding="xs" className="overflow-hidden">
{/* Header clickable */}
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
{tool.image ? (
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
) : tool.icon ? (
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>
{tool.icon}
</span>
) : (
<span className="material-symbols-outlined text-[28px] text-primary">terminal</span>
)}
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{configStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{configStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>
expand_more
</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking {tool.name}...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">{tool.name} not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button
variant="secondary"
size="sm"
onClick={() => setShowManualConfigModal(true)}
className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30"
>
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">Install command:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">{getInstallCommand()}</code>
</div>
{tool.docsUrl && (
<p className="text-xs text-text-muted">
Docs: <a href={tool.docsUrl} target="_blank" rel="noreferrer" className="text-primary hover:underline">{tool.docsUrl}</a>
</p>
)}
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/>
</div>
{/* Current configured */}
{currentBaseUrl ? (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{currentBaseUrl}
</span>
</div>
) : null}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models selector cho Pi (multi-models) */}
{tool.id === "pi" && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm mt-1">Models</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[36px] p-2 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted italic">No models selected. Add models to use in Pi.</span>
) : (
selectedModels.map((modelId) => (
<span
key={modelId}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-bg-secondary text-xs text-text-main border border-border"
>
<span>{modelId}</span>
<button
type="button"
onClick={() => handleRemoveModel(modelId)}
className="text-text-muted hover:text-red-500 rounded p-0.5"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setModalOpen(true)}
disabled={!activeProviders?.length}
>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
Add Model
</Button>
{activeProviders?.length > 0 && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleAddAllActiveModels}
className="text-xs text-primary hover:text-primary-hover"
>
+ Add All Active Models
</Button>
)}
{selectedModels.length > 0 && (
<button
type="button"
onClick={() => setSelectedModels([])}
className="text-xs text-text-muted hover:text-red-500 ml-auto"
>
Clear all
</button>
)}
</div>
</div>
</div>
)}
{/* Model (1 model cho các tool khác, trừ omp) */}
{tool.id !== "omp" && tool.id !== "pi" && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => setModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${
activeProviders?.length
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
</button>
</div>
)}
</div>
{/* Messages */}
{message && (
<div
className={`p-3 rounded-lg text-sm ${
message.type === "success"
? "bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
}`}
>
{message.text}
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={handleApply}
disabled={applying || checking}
>
{applying ? "Applying..." : "Apply Settings"}
</Button>
{status?.has9Router && (
<Button
variant="outline"
size="sm"
onClick={handleRestore}
disabled={restoring || checking}
className="text-red-500 hover:text-red-600 hover:border-red-500/50"
>
{restoring ? "Removing..." : "Remove from Tool"}
</Button>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowManualConfigModal(true)}
>
<span className="material-symbols-outlined text-[18px] mr-1">code</span>
Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleSelectModel}
activeProviders={activeProviders}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title={`${tool.name} Configuration`}
configs={getManualConfigContent()}
/>
</Card>
);
}
@@ -13,6 +13,7 @@ export { default as KiloToolCard } from "./KiloToolCard";
export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
export { default as JcodeToolCard } from "./JcodeToolCard"; export { default as JcodeToolCard } from "./JcodeToolCard";
export { default as GrokBuildToolCard } from "./GrokBuildToolCard"; export { default as GrokBuildToolCard } from "./GrokBuildToolCard";
export { default as GenericCliToolCard } from "./GenericCliToolCard";
export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmServerCard } from "./MitmServerCard";
export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmToolCard } from "./MitmToolCard";
export { default as MitmLinkCard } from "./MitmLinkCard"; export { default as MitmLinkCard } from "./MitmLinkCard";
@@ -14,6 +14,12 @@ import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route";
import { GET as jcodeGet } from "../jcode-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route";
import { GET as grokBuildGet } from "../grok-build-settings/route"; import { GET as grokBuildGet } from "../grok-build-settings/route";
import { GET as devinGet } from "../devin-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 = { const STATUS_GETTERS = {
claude: claudeGet, claude: claudeGet,
@@ -29,6 +35,12 @@ const STATUS_GETTERS = {
jcode: jcodeGet, jcode: jcodeGet,
"grok-build": grokBuildGet, "grok-build": grokBuildGet,
devin: devinGet, 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 // Batch endpoint: gather all CLI tool statuses in one round-trip
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
+179
View File
@@ -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 });
}
}
+187
View File
@@ -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 });
}
}
@@ -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 });
}
}
+90
View File
@@ -468,6 +468,96 @@ gemini extensions install https://github.com/manalkaff/opendesign
# Fetch and follow .opencode/INSTALL.md from the repo`, # 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 // HIDDEN: gemini-cli
// "gemini-cli": { // "gemini-cli": {
// id: "gemini-cli", // id: "gemini-cli",