feat: CLI Tools — curated tool catalog, detection, install, chat awareness

New sidebar page for managing system CLI tools (ffmpeg, jq, ripgrep, yt-dlp, pandoc).

Core modules:
- cli-tools-catalog.ts: static catalog of 6 curated tools + EXTRA_WELL_KNOWN_BINS
- cli-tools-detect.ts: system detection via which/where + --version (2min cache)
- cli-tools-context.ts: builds <available_cli_tools> block for chat system prompt

API routes:
- GET /api/cli-tools/catalog, /installed, /[id]/status, /[id]/detail
- POST /api/cli-tools/[id]/install (SSE streaming), /[id]/describe (AI gen)

UI components:
- CliToolsManager: installed + recommended sections with grid layout
- CliToolCard: two variants (installed/recommended) with status badges
- CliToolDetailDialog: intro, use cases, guide steps, example prompts
- CliToolInstallDialog: SSE-based install progress with live terminal log

Chat integration:
- Chat input toolbar: Terminal icon opens CLI tool picker popover
- System prompt injection: buildCliToolsContext() in /api/chat
- MessageInput: CLI popover with search, keyboard nav, locale-aware prefill
- NavRail + ChatListPanel: frosted glass (bg-sidebar/80 backdrop-blur-xl)
- SlashCommandButton: Lightning icon, unified 16px toolbar icons
- input-group: shadow-md → shadow-sm
- Model selector: searchable popover with provider grouping

Docs:
- docs/handover/cli-tools.md + apps/site/content/docs/{en,zh}/cli-tools.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-11 19:56:29 +08:00
1 parent 4e603323c4
commit 16edf9f59c
37 files changed
+3108 -78

No files matched your search

@@ -0,0 +1,351 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { SpinnerGap, CheckCircle, XCircle, Sparkle } from "@phosphor-icons/react";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
interface ProviderModelGroup {
provider_id: string;
provider_name: string;
sdkProxyOnly?: boolean;
models: Array<{ value: string; label: string }>;
}
type AutoDescCache = Record<string, { zh: string; en: string }>;
interface CliToolBatchDescribeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
toolIds: string[];
existingDescriptions: AutoDescCache;
onComplete: (results: AutoDescCache) => void;
}
type Phase = "select" | "running" | "done";
interface ToolResult {
id: string;
status: 'pending' | 'loading' | 'success' | 'error';
error?: string;
}
export function CliToolBatchDescribeDialog({
open,
onOpenChange,
toolIds,
existingDescriptions,
onComplete,
}: CliToolBatchDescribeDialogProps) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("select");
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
const [selectedModel, setSelectedModel] = useState<string>("");
const [toolResults, setToolResults] = useState<ToolResult[]>([]);
const [skipExisting, setSkipExisting] = useState(true);
const resultsRef = useRef<AutoDescCache>({});
const abortControllerRef = useRef<AbortController | null>(null);
// Fetch available models when dialog opens
useEffect(() => {
if (!open) return;
setPhase("select");
abortControllerRef.current = null;
resultsRef.current = {};
setToolResults([]);
fetch("/api/providers/models")
.then(r => r.json())
.then(data => {
const allGroups: ProviderModelGroup[] = data.groups || [];
// All providers are supported — describe uses the same SDK path as chat
const groups = allGroups;
const defaultPid: string = data.default_provider_id || '';
setProviderGroups(groups);
// Auto-select the user's default provider (if not filtered), or fall back to first group
const defaultGroup = groups.find(g => g.provider_id === defaultPid) || groups[0];
if (defaultGroup) {
setSelectedProviderId(defaultGroup.provider_id);
if (defaultGroup.models.length > 0) {
setSelectedModel(defaultGroup.models[0].value);
}
}
})
.catch(err => console.error("Failed to fetch models:", err));
}, [open]);
const selectedGroup = providerGroups.find(g => g.provider_id === selectedProviderId);
const models = selectedGroup?.models || [];
const handleProviderChange = (pid: string) => {
setSelectedProviderId(pid);
const group = providerGroups.find(g => g.provider_id === pid);
if (group && group.models.length > 0) {
setSelectedModel(group.models[0].value);
} else {
setSelectedModel("");
}
};
const handleStart = useCallback(async () => {
const idsToProcess = skipExisting
? toolIds.filter(id => !existingDescriptions[id])
: [...toolIds];
if (idsToProcess.length === 0) {
setPhase("done");
return;
}
const controller = new AbortController();
abortControllerRef.current = controller;
setPhase("running");
setToolResults(idsToProcess.map(id => ({ id, status: 'pending' })));
for (let i = 0; i < idsToProcess.length; i++) {
if (controller.signal.aborted) break;
const toolId = idsToProcess[i];
setToolResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'loading' } : r
));
try {
// Always pass providerId so the server uses exactly what the user selected.
// 'env' is a valid providerId that resolveProvider understands.
const body: Record<string, string> = {
providerId: selectedProviderId,
};
if (selectedModel) {
body.model = selectedModel;
}
const res = await fetch(`/api/cli-tools/${toolId}/describe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${res.status}`);
}
const data = await res.json();
resultsRef.current[toolId] = data.description;
setToolResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'success' } : r
));
} catch (err) {
// Don't record abort errors as tool failures
if (controller.signal.aborted) break;
setToolResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'error', error: err instanceof Error ? err.message : 'Failed' } : r
));
}
}
// Only commit results if not cancelled
if (!controller.signal.aborted && Object.keys(resultsRef.current).length > 0) {
onComplete(resultsRef.current);
}
if (!controller.signal.aborted) {
setPhase("done");
}
}, [toolIds, existingDescriptions, skipExisting, selectedProviderId, selectedModel, onComplete]);
const handleClose = () => {
// Abort any in-flight requests
abortControllerRef.current?.abort();
abortControllerRef.current = null;
onOpenChange(false);
};
const successCount = toolResults.filter(r => r.status === 'success').length;
const errorCount = toolResults.filter(r => r.status === 'error').length;
const toolsToProcessCount = skipExisting
? toolIds.filter(id => !existingDescriptions[id]).length
: toolIds.length;
const existingCount = toolIds.filter(id => !!existingDescriptions[id]).length;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkle size={18} />
{t('cliTools.batchDescribe')}
</DialogTitle>
<DialogDescription>
{t('cliTools.batchDescribeIntro')}
</DialogDescription>
</DialogHeader>
{phase === "select" && providerGroups.length === 0 && (
<p className="text-sm text-muted-foreground py-4">
{t('cliTools.batchNoProvider' as TranslationKey)}
</p>
)}
{phase === "select" && providerGroups.length > 0 && (
<div className="flex flex-col gap-4 py-2">
{/* Provider selector */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t('cliTools.batchProvider')}
</label>
<Select value={selectedProviderId} onValueChange={handleProviderChange}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{providerGroups.map(g => (
<SelectItem key={g.provider_id} value={g.provider_id}>
{g.provider_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model selector */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t('cliTools.batchModel')}
</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map(m => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Skip existing toggle */}
{existingCount > 0 && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={skipExisting}
onChange={e => setSkipExisting(e.target.checked)}
className="rounded border-input"
/>
{t('cliTools.batchSkipExisting', { count: String(existingCount) })}
</label>
)}
{/* Summary */}
<p className="text-xs text-muted-foreground">
{t('cliTools.batchToolCount', { count: String(toolsToProcessCount), total: String(toolIds.length) })}
</p>
</div>
)}
{phase === "running" && (
<div className="flex flex-col gap-2 py-2 max-h-64 overflow-y-auto">
{toolResults.map(r => (
<div key={r.id} className="flex items-center gap-2 text-sm">
{r.status === 'pending' && (
<span className="w-4 h-4 rounded-full border border-muted-foreground/30 shrink-0" />
)}
{r.status === 'loading' && (
<SpinnerGap size={16} className="animate-spin text-blue-500 shrink-0" />
)}
{r.status === 'success' && (
<CheckCircle size={16} weight="fill" className="text-green-500 shrink-0" />
)}
{r.status === 'error' && (
<XCircle size={16} weight="fill" className="text-destructive shrink-0" />
)}
<span className="truncate">{r.id}</span>
{r.status === 'error' && r.error && (
<span className="text-xs text-destructive truncate ml-auto">{r.error}</span>
)}
</div>
))}
</div>
)}
{phase === "done" && (
<div className="flex flex-col gap-2 py-2">
<div className="flex items-center gap-4 text-sm">
{successCount > 0 && (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle size={16} weight="fill" />
{t('cliTools.batchSuccess', { count: String(successCount) })}
</span>
)}
{errorCount > 0 && (
<span className="flex items-center gap-1 text-destructive">
<XCircle size={16} weight="fill" />
{t('cliTools.batchFailed', { count: String(errorCount) })}
</span>
)}
{successCount === 0 && errorCount === 0 && (
<span className="text-muted-foreground">{t('cliTools.batchNothingToProcess')}</span>
)}
</div>
</div>
)}
<DialogFooter>
{phase === "select" && (
<>
<Button variant="ghost" size="sm" onClick={handleClose}>
{t('cliTools.cancel')}
</Button>
{providerGroups.length > 0 && (
<Button
size="sm"
onClick={handleStart}
disabled={!selectedModel || toolsToProcessCount === 0}
className="gap-1.5"
>
<Sparkle size={14} />
{t('cliTools.batchStart')}
</Button>
)}
</>
)}
{phase === "running" && (
<Button variant="ghost" size="sm" onClick={handleClose}>
{t('cliTools.cancel')}
</Button>
)}
{phase === "done" && (
<Button size="sm" onClick={handleClose}>
{t('cliTools.done')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { Plus, CaretDown } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
import type { CliToolDefinition, CliToolRuntimeInfo, CliToolPlatform } from "@/types";
interface CliToolCardProps {
tool: CliToolDefinition;
runtimeInfo?: CliToolRuntimeInfo;
variant: 'installed' | 'recommended';
autoDescription?: { zh: string; en: string };
onDetail: () => void;
onInstall?: (tool: CliToolDefinition, method: string) => void;
locale: string;
platform: string;
}
export function CliToolCard({
tool,
runtimeInfo,
variant,
autoDescription,
onDetail,
onInstall,
locale,
platform,
}: CliToolCardProps) {
const { t } = useTranslation();
const [showMethodPicker, setShowMethodPicker] = useState(false);
const isZh = locale === 'zh';
const availableMethods = tool.installMethods.filter(
m => m.platforms.includes(platform as CliToolPlatform)
);
const summary = autoDescription
? (isZh ? autoDescription.zh : autoDescription.en)
: (isZh ? tool.summaryZh : tool.summaryEn);
const handleInstallClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (availableMethods.length === 1) {
onInstall?.(tool, availableMethods[0].method);
} else if (availableMethods.length > 1) {
setShowMethodPicker(!showMethodPicker);
}
};
const handleMethodSelect = (e: React.MouseEvent, method: string) => {
e.stopPropagation();
setShowMethodPicker(false);
onInstall?.(tool, method);
};
return (
<div
className="flex items-center gap-3 rounded-lg border border-border/40 px-3 py-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={onDetail}
>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
{/* Category tags inline */}
{tool.categories.map(cat => (
<span
key={cat}
className="inline-block rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground shrink-0"
>
{t(`cliTools.category.${cat}` as TranslationKey)}
</span>
))}
{/* Version for installed */}
{variant === 'installed' && runtimeInfo?.version && (
<span className="text-xs text-muted-foreground shrink-0">
v{runtimeInfo.version}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{summary || t('cliTools.noDescription' as TranslationKey)}
</p>
</div>
{/* Right action */}
{variant === 'recommended' && onInstall && availableMethods.length > 0 && (
<div className="shrink-0 relative">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleInstallClick}
title={t('cliTools.install')}
>
{availableMethods.length > 1
? <CaretDown size={16} />
: <Plus size={16} />}
</Button>
{showMethodPicker && availableMethods.length > 1 && (
<div className="absolute right-0 top-8 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[140px]">
{availableMethods.map(m => (
<button
key={m.method}
className="w-full text-left px-2 py-1 text-xs rounded hover:bg-muted transition-colors"
onClick={(e) => handleMethodSelect(e, m.method)}
>
{m.method}: {m.command}
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Copy, ArrowSquareOut, Plus, CaretDown } from "@phosphor-icons/react";
import { useTranslation } from "@/hooks/useTranslation";
import type { CliToolDefinition, CliToolPlatform } from "@/types";
interface CliToolDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tool: CliToolDefinition;
locale: string;
/** If provided, shows install button at the bottom (for recommended tools) */
onInstall?: (tool: CliToolDefinition, method: string) => void;
platform?: string;
}
export function CliToolDetailDialog({
open,
onOpenChange,
tool,
locale,
onInstall,
platform,
}: CliToolDetailDialogProps) {
const { t } = useTranslation();
const isZh = locale === 'zh';
const [showMethodPicker, setShowMethodPicker] = useState(false);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const availableMethods = platform
? tool.installMethods.filter(m => m.platforms.includes(platform as CliToolPlatform))
: tool.installMethods;
const handleInstallClick = () => {
if (availableMethods.length === 1) {
onInstall?.(tool, availableMethods[0].method);
} else if (availableMethods.length > 1) {
setShowMethodPicker(!showMethodPicker);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{tool.name}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-5">
{/* Intro */}
<section>
<h3 className="text-sm font-medium mb-2">{t('cliTools.detailIntro')}</h3>
<p className="text-sm text-muted-foreground">
{isZh ? tool.detailIntro.zh : tool.detailIntro.en}
</p>
</section>
{/* Use cases */}
<section>
<h3 className="text-sm font-medium mb-2">{t('cliTools.useCases')}</h3>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
{(isZh ? tool.useCases.zh : tool.useCases.en).map((uc, i) => (
<li key={i}>{uc}</li>
))}
</ul>
</section>
{/* Guide steps */}
<section>
<h3 className="text-sm font-medium mb-2">{t('cliTools.guideSteps')}</h3>
<ol className="text-sm text-muted-foreground list-decimal list-inside space-y-1">
{(isZh ? tool.guideSteps.zh : tool.guideSteps.en).map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</section>
{/* Example prompts */}
<section>
<h3 className="text-sm font-medium mb-2">{t('cliTools.examplePrompts')}</h3>
<div className="space-y-2">
{tool.examplePrompts.map((ep, i) => (
<div key={i} className="rounded-md border bg-muted/30 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium mb-1">{ep.label}</p>
<p className="text-xs text-muted-foreground">
{isZh ? ep.promptZh : ep.promptEn}
</p>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(isZh ? ep.promptZh : ep.promptEn)}
title={t('cliTools.copy')}
>
<Copy size={12} />
</Button>
</div>
</div>
</div>
))}
</div>
</section>
{/* Links */}
{(tool.homepage || tool.repoUrl || tool.officialDocsUrl) && (
<section className="flex flex-wrap gap-2 pt-2 border-t">
{tool.homepage && (
<a
href={tool.homepage}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ArrowSquareOut size={12} />
{t('cliTools.homepage')}
</a>
)}
{tool.repoUrl && (
<a
href={tool.repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ArrowSquareOut size={12} />
GitHub
</a>
)}
{tool.officialDocsUrl && (
<a
href={tool.officialDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ArrowSquareOut size={12} />
{t('cliTools.docs')}
</a>
)}
</section>
)}
</div>
{/* Install button — only for recommended (not installed) tools */}
{onInstall && availableMethods.length > 0 && (
<DialogFooter className="relative">
<Button
size="sm"
className="gap-1.5"
onClick={handleInstallClick}
>
<Plus size={14} />
{t('cliTools.install')}
{availableMethods.length > 1 && <CaretDown size={12} />}
</Button>
{showMethodPicker && availableMethods.length > 1 && (
<div className="absolute right-0 bottom-10 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[180px]">
{availableMethods.map(m => (
<button
key={m.method}
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-muted transition-colors"
onClick={() => {
setShowMethodPicker(false);
onInstall(tool, m.method);
}}
>
{m.method}: {m.command}
</button>
))}
</div>
)}
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,78 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslation } from "@/hooks/useTranslation";
import type { CliToolRuntimeInfo } from "@/types";
interface CliToolExtraDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
displayName: string;
runtimeInfo: CliToolRuntimeInfo;
autoDescription?: { zh: string; en: string };
locale: string;
}
export function CliToolExtraDetailDialog({
open,
onOpenChange,
displayName,
runtimeInfo,
autoDescription,
locale,
}: CliToolExtraDetailDialogProps) {
const { t } = useTranslation();
const isZh = locale === 'zh';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{displayName}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* AI-generated description */}
{autoDescription && (
<section>
<h3 className="text-sm font-medium mb-2">{t('cliTools.detailIntro')}</h3>
<p className="text-sm text-muted-foreground">
{isZh ? autoDescription.zh : autoDescription.en}
</p>
</section>
)}
{/* Runtime info */}
<section>
<h3 className="text-sm font-medium mb-2">{t('cliTools.toolInfo')}</h3>
<div className="text-sm text-muted-foreground space-y-1">
{runtimeInfo.version && (
<div className="flex gap-2">
<span className="text-foreground/70">{t('cliTools.version')}:</span>
<span>{runtimeInfo.version}</span>
</div>
)}
{runtimeInfo.binPath && (
<div className="flex gap-2">
<span className="text-foreground/70 shrink-0">{t('cliTools.path')}:</span>
<span className="break-all font-mono text-xs">{runtimeInfo.binPath}</span>
</div>
)}
</div>
</section>
{!autoDescription && (
<p className="text-sm text-muted-foreground italic">
{t('cliTools.noDescription')}
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { SpinnerGap, CheckCircle, X } from "@phosphor-icons/react";
import { useTranslation } from "@/hooks/useTranslation";
import type { CliToolDefinition } from "@/types";
interface CliToolInstallDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tool: CliToolDefinition;
method: string;
onComplete: () => void;
}
type Phase = "running" | "success" | "error";
export function CliToolInstallDialog({
open,
onOpenChange,
tool,
method,
onComplete,
}: CliToolInstallDialogProps) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("running");
const [logs, setLogs] = useState<string[]>([]);
const logsEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const startProcess = useCallback(async () => {
setPhase("running");
setLogs([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(`/api/cli-tools/${tool.id}/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
setPhase("error");
setLogs((prev) => [...prev, `HTTP ${res.status}: ${res.statusText}`]);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let currentEvent = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
const raw = line.slice(6);
let data: string;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
if (currentEvent === "output") {
setLogs((prev) => [...prev, data]);
} else if (currentEvent === "done") {
setPhase("success");
} else if (currentEvent === "error") {
setPhase("error");
setLogs((prev) => [...prev, `Error: ${data}`]);
}
}
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
setPhase("error");
setLogs((prev) => [...prev, (err as Error).message]);
}
}
}, [tool.id, method]);
useEffect(() => {
if (open) {
startProcess();
}
return () => {
abortRef.current?.abort();
};
}, [open, startProcess]);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
const handleClose = () => {
abortRef.current?.abort();
if (phase === "success") {
onComplete();
}
onOpenChange(false);
};
const installMethod = tool.installMethods.find(m => m.method === method);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{phase === "running" && (
<SpinnerGap size={20} className="animate-spin text-primary" />
)}
{phase === "success" && (
<CheckCircle size={20} className="text-green-500" />
)}
{phase === "error" && (
<X size={20} className="text-red-500" />
)}
{phase === "running"
? `${t('cliTools.installing')} ${tool.name}...`
: phase === "success"
? t('cliTools.installSuccess')
: t('cliTools.installFailed')}
</DialogTitle>
</DialogHeader>
{installMethod && (
<div className="text-xs text-muted-foreground font-mono bg-muted/30 rounded px-2 py-1">
$ {installMethod.command}
</div>
)}
<div className="bg-muted/50 rounded-md p-3 max-h-64 overflow-y-auto font-mono text-xs leading-relaxed">
{logs.length === 0 && phase === "running" && (
<span className="text-muted-foreground">{t('cliTools.installing')}...</span>
)}
{logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{line}
</div>
))}
<div ref={logsEndRef} />
</div>
<DialogFooter>
<Button onClick={handleClose}>
{phase === "running" ? t('common.cancel') : t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,267 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
import type { CliToolDefinition, CliToolRuntimeInfo } from "@/types";
import { CliToolCard } from "./CliToolCard";
import { CliToolDetailDialog } from "./CliToolDetailDialog";
import { CliToolExtraDetailDialog } from "./CliToolExtraDetailDialog";
import { CliToolInstallDialog } from "./CliToolInstallDialog";
import { CliToolBatchDescribeDialog } from "./CliToolBatchDescribeDialog";
import { SpinnerGap, Sparkle, ArrowSquareOut, Warning } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { EXTRA_WELL_KNOWN_BINS } from "@/lib/cli-tools-catalog";
type AutoDescCache = Record<string, { zh: string; en: string }>;
export function CliToolsManager() {
const { t, locale } = useTranslation();
const [catalog, setCatalog] = useState<CliToolDefinition[]>([]);
const [runtimeInfos, setRuntimeInfos] = useState<CliToolRuntimeInfo[]>([]);
const [extraDetected, setExtraDetected] = useState<CliToolRuntimeInfo[]>([]);
const [platform, setPlatform] = useState<string>('');
const [hasBrew, setHasBrew] = useState(true);
const [loading, setLoading] = useState(true);
const [autoDescriptions, setAutoDescriptions] = useState<AutoDescCache>({});
// Dialog state
const [detailTool, setDetailTool] = useState<{ tool: CliToolDefinition; canInstall: boolean } | null>(null);
const [extraDetailTool, setExtraDetailTool] = useState<{ displayName: string; runtimeInfo: CliToolRuntimeInfo } | null>(null);
const [installTool, setInstallTool] = useState<{ tool: CliToolDefinition; method: string } | null>(null);
const [batchDescribeOpen, setBatchDescribeOpen] = useState(false);
const fetchData = useCallback(async () => {
try {
const [catalogRes, installedRes] = await Promise.all([
fetch('/api/cli-tools/catalog'),
fetch('/api/cli-tools/installed'),
]);
const catalogData = await catalogRes.json();
const installedData = await installedRes.json();
setCatalog(catalogData.tools || []);
setRuntimeInfos(installedData.tools || []);
setExtraDetected(installedData.extra || []);
setPlatform(installedData.platform || '');
setHasBrew(installedData.hasBrew !== false);
} catch (err) {
console.error('Failed to fetch CLI tools data:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
// Load cached auto-descriptions from localStorage
try {
const cached = localStorage.getItem('cli-tools-auto-desc');
if (cached) setAutoDescriptions(JSON.parse(cached));
} catch { /* ignore */ }
}, [fetchData]);
const getRuntimeInfo = (toolId: string): CliToolRuntimeInfo | undefined => {
return runtimeInfos.find(r => r.id === toolId);
};
const installedCatalogTools = catalog.filter(t => {
const info = getRuntimeInfo(t.id);
return info && info.status !== 'not_installed';
});
const recommendedTools = catalog.filter(t => {
const info = getRuntimeInfo(t.id);
return !info || info.status === 'not_installed';
});
// Only extra-detected tool IDs for batch describe (catalog tools already have descriptions)
const extraToolIds = extraDetected.map(e => e.id);
const handleInstall = (tool: CliToolDefinition, method: string) => {
setInstallTool({ tool, method });
};
const handleInstallComplete = () => {
setInstallTool(null);
fetchData();
};
const handleBatchDescribeComplete = (results: AutoDescCache) => {
const merged = { ...autoDescriptions, ...results };
setAutoDescriptions(merged);
localStorage.setItem('cli-tools-auto-desc', JSON.stringify(merged));
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<SpinnerGap size={24} className="animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col gap-6 overflow-y-auto">
<div>
<h1 className="text-xl font-semibold">{t('cliTools.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('cliTools.description')}</p>
</div>
{/* Installed — catalog tools + extra system-detected tools merged */}
{(installedCatalogTools.length > 0 || extraDetected.length > 0) && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-muted-foreground">{t('cliTools.installed')}</h2>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setBatchDescribeOpen(true)}
>
<Sparkle size={14} />
{t('cliTools.batchDescribe')}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{/* Catalog tools that are installed */}
{installedCatalogTools.map(tool => (
<CliToolCard
key={tool.id}
tool={tool}
runtimeInfo={getRuntimeInfo(tool.id)!}
variant="installed"
autoDescription={autoDescriptions[tool.id]}
onDetail={() => setDetailTool({ tool, canInstall: false })}
locale={locale}
platform={platform}
/>
))}
{/* Extra system-detected tools (not in catalog) */}
{extraDetected.map(info => {
const entry = EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === info.id);
const displayName = entry?.[1] ?? info.id;
const desc = autoDescriptions[info.id];
return (
<div
key={info.id}
className="flex items-center gap-3 rounded-lg border border-border/40 px-3 py-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => setExtraDetailTool({ displayName, runtimeInfo: info })}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm truncate">{displayName}</h3>
{info.version && (
<span className="text-xs text-muted-foreground shrink-0">
v{info.version}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{desc
? (locale === 'zh' ? desc.zh : desc.en)
: t('cliTools.noDescription' as TranslationKey)}
</p>
</div>
</div>
);
})}
</div>
</section>
)}
{/* Recommended (not installed) */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-3">{t('cliTools.recommended')}</h2>
{/* Brew not installed warning */}
{!hasBrew && (platform === 'darwin' || platform === 'linux') && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 mb-3">
<Warning size={16} className="text-amber-500 shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">{t('cliTools.brewNotInstalled')}</p>
<p>{t('cliTools.brewInstallGuide')}</p>
<code className="block mt-1.5 bg-muted/50 rounded px-2 py-1 text-[11px] font-mono select-all">
/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
</code>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{recommendedTools.map(tool => (
<CliToolCard
key={tool.id}
tool={tool}
runtimeInfo={getRuntimeInfo(tool.id)}
variant="recommended"
onDetail={() => setDetailTool({ tool, canInstall: true })}
onInstall={handleInstall}
locale={locale}
platform={platform}
/>
))}
</div>
{recommendedTools.length === 0 && (
<p className="text-sm text-muted-foreground">{t('cliTools.allInstalled')}</p>
)}
</section>
{/* Docs link */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground pt-2">
<ArrowSquareOut size={12} />
<a
href={locale === 'zh' ? 'https://www.codepilot.sh/zh/docs' : 'https://www.codepilot.sh/docs'}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground hover:underline transition-colors"
>
{t('cliTools.viewDocs')}
</a>
</div>
{/* Detail dialog */}
{detailTool && (
<CliToolDetailDialog
open={!!detailTool}
onOpenChange={(open) => !open && setDetailTool(null)}
tool={detailTool.tool}
locale={locale}
onInstall={detailTool.canInstall ? handleInstall : undefined}
platform={platform}
/>
)}
{/* Extra tool detail dialog */}
{extraDetailTool && (
<CliToolExtraDetailDialog
open={!!extraDetailTool}
onOpenChange={(open) => !open && setExtraDetailTool(null)}
displayName={extraDetailTool.displayName}
runtimeInfo={extraDetailTool.runtimeInfo}
autoDescription={autoDescriptions[extraDetailTool.runtimeInfo.id]}
locale={locale}
/>
)}
{/* Install dialog */}
{installTool && (
<CliToolInstallDialog
open={!!installTool}
onOpenChange={(open) => !open && setInstallTool(null)}
tool={installTool.tool}
method={installTool.method}
onComplete={handleInstallComplete}
/>
)}
{/* Batch AI describe dialog */}
<CliToolBatchDescribeDialog
open={batchDescribeOpen}
onOpenChange={setBatchDescribeOpen}
toolIds={extraToolIds}
existingDescriptions={autoDescriptions}
onComplete={handleBatchDescribeComplete}
/>
</div>
);
}