refactor(dashboard,cli): move cards to cli-code/components + add detail pages /cli-code/[id] + /cli-agents/[id] + ToolDetailClient orchestrator (plan 14 F8)

- git mv cli-tools/components → cli-code/components (history preserved, 15 renames)
- Add isExpanded=false + onToggle=()=>{} defaults to all 12 specialized cards
- Create cli-code/[id]/page.tsx: server component, guards category=code
- Create cli-agents/[id]/page.tsx: server component, guards category=agent
- Create cli-code/components/ToolDetailClient.tsx: orchestrator with isExpanded:true always (D23), header with back-link + vendor/category/baseUrl badges
- Delete cli-tools/CLIToolsPageClient.tsx + cli-tools/page.tsx (accordion-based orchestrator replaced)
- Tests: ToolDetailClient smoke (5), cli-code detail page (3), cli-agents detail page (3) — 11/11 passing
This commit is contained in:
diegosouzapw
2026-05-28 00:15:04 -03:00
parent 5847c3b50e
commit 2d58519ca9
23 changed files with 772 additions and 543 deletions

View File

@@ -0,0 +1,14 @@
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { notFound } from "next/navigation";
import ToolDetailClient from "../../cli-code/components/ToolDetailClient";
export default async function CliAgentsDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const tool = CLI_TOOLS[id];
if (!tool || tool.category !== "agent") notFound();
return <ToolDetailClient toolId={id} category="agent" />;
}

View File

@@ -0,0 +1,14 @@
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { notFound } from "next/navigation";
import ToolDetailClient from "../components/ToolDetailClient";
export default async function CliCodeDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const tool = CLI_TOOLS[id];
if (!tool || tool.category !== "code") notFound();
return <ToolDetailClient toolId={id} category="code" />;
}

View File

@@ -8,8 +8,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
export default function AntigravityToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
apiKeys,
activeProviders,

View File

@@ -14,8 +14,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function ClaudeToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
activeProviders,
modelMappings,
onModelMappingChange,

View File

@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function ClineToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
hasActiveProviders,
apiKeys,

View File

@@ -23,7 +23,7 @@ interface UpdateInfo {
updateAvailable: boolean;
}
export default function CliproxyapiToolCard({ isExpanded, onToggle }) {
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
const [toolState, setToolState] = useState<ToolState | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState<string | null>(null);

View File

@@ -10,8 +10,8 @@ import { normalizeCodexBaseUrl } from "@/shared/utils/codexBaseUrl";
export default function CodexToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
apiKeys,
activeProviders,

View File

@@ -15,8 +15,8 @@ import { useTranslations } from "next-intl";
*/
export default function CopilotToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
apiKeys,
activeProviders = [],

View File

@@ -24,8 +24,8 @@ interface CustomCliMappingRow {
export default function CustomCliCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
apiKeys,
availableModels = [],

View File

@@ -13,8 +13,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
export default function DefaultToolCard({
toolId,
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
apiKeys,
activeProviders = [],

View File

@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function DroidToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
hasActiveProviders,
apiKeys,

View File

@@ -25,8 +25,8 @@ const HERMES_ROLES: Role[] = [
export default function HermesAgentToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
apiKeys,
activeProviders = [],

View File

@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function KiloToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
hasActiveProviders,
apiKeys,

View File

@@ -10,8 +10,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function OpenClawToolCard({
tool,
isExpanded,
onToggle,
isExpanded = false,
onToggle = () => {},
baseUrl,
hasActiveProviders,
apiKeys,

View File

@@ -0,0 +1,269 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { PROVIDER_ID_TO_ALIAS, getModelsByProviderId } from "@/shared/constants/models";
import {
AntigravityToolCard,
ClaudeToolCard,
ClineToolCard,
CodexToolCard,
CopilotToolCard,
CustomCliCard,
DefaultToolCard,
DroidToolCard,
HermesAgentToolCard,
KiloToolCard,
OpenClawToolCard,
} from "./index";
import CliproxyapiToolCard from "./CliproxyapiToolCard";
export interface ToolDetailClientProps {
toolId: string;
category: "code" | "agent";
}
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function ToolDetailClient({ toolId, category }: ToolDetailClientProps) {
const tool = CLI_TOOLS[toolId];
const [connections, setConnections] = useState<any[]>([]);
const [apiKeys, setApiKeys] = useState<any[]>([]);
const [cloudEnabled, setCloudEnabled] = useState(false);
const [dynamicModels, setDynamicModels] = useState<any[]>([]);
const [modelMappings, setModelMappings] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const fetchConnections = useCallback(async () => {
try {
const res = await fetch("/api/providers");
if (res.ok) {
const data = await res.json();
setConnections(data.connections || []);
}
} catch (error) {
console.log("Error fetching connections:", error);
}
}, []);
const fetchApiKeys = useCallback(async () => {
try {
const res = await fetch("/api/cli-tools/keys");
if (res.ok) {
const data = await res.json();
setApiKeys(data.keys || []);
}
} catch (error) {
console.log("Error fetching API keys:", error);
}
}, []);
const fetchCloudSettings = useCallback(async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
}
} catch (error) {
console.log("Error loading cloud settings:", error);
}
}, []);
const fetchDynamicModels = useCallback(async () => {
try {
const res = await fetch("/v1/models");
if (res.ok) {
const data = await res.json();
setDynamicModels(data?.data || []);
}
} catch (error) {
console.log("Error fetching dynamic models:", error);
}
}, []);
useEffect(() => {
Promise.all([
fetchConnections(),
fetchApiKeys(),
fetchCloudSettings(),
fetchDynamicModels(),
]).finally(() => setLoading(false));
}, [fetchConnections, fetchApiKeys, fetchCloudSettings, fetchDynamicModels]);
const getActiveProviders = useCallback(() => {
return connections.filter((c) => c.isActive !== false);
}, [connections]);
const getAllAvailableModels = useCallback(() => {
const activeProviders = getActiveProviders();
const models: any[] = [];
const seenModels = new Set<string>();
activeProviders.forEach((conn) => {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
const providerModels = getModelsByProviderId(conn.provider);
providerModels.forEach((m: any) => {
const modelValue = `${alias}/${m.id}`;
if (!seenModels.has(modelValue)) {
seenModels.add(modelValue);
models.push({
value: modelValue,
label: `${alias}/${m.id}`,
provider: conn.provider,
alias,
connectionName: conn.name,
modelId: m.id,
});
}
});
});
const activeAliases = new Set(
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
);
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
dynamicModels.forEach((dm) => {
const rawId = dm?.id ?? dm;
const modelId = typeof rawId === "string" ? rawId : "";
if (!modelId || seenModels.has(modelId)) return;
const slashIdx = modelId.indexOf("/");
if (slashIdx === -1) return;
const alias = modelId.substring(0, slashIdx);
const bareModel = modelId.substring(slashIdx + 1);
if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return;
seenModels.add(modelId);
models.push({
value: modelId,
label: modelId,
provider: alias,
alias,
connectionName: "",
modelId: bareModel,
});
});
return models;
}, [getActiveProviders, dynamicModels]);
const handleModelMappingChange = useCallback((alias: string, targetModel: string) => {
setModelMappings((prev) => {
if (prev[alias] === targetModel) return prev;
return { ...prev, [alias]: targetModel };
});
}, []);
const getBaseUrl = useCallback(() => {
if (cloudEnabled && CLOUD_URL) return CLOUD_URL;
if (typeof window !== "undefined") return window.location.origin;
return "";
}, [cloudEnabled]);
if (!tool) return null;
const activeProviders = getActiveProviders();
const availableModels = getAllAvailableModels();
const hasActiveProviders = availableModels.length > 0;
const backCategory = category === "code" ? "/dashboard/cli-code" : "/dashboard/cli-agents";
// Common props passed to every specialized card.
// isExpanded is always true in the detail page (D23).
const cardProps: any = {
tool,
isExpanded: true,
onToggle: () => {},
baseUrl: getBaseUrl(),
apiKeys,
batchStatus: null,
lastConfiguredAt: null,
activeProviders,
hasActiveProviders,
cloudEnabled,
availableModels,
};
const renderCard = () => {
switch (toolId) {
case "claude":
return (
<ClaudeToolCard
{...cardProps}
modelMappings={modelMappings}
onModelMappingChange={handleModelMappingChange}
/>
);
case "codex":
return <CodexToolCard {...cardProps} />;
case "droid":
return <DroidToolCard {...cardProps} />;
case "openclaw":
return <OpenClawToolCard {...cardProps} />;
case "cline":
return <ClineToolCard {...cardProps} />;
case "kilo":
return <KiloToolCard {...cardProps} />;
case "copilot":
return <CopilotToolCard {...cardProps} />;
case "hermes-agent":
return <HermesAgentToolCard {...cardProps} />;
case "antigravity":
return <AntigravityToolCard {...cardProps} />;
case "cliproxyapi":
return <CliproxyapiToolCard isExpanded={true} onToggle={() => {}} />;
case "custom":
return <CustomCliCard {...cardProps} />;
default:
if (tool.configType === "mitm") {
return <AntigravityToolCard {...cardProps} />;
}
return <DefaultToolCard toolId={toolId} {...cardProps} />;
}
};
return (
<div className="flex flex-col gap-4">
{/* Back navigation */}
<div className="flex items-center gap-2">
<Link
href={backCategory}
className="inline-flex items-center gap-1.5 text-sm text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{category === "code" ? "CLI Code" : "CLI Agents"}
</Link>
<span className="text-text-muted">/</span>
<span className="text-sm font-medium">{tool.name}</span>
</div>
{/* Tool header */}
<div className="flex items-center gap-3 flex-wrap">
{tool.vendor && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-surface border border-border text-text-muted">
{tool.vendor}
</span>
)}
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
{category}
</span>
{tool.baseUrlSupport && tool.baseUrlSupport !== "none" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
<span className="material-symbols-outlined text-[12px]">link</span>
{tool.baseUrlSupport === "full" ? "Full base URL" : "Partial base URL"}
</span>
)}
</div>
{/* Specialized card — always expanded */}
{loading ? (
<div className="flex flex-col gap-4">
<div className="h-24 rounded-xl bg-surface animate-pulse" />
</div>
) : (
renderCard()
)}
</div>
);
}

View File

@@ -1,513 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardSkeleton, SegmentedControl } from "@/shared/components";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import {
PROVIDER_MODELS,
getModelsByProviderId,
PROVIDER_ID_TO_ALIAS,
} from "@/shared/constants/models";
import {
ClaudeToolCard,
CodexToolCard,
DroidToolCard,
OpenClawToolCard,
ClineToolCard,
KiloToolCard,
DefaultToolCard,
AntigravityToolCard,
CopilotToolCard,
CustomCliCard,
HermesAgentToolCard,
} from "./components";
import { useTranslations } from "next-intl";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
const AUTO_CONFIGURED_TOOL_IDS = new Set([
"claude",
"codex",
"droid",
"openclaw",
"cline",
"kilo",
"copilot",
"hermes-agent",
]);
const GUIDED_TOOL_IDS = new Set([
"cursor",
"windsurf",
"continue",
"opencode",
"hermes",
"amp",
"qwen",
]);
const MITM_TOOL_IDS = new Set(["antigravity", "kiro"]);
const CUSTOM_TOOL_IDS = new Set(["custom"]);
export default function CLIToolsPageClient({ machineId: _machineId }) {
const t = useTranslations("cliTools");
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedTool, setExpandedTool] = useState(null);
const [modelMappings, setModelMappings] = useState({});
const [cloudEnabled, setCloudEnabled] = useState(false);
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
const [dynamicModels, setDynamicModels] = useState([]);
const [activeCategory, setActiveCategory] = useState("auto");
const translateOrFallback = useCallback(
(key, fallback, values = undefined) => {
try {
const translated = t(key, values);
return translated === key || translated === `cliTools.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t]
);
useEffect(() => {
fetchConnections();
loadCloudSettings();
fetchApiKeys();
fetchToolStatuses();
fetchDynamicModels();
}, []);
const loadCloudSettings = async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
}
} catch (error) {
console.log("Error loading cloud settings:", error);
}
};
const fetchApiKeys = async () => {
try {
const res = await fetch("/api/cli-tools/keys");
if (res.ok) {
const data = await res.json();
setApiKeys(data.keys || []);
}
} catch (error) {
console.log("Error fetching API keys:", error);
}
};
const fetchToolStatuses = async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s client timeout
const res = await fetch("/api/cli-tools/status", { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
setToolStatuses(data || {});
}
} catch (error) {
// Timeout or network error — proceed without statuses
console.log("CLI tool status check timed out or failed:", error);
} finally {
setStatusesLoaded(true);
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) {
setConnections(data.connections || []);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
}
};
const fetchDynamicModels = async () => {
try {
const res = await fetch("/v1/models");
if (res.ok) {
const data = await res.json();
setDynamicModels(data?.data || []);
}
} catch (error) {
console.log("Error fetching dynamic models:", error);
}
};
const getActiveProviders = () => {
return connections.filter((c) => c.isActive !== false);
};
const getAllAvailableModels = () => {
const activeProviders = getActiveProviders();
const models = [];
const seenModels = new Set();
// First: add static models from the constants
activeProviders.forEach((conn) => {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
const providerModels = getModelsByProviderId(conn.provider);
providerModels.forEach((m) => {
const modelValue = `${alias}/${m.id}`;
if (!seenModels.has(modelValue)) {
seenModels.add(modelValue);
models.push({
value: modelValue,
label: `${alias}/${m.id}`,
provider: conn.provider,
alias: alias,
connectionName: conn.name,
modelId: m.id,
});
}
});
});
// Second: add dynamic models from /v1/models (fills gaps for Kiro, OpenCode, custom providers)
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
const activeAliases = new Set(
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
);
dynamicModels.forEach((dm) => {
const rawId = dm?.id ?? dm;
const modelId = typeof rawId === "string" ? rawId : "";
if (!modelId || seenModels.has(modelId)) return;
// Parse alias/model format
const slashIdx = modelId.indexOf("/");
if (slashIdx === -1) return;
const alias = modelId.substring(0, slashIdx);
const bareModel = modelId.substring(slashIdx + 1);
if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return;
seenModels.add(modelId);
models.push({
value: modelId,
label: modelId,
provider: alias,
alias: alias,
connectionName: "",
modelId: bareModel,
});
});
return models;
};
const handleModelMappingChange = useCallback((toolId, modelAlias, targetModel) => {
setModelMappings((prev) => {
// Prevent unnecessary updates if value hasn't changed
if (prev[toolId]?.[modelAlias] === targetModel) {
return prev;
}
return {
...prev,
[toolId]: {
...prev[toolId],
[modelAlias]: targetModel,
},
};
});
}, []);
const getBaseUrl = () => {
if (cloudEnabled && CLOUD_URL) {
return CLOUD_URL;
}
// Use window.location.origin directly — works correctly in Docker/reverse-proxy
// Per @alpgul feedback: don't use baseUrl prop (has port duplication issues)
if (typeof window !== "undefined") {
return window.location.origin;
}
return DEFAULT_DISPLAY_BASE_URL;
};
if (loading || !statusesLoaded) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}
const availableModels = getAllAvailableModels();
const hasActiveProviders = availableModels.length > 0;
const toolEntries = Object.entries(CLI_TOOLS).filter(([toolId]) => {
if (activeCategory === "all") return true;
if (activeCategory === "auto") return AUTO_CONFIGURED_TOOL_IDS.has(toolId);
if (activeCategory === "guided") return GUIDED_TOOL_IDS.has(toolId);
if (activeCategory === "mitm") return MITM_TOOL_IDS.has(toolId);
if (activeCategory === "custom") return CUSTOM_TOOL_IDS.has(toolId);
return true;
});
const renderToolCard = (toolId, tool) => {
const commonProps = {
tool,
isExpanded: expandedTool === toolId,
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
baseUrl: getBaseUrl(),
apiKeys,
batchStatus: toolStatuses[toolId] || null,
lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null,
};
switch (toolId) {
case "claude":
return (
<ClaudeToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
modelMappings={modelMappings[toolId] || {}}
onModelMappingChange={(alias, target) =>
handleModelMappingChange(toolId, alias, target)
}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "codex":
return (
<CodexToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
cloudEnabled={cloudEnabled}
/>
);
case "droid":
return (
<DroidToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "openclaw":
return (
<OpenClawToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "antigravity":
return (
<AntigravityToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "cline":
return (
<ClineToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "kilo":
return (
<KiloToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "copilot":
return (
<CopilotToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "hermes-agent":
return (
<HermesAgentToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "custom":
return (
<CustomCliCard
key={toolId}
{...commonProps}
availableModels={availableModels}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
default:
// #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls)
if (tool.configType === "mitm") {
return (
<AntigravityToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
}
return (
<DefaultToolCard
key={toolId}
toolId={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
cloudEnabled={cloudEnabled}
/>
);
}
};
const getToolDocsHref = (toolId, tool) => {
if (typeof tool.docsUrl === "string" && tool.docsUrl.trim()) {
return tool.docsUrl.trim();
}
return `/docs?section=cli-tools&tool=${toolId}`;
};
const getToolUseCase = (toolId, tool) => {
const fallbackDescription = translateOrFallback(`toolDescriptions.${toolId}`, tool.description);
return translateOrFallback(`toolUseCases.${toolId}`, fallbackDescription);
};
return (
<div className="flex flex-col gap-6">
<Card>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">tips_and_updates</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold">{t("howItWorks")}</h2>
<div className="mt-2 grid grid-cols-1 md:grid-cols-3 gap-2 text-xs text-text-muted">
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
{t("installationGuide")}
</div>
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
{t("configureEndpoint")}
</div>
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
{t("testConnection")}
</div>
</div>
</div>
</div>
</Card>
<Card>
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-sm font-semibold">{t("toolCategories")}</h2>
<p className="text-xs text-text-muted mt-1">{t("toolCategoriesDesc")}</p>
</div>
<span className="text-xs text-text-muted">
{t("visibleToolsCount", { count: toolEntries.length })}
</span>
</div>
<SegmentedControl
options={[
{ value: "auto", label: t("autoConfiguredTab") },
{ value: "guided", label: t("guidedClientsTab") },
{ value: "mitm", label: t("mitmClientsTab") },
{
value: "custom",
label: translateOrFallback("customCliTab", "Custom CLI"),
},
{ value: "all", label: t("allToolsTab") },
]}
value={activeCategory}
onChange={setActiveCategory}
/>
</div>
</Card>
{!hasActiveProviders && (
<Card className="border-yellow-500/50 bg-yellow-500/5">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div>
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{t("noActiveProviders")}
</p>
<p className="text-sm text-text-muted">{t("noActiveProvidersDesc")}</p>
</div>
</div>
</Card>
)}
<div className="flex flex-col gap-4">
{toolEntries.map(([toolId, tool]) => {
const docsHref = getToolDocsHref(toolId, tool);
const isExternalDocs = /^https?:\/\//i.test(docsHref);
return (
<div key={toolId} className="flex flex-col gap-2.5">
{renderToolCard(toolId, tool)}
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2.5">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{t("whenToUseLabel")}
</p>
<p className="text-xs text-text-muted mt-1 break-words">
{getToolUseCase(toolId, tool)}
</p>
</div>
<a
href={docsHref}
target={isExternalDocs ? "_blank" : undefined}
rel={isExternalDocs ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors whitespace-nowrap"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
menu_book
</span>
{t("openToolDocs")}
</a>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,7 +0,0 @@
import { getMachineId } from "@/shared/utils/machine";
import CLIToolsPageClient from "./CLIToolsPageClient";
export default async function CLIToolsPage() {
const machineId = await getMachineId();
return <CLIToolsPageClient machineId={machineId} />;
}

View File

@@ -0,0 +1,202 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next/link", () => ({
default: ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
// Stub fetch globally
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
});
vi.stubGlobal("fetch", mockFetch);
// Stub next/navigation
vi.mock("next/navigation", () => ({
notFound: () => {
throw new Error("NOT_FOUND");
},
}));
// Stub CLI_TOOLS catalog
vi.mock("@/shared/constants/cliTools", () => ({
CLI_TOOLS: {
claude: {
id: "claude",
name: "Claude Code",
icon: "terminal",
color: "#D97757",
category: "code",
configType: "env",
vendor: "Anthropic",
baseUrlSupport: "full",
defaultModels: [],
},
codex: {
id: "codex",
name: "Codex",
icon: "terminal",
color: "#000",
category: "code",
configType: "custom",
vendor: "OpenAI",
baseUrlSupport: "full",
defaultModels: [],
},
custom: {
id: "custom",
name: "Custom CLI",
icon: "terminal",
color: "#888",
category: "code",
configType: "custom-builder",
vendor: undefined,
baseUrlSupport: "full",
defaultModels: [],
},
"hermes-agent": {
id: "hermes-agent",
name: "Hermes Agent",
icon: "terminal",
color: "#5865f2",
category: "agent",
configType: "custom",
vendor: "HermesAI",
baseUrlSupport: "full",
defaultModels: [],
},
forge: {
id: "forge",
name: "Forge",
icon: "terminal",
color: "#888",
category: "code",
configType: "custom",
vendor: undefined,
baseUrlSupport: "partial",
defaultModels: [],
},
},
}));
// Stub model constants
vi.mock("@/shared/constants/models", () => ({
PROVIDER_ID_TO_ALIAS: {},
getModelsByProviderId: () => [],
}));
// Stub specialized cards — render a testid so we can identify which was rendered
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({
ClaudeToolCard: () => <div data-testid="ClaudeToolCard" />,
CodexToolCard: () => <div data-testid="CodexToolCard" />,
DroidToolCard: () => <div data-testid="DroidToolCard" />,
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
ClineToolCard: () => <div data-testid="ClineToolCard" />,
KiloToolCard: () => <div data-testid="KiloToolCard" />,
DefaultToolCard: ({ toolId }: { toolId: string }) => (
<div data-testid="DefaultToolCard" data-toolid={toolId} />
),
AntigravityToolCard: () => <div data-testid="AntigravityToolCard" />,
CopilotToolCard: () => <div data-testid="CopilotToolCard" />,
CustomCliCard: () => <div data-testid="CustomCliCard" />,
HermesAgentToolCard: () => <div data-testid="HermesAgentToolCard" />,
}));
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard", () => ({
default: () => <div data-testid="CliproxyapiToolCard" />,
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { default: ToolDetailClient } = await import(
"@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: HTMLElement[] = [];
function renderDetail(toolId: string, category: "code" | "agent"): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
act(() => {
root.render(<ToolDetailClient toolId={toolId} category={category} />);
});
return container;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
mockFetch.mockClear();
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ToolDetailClient", () => {
it("renders ClaudeToolCard for toolId=claude", async () => {
const container = renderDetail("claude", "code");
// Wait for async state resolution
await act(async () => {});
expect(container.querySelector("[data-testid='ClaudeToolCard']")).not.toBeNull();
});
it("renders CodexToolCard for toolId=codex", async () => {
const container = renderDetail("codex", "code");
await act(async () => {});
expect(container.querySelector("[data-testid='CodexToolCard']")).not.toBeNull();
});
it("renders CustomCliCard for toolId=custom", async () => {
const container = renderDetail("custom", "code");
await act(async () => {});
expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull();
});
it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => {
const container = renderDetail("forge", "code");
await act(async () => {});
const card = container.querySelector("[data-testid='DefaultToolCard']");
expect(card).not.toBeNull();
expect(card!.getAttribute("data-toolid")).toBe("forge");
});
it("renders nothing (null) for completely unknown toolId", async () => {
const container = renderDetail("totally-unknown-xyz", "code");
await act(async () => {});
// CLI_TOOLS["totally-unknown-xyz"] is undefined → returns null → empty container
expect(container.textContent).toBe("");
});
});

View File

@@ -0,0 +1,123 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next/navigation", () => ({
notFound: () => {
throw new Error("NOT_FOUND");
},
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
vi.mock("next/link", () => ({
default: ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
}));
vi.mock("@/shared/constants/models", () => ({
PROVIDER_ID_TO_ALIAS: {},
getModelsByProviderId: () => [],
}));
// Stub ToolDetailClient — renders a testid with props
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({
default: ({ toolId, category }: { toolId: string; category: string }) => (
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { default: CliAgentsDetailPage } = await import(
"@/app/(dashboard)/dashboard/cli-agents/[id]/page"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: HTMLElement[] = [];
async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> {
let notFoundThrown = false;
let jsx: React.ReactNode | null = null;
try {
jsx = await CliAgentsDetailPage({ params: Promise.resolve({ id }) });
} catch (err: any) {
if (err?.message === "NOT_FOUND") {
notFoundThrown = true;
} else {
throw err;
}
}
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
if (!notFoundThrown && jsx) {
const root = createRoot(container);
act(() => {
root.render(jsx as React.ReactElement);
});
}
return { container, notFound: notFoundThrown };
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("CliAgentsDetailPage", () => {
it("renders ToolDetailClient for /dashboard/cli-agents/hermes-agent (category:agent)", async () => {
const { container, notFound } = await renderPage("hermes-agent");
expect(notFound).toBe(false);
const el = container.querySelector("[data-testid='ToolDetailClient']");
expect(el).not.toBeNull();
expect(el!.getAttribute("data-toolid")).toBe("hermes-agent");
expect(el!.getAttribute("data-category")).toBe("agent");
});
it("returns 404 for /dashboard/cli-agents/claude (category:code — cross-category)", async () => {
const { notFound } = await renderPage("claude");
expect(notFound).toBe(true);
});
it("returns 404 for /dashboard/cli-agents/invalid-id (unknown tool)", async () => {
const { notFound } = await renderPage("invalid-id-xyz");
expect(notFound).toBe(true);
});
});

View File

@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
let notFoundCalled = false;
vi.mock("next/navigation", () => ({
notFound: () => {
notFoundCalled = true;
throw new Error("NOT_FOUND");
},
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
vi.mock("next/link", () => ({
default: ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
}));
vi.mock("@/shared/constants/models", () => ({
PROVIDER_ID_TO_ALIAS: {},
getModelsByProviderId: () => [],
}));
// Stub ToolDetailClient — just renders a testid with the received props
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({
default: ({ toolId, category }: { toolId: string; category: string }) => (
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { default: CliCodeDetailPage } = await import(
"@/app/(dashboard)/dashboard/cli-code/[id]/page"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: HTMLElement[] = [];
async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> {
let notFoundThrown = false;
let jsx: React.ReactNode | null = null;
try {
jsx = await CliCodeDetailPage({ params: Promise.resolve({ id }) });
} catch (err: any) {
if (err?.message === "NOT_FOUND") {
notFoundThrown = true;
} else {
throw err;
}
}
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
if (!notFoundThrown && jsx) {
const root = createRoot(container);
act(() => {
root.render(jsx as React.ReactElement);
});
}
return { container, notFound: notFoundThrown };
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
notFoundCalled = false;
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("CliCodeDetailPage", () => {
it("renders ToolDetailClient for /dashboard/cli-code/claude (category:code)", async () => {
const { container, notFound } = await renderPage("claude");
expect(notFound).toBe(false);
const el = container.querySelector("[data-testid='ToolDetailClient']");
expect(el).not.toBeNull();
expect(el!.getAttribute("data-toolid")).toBe("claude");
expect(el!.getAttribute("data-category")).toBe("code");
});
it("returns 404 for /dashboard/cli-code/hermes-agent (category:agent — cross-category)", async () => {
const { notFound } = await renderPage("hermes-agent");
expect(notFound).toBe(true);
});
it("returns 404 for /dashboard/cli-code/invalid-id (unknown tool)", async () => {
const { notFound } = await renderPage("invalid-id-xyz");
expect(notFound).toBe(true);
});
});