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

+1 -1
View File
@@ -479,7 +479,7 @@ describe('Provider Resolver', () => {
const config = toAiSdkConfig(resolved);
assert.equal(config.sdkType, 'anthropic');
assert.equal(config.apiKey, 'key');
assert.equal(config.baseUrl, 'https://api.anthropic.com');
assert.equal(config.baseUrl, 'https://api.anthropic.com/v1');
assert.equal(config.modelId, 'sonnet');
assert.deepEqual(config.processEnvInjections, {});
});
+11
View File
@@ -304,6 +304,17 @@ Start by greeting the user and asking the first question.
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions;
}
// Inject available CLI tools context (best-effort, non-blocking)
try {
const { buildCliToolsContext } = await import('@/lib/cli-tools-context');
const cliToolsCtx = await buildCliToolsContext();
if (cliToolsCtx) {
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + cliToolsCtx;
}
} catch {
// CLI tools context injection failed — don't block chat
}
// Load recent conversation history from DB as fallback context.
// This is used when SDK session resume is unavailable or fails,
// so the model still has conversation context.
@@ -0,0 +1,105 @@
import { NextResponse } from 'next/server';
import { CLI_TOOLS_CATALOG, EXTRA_WELL_KNOWN_BINS } from '@/lib/cli-tools-catalog';
import { generateTextViaSdk } from '@/lib/claude-client';
/**
* Try to extract a JSON object from text that may be wrapped in markdown code blocks
* or contain leading/trailing non-JSON content.
*/
function extractJson(raw: string): Record<string, string> {
// Try direct parse first
const trimmed = raw.trim();
try { return JSON.parse(trimmed); } catch { /* continue */ }
// Strip markdown code fences
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (fenceMatch) {
try { return JSON.parse(fenceMatch[1].trim()); } catch { /* continue */ }
}
// Find first { ... last }
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start !== -1 && end > start) {
try { return JSON.parse(trimmed.slice(start, end + 1)); } catch { /* continue */ }
}
throw new Error('AI response was not valid JSON');
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Look up in catalog first, then in extra well-known list
const catalogTool = CLI_TOOLS_CATALOG.find(t => t.id === id);
const extraEntry = !catalogTool ? EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === id) : null;
if (!catalogTool && !extraEntry) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
}
if (catalogTool && !catalogTool.supportsAutoDescribe) {
return NextResponse.json(
{ error: 'Auto-describe not supported for this tool' },
{ status: 400 }
);
}
try {
const body = await request.json();
const { providerId, model: requestModel } = body as { providerId?: string; model?: string };
const toolName = catalogTool?.name ?? extraEntry?.[1] ?? id;
const binNames = catalogTool?.binNames.join(', ') ?? extraEntry?.[2] ?? id;
const categories = catalogTool?.categories.join(', ') ?? 'general';
const homepage = catalogTool?.homepage ?? 'N/A';
const prompt = `You are a technical writer. Write a brief, practical description of the CLI tool "${toolName}" (binary: ${binNames}).
Categories: ${categories}
Homepage: ${homepage}
Provide the description in both Chinese and English. Focus on practical use cases in AI-assisted development workflows.
Respond in this exact JSON format (no markdown, no code fences, just raw JSON):
{"zh": "中文描述(2-3句话)", "en": "English description (2-3 sentences)"}`;
let result: string;
try {
result = await generateTextViaSdk({
providerId: providerId || undefined,
model: requestModel || undefined,
system: 'You are a technical documentation writer. Respond with raw JSON only, no markdown formatting.',
prompt,
});
} catch (genError) {
console.error(`[cli-tools/${id}/describe] generateTextViaSdk threw:`, genError);
const msg = genError instanceof Error ? genError.message : 'Text generation failed';
return NextResponse.json({ error: msg }, { status: 502 });
}
if (!result || !result.trim()) {
return NextResponse.json(
{ error: 'AI returned an empty response. Please check your provider configuration.' },
{ status: 502 }
);
}
const parsed = extractJson(result);
if (!parsed.zh || !parsed.en) {
return NextResponse.json(
{ error: 'AI response missing required zh/en fields' },
{ status: 502 }
);
}
return NextResponse.json({ description: parsed });
} catch (error) {
console.error(`[cli-tools/${id}/describe] Error:`, error);
const message = error instanceof Error ? error.message : 'Description generation failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const tool = CLI_TOOLS_CATALOG.find(t => t.id === id);
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
}
return NextResponse.json({
id: tool.id,
name: tool.name,
detailIntro: tool.detailIntro,
useCases: tool.useCases,
guideSteps: tool.guideSteps,
examplePrompts: tool.examplePrompts,
homepage: tool.homepage,
repoUrl: tool.repoUrl,
officialDocsUrl: tool.officialDocsUrl,
});
}
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog';
import { invalidateDetectCache } from '@/lib/cli-tools-detect';
import { getExpandedPath } from '@/lib/platform';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const tool = CLI_TOOLS_CATALOG.find(t => t.id === id);
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
}
try {
const body = await request.json();
const { method } = body as { method: string };
// Security: only execute commands declared in the catalog
const installMethod = tool.installMethods.find(m => m.method === method);
if (!installMethod) {
return NextResponse.json(
{ error: `Install method "${method}" not available for ${tool.name}` },
{ status: 400 }
);
}
// Platform check: reject if this method doesn't support the current platform
const currentPlatform = process.platform as 'darwin' | 'linux' | 'win32';
if (!installMethod.platforms.includes(currentPlatform)) {
return NextResponse.json(
{ error: `Install method "${method}" is not supported on ${currentPlatform}` },
{ status: 400 }
);
}
// Parse the command into executable and args
const parts = installMethod.command.split(/\s+/);
const cmd = parts[0];
const args = parts.slice(1);
const child = spawn(cmd, args, {
env: { ...process.env, PATH: getExpandedPath() },
shell: true,
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: string) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
};
child.stdout?.on('data', (chunk: Buffer) => {
send('output', chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer) => {
send('output', chunk.toString());
});
child.on('close', (code) => {
invalidateDetectCache();
if (code === 0) {
send('done', 'Install completed successfully');
} else {
send('error', `Process exited with code ${code}`);
}
controller.close();
});
child.on('error', (err) => {
send('error', err.message);
controller.close();
});
},
cancel() {
child.kill();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
console.error(`[cli-tools/${id}/install] Error:`, error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Install failed' },
{ status: 500 }
);
}
}
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog';
import { detectCliTool } from '@/lib/cli-tools-detect';
export const dynamic = 'force-dynamic';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const tool = CLI_TOOLS_CATALOG.find(t => t.id === id);
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
}
try {
const info = await detectCliTool(tool);
return NextResponse.json(info);
} catch (error) {
console.error(`[cli-tools/${id}/status] Error:`, error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Detection failed' },
{ status: 500 }
);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
import { CLI_TOOLS_CATALOG } from '@/lib/cli-tools-catalog';
export async function GET() {
return NextResponse.json({ tools: CLI_TOOLS_CATALOG });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { detectAllCliTools } from '@/lib/cli-tools-detect';
import { getExpandedPath } from '@/lib/platform';
const execFileAsync = promisify(execFile);
export const dynamic = 'force-dynamic';
async function detectBrew(): Promise<boolean> {
try {
await execFileAsync('/usr/bin/which', ['brew'], {
timeout: 3000,
env: { ...process.env, PATH: getExpandedPath() },
});
return true;
} catch {
return false;
}
}
export async function GET() {
try {
const [{ catalog, extra }, hasBrew] = await Promise.all([
detectAllCliTools(),
detectBrew(),
]);
return NextResponse.json({
tools: catalog,
extra,
platform: process.platform,
hasBrew,
});
} catch (error) {
console.error('[cli-tools/installed] Error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Detection failed' },
{ status: 500 }
);
}
}
+16 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { getAllProviders, getDefaultProviderId, getModelsForProvider } from '@/lib/db';
import { getAllProviders, getDefaultProviderId, getModelsForProvider, getSetting } from '@/lib/db';
import { getContextWindow } from '@/lib/model-context';
import { getDefaultModelsForProvider, inferProtocolFromLegacy } from '@/lib/provider-catalog';
import { getDefaultModelsForProvider, inferProtocolFromLegacy, findPresetForLegacy } from '@/lib/provider-catalog';
import type { Protocol } from '@/lib/provider-catalog';
import type { ErrorResponse, ProviderModelGroup } from '@/types';
@@ -45,10 +45,19 @@ export async function GET() {
const groups: ProviderModelGroup[] = [];
// Always show the built-in Claude Code provider group.
// Mark it as sdkProxyOnly if no direct API credentials exist — in that case
// the env provider only works through the Claude Code SDK subprocess, not the
// Vercel AI SDK text generation path used by features like AI Describe.
const envHasDirectCredentials = !!(
process.env.ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_AUTH_TOKEN ||
getSetting('anthropic_auth_token')
);
groups.push({
provider_id: 'env',
provider_name: 'Claude Code',
provider_type: 'anthropic',
...(!envHasDirectCredentials ? { sdkProxyOnly: true } : {}),
models: DEFAULT_MODELS.map(m => {
const cw = getContextWindow(m.value);
return cw != null ? { ...m, contextWindow: cw } : m;
@@ -155,10 +164,15 @@ export async function GET() {
};
});
// Detect SDK-proxy-only providers via preset match
const preset = findPresetForLegacy(provider.base_url, provider.provider_type);
const sdkProxyOnly = preset?.sdkProxyOnly === true;
groups.push({
provider_id: provider.id,
provider_name: provider.name,
provider_type: provider.provider_type,
...(sdkProxyOnly ? { sdkProxyOnly: true } : {}),
models,
});
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { CliToolsManager } from "@/components/cli-tools/CliToolsManager";
export default function CliToolsPage() {
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-hidden p-6 flex flex-col min-h-0">
<CliToolsManager />
</div>
</div>
);
}
+415 -60
View File
@@ -18,6 +18,8 @@ import {
Brain,
GlobeSimple,
Stop,
Lightning,
Gear,
} from "@phosphor-icons/react";
import type { Icon } from "@phosphor-icons/react";
import { cn } from '@/lib/utils';
@@ -36,6 +38,11 @@ import type { ChatStatus } from 'ai';
import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types';
import { nanoid } from 'nanoid';
import { SlashCommandButton } from './SlashCommandButton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useImageGen } from '@/hooks/useImageGen';
import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store';
@@ -126,7 +133,19 @@ interface CommandBadge {
installedSource?: "agents" | "claude";
}
type PopoverMode = 'file' | 'skill' | null;
interface CliBadge {
id: string;
name: string;
}
interface CliToolItem {
id: string;
name: string;
version: string | null;
summary: string;
}
type PopoverMode = 'file' | 'skill' | 'cli' | null;
// Expansion prompts for CLI-only commands (not natively supported by SDK).
// SDK-native commands (/compact, /init, /review) are sent as-is — the SDK handles them directly.
@@ -236,7 +255,7 @@ function AttachFileButton() {
onClick={() => attachments.openFileDialog()}
tooltip={t('messageInput.attachFiles')}
>
<Plus size={14} />
<Plus size={16} />
</PromptInputButton>
);
}
@@ -327,7 +346,7 @@ export function MessageInput({
onEffortChange,
sdkInitMeta,
}: MessageInputProps) {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const imageGen = useImageGen();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
@@ -340,8 +359,13 @@ export function MessageInput({
const [selectedIndex, setSelectedIndex] = useState(0);
const [triggerPos, setTriggerPos] = useState<number | null>(null);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [modelSearch, setModelSearch] = useState('');
const [inputValue, setInputValue] = useState('');
const [badge, setBadge] = useState<CommandBadge | null>(null);
const [cliBadge, setCliBadge] = useState<CliBadge | null>(null);
const [cliTools, setCliTools] = useState<CliToolItem[]>([]);
const [cliFilter, setCliFilter] = useState('');
const cliSearchRef = useRef<HTMLInputElement>(null);
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
const [defaultProviderId, setDefaultProviderId] = useState<string>('');
const [aiSuggestions, setAiSuggestions] = useState<PopoverItem[]>([]);
@@ -525,6 +549,81 @@ export function MessageInput({
return [...BUILT_IN_COMMANDS, ...uniqueSkills];
}, [workingDirectory, sdkInitMeta]);
// Fetch CLI tools for CLI popover
const fetchCliTools = useCallback(async () => {
try {
const [installedRes, catalogRes] = await Promise.all([
fetch('/api/cli-tools/installed'),
fetch('/api/cli-tools/catalog'),
]);
const installedData = await installedRes.json();
const catalogData = await catalogRes.json();
const catalogTools = catalogData.tools || [];
const runtimeInfos = installedData.tools || [];
const extraDetected = installedData.extra || [];
// Build lookup for catalog summaries
const catalogMap = new Map<string, { name: string; summaryZh: string; summaryEn: string }>();
for (const ct of catalogTools) {
catalogMap.set(ct.id, { name: ct.name, summaryZh: ct.summaryZh, summaryEn: ct.summaryEn });
}
// Extra well-known names lookup
const extraNames: Record<string, string> = {};
try {
const { EXTRA_WELL_KNOWN_BINS } = await import('@/lib/cli-tools-catalog');
for (const [id, name] of EXTRA_WELL_KNOWN_BINS) {
extraNames[id] = name;
}
} catch { /* ignore */ }
// Load cached AI descriptions
let autoDesc: Record<string, { zh: string; en: string }> = {};
try {
const cached = localStorage.getItem('cli-tools-auto-desc');
if (cached) autoDesc = JSON.parse(cached);
} catch { /* ignore */ }
const locale = document.documentElement.lang === 'zh' ? 'zh' : 'en';
const items: CliToolItem[] = [];
// Installed catalog tools
for (const ri of runtimeInfos) {
if (ri.status !== 'installed') continue;
const cat = catalogMap.get(ri.id);
const ad = autoDesc[ri.id];
const summary = ad
? (locale === 'zh' ? ad.zh : ad.en)
: cat
? (locale === 'zh' ? cat.summaryZh : cat.summaryEn)
: '';
items.push({
id: ri.id,
name: cat?.name || ri.id,
version: ri.version,
summary,
});
}
// Extra detected tools
for (const ri of extraDetected) {
const ad = autoDesc[ri.id];
const summary = ad ? (locale === 'zh' ? ad.zh : ad.en) : '';
items.push({
id: ri.id,
name: extraNames[ri.id] || ri.id,
version: ri.version,
summary,
});
}
setCliTools(items);
} catch {
setCliTools([]);
}
}, []);
// Close popover
const closePopover = useCallback(() => {
setPopoverMode(null);
@@ -551,6 +650,52 @@ export function MessageInput({
setTimeout(() => textareaRef.current?.focus(), 0);
}, []);
// Remove CLI badge
const removeCliBadge = useCallback(() => {
setCliBadge(null);
setTimeout(() => textareaRef.current?.focus(), 0);
}, []);
// Handle CLI tool selection from popover
const handleCliSelect = useCallback((tool: CliToolItem) => {
closePopover();
setCliFilter('');
if (!inputValue.trim()) {
// Empty input: prefill with prompt template
const prefix = locale === 'zh'
? `我想用 ${tool.name} 工具完成:`
: `I want to use ${tool.name} to: `;
setInputValue(prefix);
setTimeout(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.selectionStart = ta.selectionEnd = prefix.length;
}
}, 0);
} else {
// Non-empty input: set CLI badge
setCliBadge({ id: tool.id, name: tool.name });
setTimeout(() => textareaRef.current?.focus(), 0);
}
}, [inputValue, locale, closePopover]);
// Open CLI popover
const handleOpenCliPopover = useCallback(async () => {
if (popoverMode === 'cli') {
closePopover();
return;
}
closePopover();
setPopoverMode('cli');
setCliFilter('');
setSelectedIndex(0);
// Focus search input on next render (before fetch completes)
setTimeout(() => cliSearchRef.current?.focus(), 0);
fetchCliTools();
}, [popoverMode, closePopover, fetchCliTools]);
// Insert selected item
const insertItem = useCallback((item: PopoverItem) => {
if (triggerPos === null) return;
@@ -628,7 +773,8 @@ export function MessageInput({
return;
}
if (popoverMode) {
// Only auto-close text-triggered popovers (file/skill); CLI is button-triggered
if (popoverMode && popoverMode !== 'cli') {
closePopover();
}
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
@@ -782,9 +928,15 @@ export function MessageInput({
}
}
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined);
// If CLI badge is active, inject systemPromptAppend to guide model
const cliAppend = cliBadge
? `The user wants to use the installed CLI tool "${cliBadge.name}" if appropriate for this task. Prefer using "${cliBadge.name}" when suitable.`
: undefined;
if (cliBadge) setCliBadge(null);
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined, cliAppend);
setInputValue('');
}, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen]);
}, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, cliBadge, imageGen]);
const filteredItems = popoverItems.filter((item) => {
const q = popoverFilter.toLowerCase();
@@ -891,8 +1043,8 @@ export function MessageInput({
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Popover navigation
if (popoverMode && popoverItems.length > 0) {
// Popover navigation (skill/file mode)
if (popoverMode && popoverMode !== 'cli' && popoverItems.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % allDisplayedItems.length);
@@ -917,21 +1069,51 @@ export function MessageInput({
}
}
// CLI popover keyboard navigation
if (popoverMode === 'cli') {
if (e.key === 'Escape') {
e.preventDefault();
closePopover();
return;
}
if (cliTools.length > 0) {
const q = cliFilter.toLowerCase();
const filtered = cliTools.filter(t =>
t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q)
);
if (filtered.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (filtered[selectedIndex]) handleCliSelect(filtered[selectedIndex]);
return;
}
}
}
}
// Backspace removes badge when input is empty
if (e.key === 'Backspace' && badge && !inputValue) {
e.preventDefault();
removeBadge();
return;
if (e.key === 'Backspace' && !inputValue) {
if (badge) { e.preventDefault(); removeBadge(); return; }
if (cliBadge) { e.preventDefault(); removeCliBadge(); return; }
}
// Escape removes badge
if (e.key === 'Escape' && badge) {
e.preventDefault();
removeBadge();
return;
if (e.key === 'Escape') {
if (badge) { e.preventDefault(); removeBadge(); return; }
if (cliBadge) { e.preventDefault(); removeCliBadge(); return; }
}
},
[popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge, allDisplayedItems]
[popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, cliBadge, inputValue, removeBadge, removeCliBadge, allDisplayedItems, cliTools, cliFilter, handleCliSelect]
);
// Click outside to close popover
@@ -952,6 +1134,7 @@ export function MessageInput({
const handler = (e: MouseEvent) => {
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) {
setModelMenuOpen(false);
setModelSearch('');
}
};
document.addEventListener('mousedown', handler);
@@ -1041,7 +1224,7 @@ export function MessageInput({
return (
<div
ref={popoverRef}
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
className="absolute bottom-full left-0 mb-2 w-full max-w-2xl rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
>
{popoverMode === 'skill' ? (
<div className="px-3 py-2 border-b">
@@ -1079,7 +1262,6 @@ export function MessageInput({
}
}}
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
autoFocus
/>
</div>
) : (
@@ -1144,6 +1326,105 @@ export function MessageInput({
</>
)}
</div>
{/* Footer: manage skills (skill mode only) */}
{popoverMode === 'skill' && (
<div className="border-t px-3 py-1.5">
<button
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
onClick={() => { closePopover(); window.location.href = '/skills'; }}
>
<Lightning size={14} />
{t('composer.manageSkills' as TranslationKey)}
</button>
</div>
)}
</div>
);
})()}
{/* CLI Tools Popover */}
{popoverMode === 'cli' && (() => {
const q = cliFilter.toLowerCase();
const filtered = cliTools.filter(t =>
t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q)
);
return (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-full max-w-2xl rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
>
<div className="px-3 py-2 border-b">
<input
ref={cliSearchRef}
type="text"
placeholder={t('cliTools.searchPlaceholder' as TranslationKey)}
value={cliFilter}
onChange={(e) => { setCliFilter(e.target.value); setSelectedIndex(0); }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[selectedIndex]) handleCliSelect(filtered[selectedIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
closePopover();
textareaRef.current?.focus();
}
}}
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="max-h-48 overflow-y-auto py-1">
{filtered.length > 0 ? (
filtered.map((tool, idx) => (
<button
key={tool.id}
ref={idx === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
idx === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => handleCliSelect(tool)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<Terminal size={16} className="shrink-0 text-muted-foreground" />
<span className="font-medium text-xs truncate">{tool.name}</span>
{tool.version && (
<span className="text-[10px] text-muted-foreground shrink-0">v{tool.version}</span>
)}
{tool.summary && (
<span className="text-xs text-muted-foreground truncate ml-auto max-w-[200px]">{tool.summary}</span>
)}
</button>
))
) : (
<div className="px-3 py-4 text-center">
<p className="text-sm text-muted-foreground">{t('cliTools.noToolsDetected' as TranslationKey)}</p>
<button
className="mt-2 text-xs text-primary hover:underline"
onClick={() => { closePopover(); window.location.href = '/cli-tools'; }}
>
{t('cliTools.goInstall' as TranslationKey)}
</button>
</div>
)}
</div>
{/* Footer: manage CLI tools */}
<div className="border-t px-3 py-1.5">
<button
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
onClick={() => { closePopover(); window.location.href = '/cli-tools'; }}
>
<Terminal size={14} />
{t('cliTools.manageCli' as TranslationKey)}
</button>
</div>
</div>
);
})()}
@@ -1176,11 +1457,29 @@ export function MessageInput({
</span>
</div>
)}
{/* CLI badge */}
{cliBadge && (
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-emerald-500/20">
<Terminal size={12} />
<span>CLI: {cliBadge.name}</span>
<button
type="button"
onClick={removeCliBadge}
className="ml-0.5 rounded-full p-0.5 hover:bg-emerald-500/20 transition-colors"
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</span>
</div>
)}
{/* File attachment capsules */}
<FileAttachmentsCapsules />
<PromptInputTextarea
ref={textareaRef}
placeholder={badge ? "Add details (optional), then press Enter..." : "Message Claude..."}
placeholder={badge ? "Add details (optional), then press Enter..." : cliBadge ? "Describe what you want to do..." : "Message Claude..."}
value={inputValue}
onChange={(e) => handleInputChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
@@ -1196,6 +1495,18 @@ export function MessageInput({
{/* Slash command button */}
<SlashCommandButton onInsertSlash={handleInsertSlash} />
{/* CLI tools button */}
<Tooltip>
<TooltipTrigger asChild>
<PromptInputButton onClick={handleOpenCliPopover}>
<Terminal size={16} />
</PromptInputButton>
</TooltipTrigger>
<TooltipContent>
{t('cliTools.selectTool' as TranslationKey)}
</TooltipContent>
</Tooltip>
{/* Model selector */}
<div className="relative" ref={modelMenuRef}>
<PromptInputButton
@@ -1205,46 +1516,90 @@ export function MessageInput({
<CaretDown size={10} className={cn("transition-transform duration-200", modelMenuOpen && "rotate-180")} />
</PromptInputButton>
{modelMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-52 rounded-lg border bg-popover shadow-lg overflow-hidden z-50 max-h-80 overflow-y-auto">
{providerGroups.map((group, groupIdx) => (
<div key={group.provider_id}>
{/* Group header */}
<div className={cn(
"px-3 py-1.5 text-[10px] font-medium text-muted-foreground",
groupIdx > 0 && "border-t"
)}>
{group.provider_name}
</div>
{/* Models in group */}
<div className="py-0.5">
{group.models.map((opt) => {
const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue;
return (
<button
key={`${group.provider_id}-${opt.value}`}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModelChange?.(opt.value);
onProviderModelChange?.(group.provider_id, opt.value);
localStorage.setItem('codepilot:last-model', opt.value);
localStorage.setItem('codepilot:last-provider-id', group.provider_id);
setModelMenuOpen(false);
}}
>
<span className="font-mono text-xs">{opt.label}</span>
{isActive && <span className="text-xs">✓</span>}
</button>
);
})}
</div>
{modelMenuOpen && (() => {
const mq = modelSearch.toLowerCase();
const filteredGroups = providerGroups.map(group => ({
...group,
models: group.models.filter(opt =>
!mq || opt.label.toLowerCase().includes(mq) || group.provider_name.toLowerCase().includes(mq)
),
})).filter(group => group.models.length > 0);
return (
<div className="absolute bottom-full left-0 mb-1.5 w-64 rounded-xl border bg-popover shadow-lg overflow-hidden z-50">
{/* Search */}
<div className="px-3 py-2 border-b">
<input
type="text"
placeholder={t('composer.searchModels' as TranslationKey)}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setModelMenuOpen(false);
setModelSearch('');
}
}}
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
))}
</div>
)}
{/* Model list */}
<div className="max-h-64 overflow-y-auto py-1">
{filteredGroups.map((group, groupIdx) => (
<div key={group.provider_id}>
<div className={cn(
"px-3 py-1.5 text-[10px] font-medium text-muted-foreground",
groupIdx > 0 && "border-t"
)}>
{group.provider_name}
</div>
<div className="py-0.5">
{group.models.map((opt) => {
const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue;
return (
<button
key={`${group.provider_id}-${opt.value}`}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModelChange?.(opt.value);
onProviderModelChange?.(group.provider_id, opt.value);
localStorage.setItem('codepilot:last-model', opt.value);
localStorage.setItem('codepilot:last-provider-id', group.provider_id);
setModelMenuOpen(false);
setModelSearch('');
}}
>
<span className="font-mono text-xs">{opt.label}</span>
{isActive && <span className="text-xs">✓</span>}
</button>
);
})}
</div>
</div>
))}
{filteredGroups.length === 0 && (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
No models found
</div>
)}
</div>
{/* Footer: manage providers */}
<div className="border-t px-3 py-1.5">
<button
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
onClick={() => { setModelMenuOpen(false); setModelSearch(''); window.location.href = '/settings'; }}
>
<Gear size={14} />
{t('composer.manageProviders' as TranslationKey)}
</button>
</div>
</div>
);
})()}
</div>
{/* Effort selector — only visible when model supports effort */}
@@ -1292,7 +1647,7 @@ export function MessageInput({
onStop={onStop}
disabled={disabled}
inputValue={inputValue}
hasBadge={!!badge}
hasBadge={!!badge || !!cliBadge}
/>
</PromptInputFooter>
</PromptInput>
+2 -1
View File
@@ -2,6 +2,7 @@
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { Lightning } from '@phosphor-icons/react';
import {
Tooltip,
TooltipContent,
@@ -20,7 +21,7 @@ export function SlashCommandButton({ onInsertSlash }: SlashCommandButtonProps) {
<Tooltip>
<TooltipTrigger asChild>
<PromptInputButton onClick={onInsertSlash}>
<span className="text-sm font-medium leading-none">/</span>
<Lightning size={16} />
</PromptInputButton>
</TooltipTrigger>
<TooltipContent>
@@ -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>
);
}
+1 -1
View File
@@ -374,7 +374,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
return (
<aside
className="hidden h-full shrink-0 flex-col overflow-hidden bg-sidebar lg:flex"
className="hidden h-full shrink-0 flex-col overflow-hidden bg-sidebar/80 backdrop-blur-xl lg:flex"
style={{ width: width ?? 240 }}
>
{/* Header - extra top padding for macOS traffic lights */}
+4 -1
View File
@@ -9,6 +9,7 @@ import {
Image,
Gear,
WifiHigh,
Terminal,
} from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
@@ -33,6 +34,7 @@ const navItems = [
{ href: "/chat", label: "Chats", icon: ChatCircle },
{ href: "/skills", label: "Skills", icon: Lightning },
{ href: "/mcp", label: "MCP", icon: Plug },
{ href: "/cli-tools", label: "CLI Tools", icon: Terminal },
{ href: "/gallery", label: "Gallery", icon: Image },
{ href: "/bridge", label: "Bridge", icon: WifiHigh },
] as const;
@@ -47,12 +49,13 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
'MCP': 'extensions.mcpServers',
'Gallery': 'gallery.title',
'Bridge': 'nav.bridge',
'CLI Tools': 'nav.cliTools',
};
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
const isSettingsActive = pathname === "/settings" || pathname.startsWith("/settings/");
return (
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar pb-3 pt-10">
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar/80 backdrop-blur-xl pb-3 pt-10">
{/* Nav icons */}
<nav className="flex flex-1 flex-col items-center gap-1">
{navItems.map((item) => {
+1 -1
View File
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-2xl border shadow-md transition-[color,box-shadow] outline-none",
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-2xl border shadow-sm transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
+58
View File
@@ -13,6 +13,7 @@ const en = {
'nav.darkMode': 'Dark mode',
'nav.toggleTheme': 'Toggle theme',
'nav.bridge': 'Bridge',
'nav.cliTools': 'CLI Tools',
// ── Chat list panel ─────────────────────────────────────────
'chatList.justNow': 'just now',
@@ -787,6 +788,9 @@ const en = {
// ── Composer ──────────────────────────────────────────────
'composer.slashCommand': 'Commands',
'composer.slashCommandTooltip': 'Insert slash command',
'composer.manageSkills': 'Manage skills',
'composer.searchModels': 'Search models...',
'composer.manageProviders': 'Manage providers',
'composer.designAgent': 'Design Agent',
'composer.designAgentTooltip': 'Enable AI design & image generation',
@@ -842,6 +846,60 @@ const en = {
'settings.email': 'Email',
'settings.organization': 'Organization',
'settings.subscription': 'Subscription',
// ── CLI Tools ─────────────────────────────────────────────
'cliTools.title': 'CLI Tools',
'cliTools.description': 'Manage CLI tools for AI-assisted workflows',
'cliTools.installed': 'Installed',
'cliTools.recommended': 'Recommended',
'cliTools.allInstalled': 'All recommended tools are installed!',
'cliTools.install': 'Install',
'cliTools.installing': 'Installing',
'cliTools.installSuccess': 'Installation Complete',
'cliTools.installFailed': 'Installation Failed',
'cliTools.detail': 'Details',
'cliTools.detailIntro': 'Introduction',
'cliTools.useCases': 'Use Cases',
'cliTools.guideSteps': 'Getting Started',
'cliTools.examplePrompts': 'Example Prompts',
'cliTools.copy': 'Copy',
'cliTools.homepage': 'Homepage',
'cliTools.docs': 'Documentation',
'cliTools.noDescription': 'No description yet — use AI Describe to generate',
'cliTools.toolInfo': 'Tool Info',
'cliTools.version': 'Version',
'cliTools.path': 'Path',
'cliTools.brewNotInstalled': 'Homebrew not detected',
'cliTools.brewInstallGuide': 'Most recommended tools require Homebrew. Run this command in your terminal to install it:',
'cliTools.viewDocs': 'View CodePilot documentation',
'cliTools.searchPlaceholder': 'Search CLI tools...',
'cliTools.noToolsDetected': 'No installed CLI tools detected',
'cliTools.goInstall': 'Go install CLI tools',
'cliTools.manageCli': 'Manage CLI tools',
'cliTools.selectTool': 'Select CLI tool',
'cliTools.autoDescribe': 'AI-enhance description',
'cliTools.autoDescribeFailed': 'AI description failed — check your provider settings',
'cliTools.batchDescribe': 'AI Describe',
'cliTools.batchDescribeIntro': 'Use AI to generate descriptions for installed tools. Results will be shown on each tool card.',
'cliTools.batchProvider': 'Provider',
'cliTools.batchModel': 'Model',
'cliTools.batchSkipExisting': 'Skip tools with existing descriptions ({count})',
'cliTools.batchToolCount': 'Will process {count} / {total} tools',
'cliTools.batchNoProvider': 'No compatible providers available. Coding Plan proxies (Kimi, GLM, MiniMax, etc.) are not supported — please configure an Anthropic API or OpenRouter provider.',
'cliTools.batchStart': 'Start',
'cliTools.batchSuccess': '{count} succeeded',
'cliTools.batchFailed': '{count} failed',
'cliTools.batchNothingToProcess': 'Nothing to process',
'cliTools.cancel': 'Cancel',
'cliTools.done': 'Done',
'cliTools.category.media': 'Media',
'cliTools.category.data': 'Data',
'cliTools.category.search': 'Search',
'cliTools.category.download': 'Download',
'cliTools.category.document': 'Document',
'cliTools.category.productivity': 'Productivity',
'cliTools.noInstallMethod': 'No automated install available for your platform',
'cliTools.systemDetected': 'System Detected',
} as const;
export type TranslationKey = keyof typeof en;
+58
View File
@@ -10,6 +10,7 @@ const zh: Record<TranslationKey, string> = {
'nav.darkMode': '深色模式',
'nav.toggleTheme': '切换主题',
'nav.bridge': '桥接',
'nav.cliTools': 'CLI 工具',
// ── Chat list panel ─────────────────────────────────────────
'chatList.justNow': '刚刚',
@@ -784,6 +785,9 @@ const zh: Record<TranslationKey, string> = {
// ── Composer ──────────────────────────────────────────────
'composer.slashCommand': '命令',
'composer.slashCommandTooltip': '插入斜杠命令',
'composer.manageSkills': '管理技能',
'composer.searchModels': '搜索模型...',
'composer.manageProviders': '管理服务商',
'composer.designAgent': '设计 Agent',
'composer.designAgentTooltip': '启用 AI 设计与图片生成',
@@ -839,6 +843,60 @@ const zh: Record<TranslationKey, string> = {
'settings.email': '邮箱',
'settings.organization': '组织',
'settings.subscription': '订阅类型',
// ── CLI Tools ─────────────────────────────────────────────
'cliTools.title': 'CLI 工具',
'cliTools.description': '管理 AI 工作流中的命令行工具',
'cliTools.installed': '已安装',
'cliTools.recommended': '推荐',
'cliTools.allInstalled': '所有推荐工具已安装!',
'cliTools.install': '安装',
'cliTools.installing': '安装中',
'cliTools.installSuccess': '安装完成',
'cliTools.installFailed': '安装失败',
'cliTools.detail': '详情',
'cliTools.detailIntro': '工具简介',
'cliTools.useCases': '适用场景',
'cliTools.guideSteps': '快速上手',
'cliTools.examplePrompts': '示例提示词',
'cliTools.copy': '复制',
'cliTools.homepage': '官网',
'cliTools.docs': '文档',
'cliTools.noDescription': '暂无简介,可使用 AI 生成简介',
'cliTools.toolInfo': '工具信息',
'cliTools.version': '版本',
'cliTools.path': '路径',
'cliTools.brewNotInstalled': '未检测到 Homebrew',
'cliTools.brewInstallGuide': '大部分推荐工具依赖 Homebrew 安装,请先在终端中运行以下命令安装:',
'cliTools.viewDocs': '查看 CodePilot 官方文档',
'cliTools.searchPlaceholder': '搜索 CLI 工具...',
'cliTools.noToolsDetected': '还没有检测到已安装的 CLI 工具',
'cliTools.goInstall': '去安装 CLI 工具',
'cliTools.manageCli': '管理 CLI 工具',
'cliTools.selectTool': '选择 CLI 工具',
'cliTools.autoDescribe': 'AI 完善描述',
'cliTools.autoDescribeFailed': 'AI 描述生成失败,请检查 Provider 配置',
'cliTools.batchDescribe': 'AI 生成简介',
'cliTools.batchDescribeIntro': '使用 AI 为已安装的工具批量生成简介,生成后将显示在工具卡片上。',
'cliTools.batchProvider': '服务商',
'cliTools.batchModel': '模型',
'cliTools.batchSkipExisting': '跳过已有简介的工具({count} 个)',
'cliTools.batchToolCount': '将处理 {count} / {total} 个工具',
'cliTools.batchNoProvider': '没有可用的服务商。Coding Plan 代理(Kimi、GLM、MiniMax 等)不支持此功能,请配置 Anthropic 官方 API 或 OpenRouter 等标准 API 服务商。',
'cliTools.batchStart': '开始生成',
'cliTools.batchSuccess': '{count} 个成功',
'cliTools.batchFailed': '{count} 个失败',
'cliTools.batchNothingToProcess': '没有需要处理的工具',
'cliTools.cancel': '取消',
'cliTools.done': '完成',
'cliTools.category.media': '媒体',
'cliTools.category.data': '数据',
'cliTools.category.search': '搜索',
'cliTools.category.download': '下载',
'cliTools.category.document': '文档',
'cliTools.category.productivity': '效率',
'cliTools.noInstallMethod': '当前平台无自动安装方式',
'cliTools.systemDetected': '系统已安装',
};
export default zh;
+96
View File
@@ -3,6 +3,7 @@ import type {
SDKAssistantMessage,
SDKUserMessage,
SDKResultMessage,
SDKResultSuccess,
SDKPartialAssistantMessage,
SDKSystemMessage,
SDKToolProgressMessage,
@@ -263,6 +264,101 @@ function buildPromptWithHistory(
return lines.join('\n');
}
/**
* Lightweight text generation via the Claude Code SDK subprocess.
* Uses the same provider/env resolution as streamClaude but without sessions,
* MCP, permissions, or conversation history. Suitable for simple tasks like
* generating tool descriptions.
*/
export async function generateTextViaSdk(params: {
providerId?: string;
model?: string;
system: string;
prompt: string;
abortSignal?: AbortSignal;
}): Promise<string> {
const resolved = resolveForClaudeCode(undefined, {
providerId: params.providerId,
});
const sdkEnv: Record<string, string> = { ...process.env as Record<string, string> };
if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir();
if (!sdkEnv.USERPROFILE) sdkEnv.USERPROFILE = os.homedir();
sdkEnv.PATH = getExpandedPath();
delete sdkEnv.CLAUDECODE;
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
const gitBashPath = findGitBash();
if (gitBashPath) sdkEnv.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath;
}
const resolvedEnv = toClaudeCodeEnv(sdkEnv, resolved);
Object.assign(sdkEnv, resolvedEnv);
const abortController = new AbortController();
if (params.abortSignal) {
params.abortSignal.addEventListener('abort', () => abortController.abort());
}
// Auto-timeout after 60s to prevent indefinite hangs
const timeoutId = setTimeout(() => abortController.abort(), 60_000);
const queryOptions: Options = {
cwd: os.homedir(),
abortController,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
env: sanitizeEnv(sdkEnv),
settingSources: resolved.settingSources as Options['settingSources'],
systemPrompt: params.system,
maxTurns: 1,
};
if (params.model) {
queryOptions.model = params.model;
}
const claudePath = findClaudePath();
if (claudePath) {
const ext = path.extname(claudePath).toLowerCase();
if (ext === '.cmd' || ext === '.bat') {
const scriptPath = resolveScriptFromCmd(claudePath);
if (scriptPath) queryOptions.pathToClaudeCodeExecutable = scriptPath;
} else {
queryOptions.pathToClaudeCodeExecutable = claudePath;
}
}
const conversation = query({
prompt: params.prompt,
options: queryOptions,
});
// Iterate through all messages; the last one with type 'result' has the answer
let resultText = '';
try {
for await (const msg of conversation) {
if (msg.type === 'result' && 'result' in msg) {
resultText = (msg as SDKResultSuccess).result || '';
}
}
} catch (err) {
clearTimeout(timeoutId);
if (abortController.signal.aborted && !(params.abortSignal?.aborted)) {
throw new Error('SDK query timed out after 60s');
}
throw err;
}
clearTimeout(timeoutId);
if (!resultText) {
throw new Error('SDK query returned no result');
}
return resultText;
}
export function streamClaude(options: ClaudeStreamOptions): ReadableStream<string> {
const {
prompt,
+260
View File
@@ -0,0 +1,260 @@
import type { CliToolDefinition } from '@/types';
export const CLI_TOOLS_CATALOG: CliToolDefinition[] = [
{
id: 'ffmpeg',
name: 'FFmpeg',
binNames: ['ffmpeg', 'ffprobe'],
summaryZh: '音视频处理瑞士军刀,支持转码、剪辑、合并、流处理',
summaryEn: 'Swiss army knife for audio/video — transcode, trim, merge, stream',
categories: ['media'],
installMethods: [
{ method: 'brew', command: 'brew install ffmpeg', platforms: ['darwin', 'linux'] },
],
setupType: 'simple',
detailIntro: {
zh: 'FFmpeg 是最强大的开源音视频处理工具,支持几乎所有格式的转码、剪辑、合并、滤镜处理和流媒体操作。Claude 可以帮你生成复杂的 FFmpeg 命令。',
en: 'FFmpeg is the most powerful open-source audio/video processing tool. It supports transcoding, trimming, merging, filtering, and streaming for virtually all formats. Claude can help generate complex FFmpeg commands.',
},
useCases: {
zh: ['视频格式转换(MP4/MKV/WebM 互转)', '音频提取和转码', '视频剪辑和拼接', '添加字幕和水印', '调整分辨率和码率'],
en: ['Video format conversion (MP4/MKV/WebM)', 'Audio extraction and transcoding', 'Video trimming and concatenation', 'Add subtitles and watermarks', 'Adjust resolution and bitrate'],
},
guideSteps: {
zh: ['安装 FFmpeg(推荐使用 Homebrew)', '安装完成后在终端输入 ffmpeg -version 验证', '在对话中描述你的音视频处理需求,Claude 会生成对应命令'],
en: ['Install FFmpeg (Homebrew recommended)', 'Verify by running ffmpeg -version in terminal', 'Describe your audio/video task in chat — Claude will generate the command'],
},
examplePrompts: [
{ label: 'Convert to MP4', promptZh: '把 input.mov 转换成 MP4 格式,保持原始质量', promptEn: 'Convert input.mov to MP4 format, keeping original quality' },
{ label: 'Extract audio', promptZh: '从视频文件中提取音频并保存为 MP3', promptEn: 'Extract audio from a video file and save as MP3' },
{ label: 'Compress video', promptZh: '将视频压缩到 10MB 以内,尽量保持画质', promptEn: 'Compress video to under 10MB while maintaining quality' },
],
homepage: 'https://ffmpeg.org',
repoUrl: 'https://github.com/FFmpeg/FFmpeg',
officialDocsUrl: 'https://ffmpeg.org/documentation.html',
supportsAutoDescribe: true,
},
{
id: 'jq',
name: 'jq',
binNames: ['jq'],
summaryZh: '轻量级 JSON 处理器,支持查询、过滤、转换',
summaryEn: 'Lightweight JSON processor — query, filter, transform',
categories: ['data'],
installMethods: [
{ method: 'brew', command: 'brew install jq', platforms: ['darwin', 'linux'] },
],
setupType: 'simple',
detailIntro: {
zh: 'jq 是命令行下的 JSON 处理利器,可以对 JSON 数据进行查询、过滤、映射和格式化。适合处理 API 响应、配置文件和日志分析。',
en: 'jq is a powerful command-line JSON processor for querying, filtering, mapping, and formatting JSON data. Great for API responses, config files, and log analysis.',
},
useCases: {
zh: ['解析和格式化 JSON 数据', '从 API 响应中提取特定字段', '批量转换 JSON 文件', '分析 JSON 格式的日志'],
en: ['Parse and format JSON data', 'Extract specific fields from API responses', 'Batch transform JSON files', 'Analyze JSON-formatted logs'],
},
guideSteps: {
zh: ['安装 jq', '运行 jq --version 验证安装', '使用管道将 JSON 数据传给 jq 处理'],
en: ['Install jq', 'Verify with jq --version', 'Pipe JSON data to jq for processing'],
},
examplePrompts: [
{ label: 'Parse JSON', promptZh: '用 jq 从 package.json 中提取所有依赖名称', promptEn: 'Use jq to extract all dependency names from package.json' },
{ label: 'Filter array', promptZh: '用 jq 过滤 JSON 数组中 status 为 active 的项目', promptEn: 'Use jq to filter JSON array items where status is active' },
],
homepage: 'https://jqlang.github.io/jq/',
repoUrl: 'https://github.com/jqlang/jq',
officialDocsUrl: 'https://jqlang.github.io/jq/manual/',
supportsAutoDescribe: true,
},
{
id: 'ripgrep',
name: 'ripgrep',
binNames: ['rg'],
summaryZh: '极速文本搜索工具,比 grep 快数倍',
summaryEn: 'Ultra-fast text search tool — orders of magnitude faster than grep',
categories: ['search'],
installMethods: [
{ method: 'brew', command: 'brew install ripgrep', platforms: ['darwin', 'linux'] },
{ method: 'cargo', command: 'cargo install ripgrep', platforms: ['darwin', 'linux', 'win32'] },
],
setupType: 'simple',
detailIntro: {
zh: 'ripgrep (rg) 是一个面向行的搜索工具,递归搜索当前目录中的正则表达式模式。它默认尊重 .gitignore 规则,速度极快。',
en: 'ripgrep (rg) is a line-oriented search tool that recursively searches directories for regex patterns. It respects .gitignore rules by default and is extremely fast.',
},
useCases: {
zh: ['在代码库中搜索特定模式', '查找包含特定文本的文件', '替代 grep 进行大规模搜索', '搜索时自动跳过 .gitignore 中的文件'],
en: ['Search codebases for specific patterns', 'Find files containing specific text', 'Replace grep for large-scale searches', 'Auto-skip .gitignore files during search'],
},
guideSteps: {
zh: ['安装 ripgrep', '运行 rg --version 验证安装', '使用 rg "pattern" 搜索当前目录'],
en: ['Install ripgrep', 'Verify with rg --version', 'Use rg "pattern" to search current directory'],
},
examplePrompts: [
{ label: 'Search code', promptZh: '用 ripgrep 在项目中搜索所有 TODO 注释', promptEn: 'Use ripgrep to find all TODO comments in the project' },
{ label: 'Find usage', promptZh: '用 rg 搜索某个函数在哪些文件中被调用', promptEn: 'Use rg to find which files call a specific function' },
],
homepage: 'https://github.com/BurntSushi/ripgrep',
repoUrl: 'https://github.com/BurntSushi/ripgrep',
supportsAutoDescribe: true,
},
{
id: 'yt-dlp',
name: 'yt-dlp',
binNames: ['yt-dlp'],
summaryZh: '功能强大的视频下载工具,支持数千个网站',
summaryEn: 'Powerful video downloader supporting thousands of websites',
categories: ['download', 'media'],
installMethods: [
{ method: 'brew', command: 'brew install yt-dlp', platforms: ['darwin', 'linux'] },
{ method: 'pipx', command: 'pipx install yt-dlp', platforms: ['darwin', 'linux', 'win32'] },
],
setupType: 'simple',
detailIntro: {
zh: 'yt-dlp 是 youtube-dl 的活跃分支,支持从数千个网站下载视频和音频。功能包括格式选择、字幕下载、播放列表处理等。',
en: 'yt-dlp is an actively maintained fork of youtube-dl, supporting video/audio downloads from thousands of websites. Features include format selection, subtitle download, playlist handling, and more.',
},
useCases: {
zh: ['下载在线视频', '提取视频中的音频', '下载字幕文件', '批量下载播放列表'],
en: ['Download online videos', 'Extract audio from videos', 'Download subtitles', 'Batch download playlists'],
},
guideSteps: {
zh: ['安装 yt-dlp', '运行 yt-dlp --version 验证安装', '使用 yt-dlp URL 下载视频'],
en: ['Install yt-dlp', 'Verify with yt-dlp --version', 'Use yt-dlp URL to download videos'],
},
examplePrompts: [
{ label: 'Download video', promptZh: '用 yt-dlp 下载这个视频的最高画质版本', promptEn: 'Use yt-dlp to download the highest quality version of this video' },
{ label: 'Extract audio', promptZh: '用 yt-dlp 只下载音频并转为 MP3', promptEn: 'Use yt-dlp to download audio only and convert to MP3' },
],
homepage: 'https://github.com/yt-dlp/yt-dlp',
repoUrl: 'https://github.com/yt-dlp/yt-dlp',
officialDocsUrl: 'https://github.com/yt-dlp/yt-dlp#readme',
supportsAutoDescribe: true,
},
{
id: 'pandoc',
name: 'Pandoc',
binNames: ['pandoc'],
summaryZh: '通用文档格式转换器,支持 Markdown/HTML/PDF/DOCX 等',
summaryEn: 'Universal document converter — Markdown, HTML, PDF, DOCX, and more',
categories: ['document'],
installMethods: [
{ method: 'brew', command: 'brew install pandoc', platforms: ['darwin', 'linux'] },
],
setupType: 'simple',
detailIntro: {
zh: 'Pandoc 是一个通用的文档格式转换器,支持 Markdown、HTML、LaTeX、PDF、DOCX、EPUB 等数十种格式之间的相互转换。',
en: 'Pandoc is a universal document converter supporting dozens of formats including Markdown, HTML, LaTeX, PDF, DOCX, EPUB, and more.',
},
useCases: {
zh: ['Markdown 转 PDF/DOCX', 'HTML 转 Markdown', '批量文档格式转换', '生成电子书(EPUB)'],
en: ['Markdown to PDF/DOCX', 'HTML to Markdown', 'Batch document conversion', 'Generate ebooks (EPUB)'],
},
guideSteps: {
zh: ['安装 Pandoc', '运行 pandoc --version 验证安装', '使用 pandoc input.md -o output.pdf 转换文件'],
en: ['Install Pandoc', 'Verify with pandoc --version', 'Use pandoc input.md -o output.pdf to convert files'],
},
examplePrompts: [
{ label: 'MD to PDF', promptZh: '用 pandoc 把 README.md 转成 PDF', promptEn: 'Use pandoc to convert README.md to PDF' },
{ label: 'HTML to MD', promptZh: '用 pandoc 把网页 HTML 转成 Markdown', promptEn: 'Use pandoc to convert HTML page to Markdown' },
],
homepage: 'https://pandoc.org',
repoUrl: 'https://github.com/jgm/pandoc',
officialDocsUrl: 'https://pandoc.org/MANUAL.html',
supportsAutoDescribe: true,
},
{
id: 'imagemagick',
name: 'ImageMagick',
binNames: ['magick', 'convert'],
summaryZh: '强大的图片处理工具,支持格式转换、缩放、裁剪、特效',
summaryEn: 'Powerful image processing tool — convert, resize, crop, effects',
categories: ['media'],
installMethods: [
{ method: 'brew', command: 'brew install imagemagick', platforms: ['darwin', 'linux'] },
],
setupType: 'simple',
detailIntro: {
zh: 'ImageMagick 是一个功能丰富的图片处理套件,支持 200+ 种图片格式的读写和转换,以及缩放、裁剪、旋转、合成、特效等操作。',
en: 'ImageMagick is a feature-rich image processing suite supporting 200+ image formats for reading, writing, converting, resizing, cropping, compositing, and applying effects.',
},
useCases: {
zh: ['批量图片格式转换', '图片缩放和裁剪', '添加水印和文字', '图片拼接和合成', 'PDF 转图片'],
en: ['Batch image format conversion', 'Image resizing and cropping', 'Add watermarks and text', 'Image montage and compositing', 'PDF to image conversion'],
},
guideSteps: {
zh: ['安装 ImageMagick', '运行 magick --version 验证安装', '使用 magick convert input.png output.jpg 转换图片'],
en: ['Install ImageMagick', 'Verify with magick --version', 'Use magick convert input.png output.jpg to convert images'],
},
examplePrompts: [
{ label: 'Batch resize', promptZh: '用 ImageMagick 批量将文件夹中的图片缩放到 800px 宽', promptEn: 'Use ImageMagick to batch resize images in a folder to 800px wide' },
{ label: 'Add watermark', promptZh: '用 ImageMagick 给图片添加文字水印', promptEn: 'Use ImageMagick to add text watermark to images' },
],
homepage: 'https://imagemagick.org',
repoUrl: 'https://github.com/ImageMagick/ImageMagick',
officialDocsUrl: 'https://imagemagick.org/script/command-line-processing.php',
supportsAutoDescribe: true,
},
{
id: 'gws',
name: 'Google Workspace CLI',
binNames: ['gws'],
summaryZh: 'Google Workspace 命令行工具,支持 Drive/Gmail/Calendar/Sheets 等 API 操作',
summaryEn: 'CLI for Google Workspace APIs — Drive, Gmail, Calendar, Sheets and more',
categories: ['productivity'],
installMethods: [
{ method: 'npm', command: 'npm install -g @googleworkspace/cli', platforms: ['darwin', 'linux', 'win32'] },
],
setupType: 'needs_auth',
detailIntro: {
zh: 'gws 是 Google Workspace 的官方命令行工具,通过 Google Discovery Service 动态生成命令,自动覆盖所有 Workspace API。输出为结构化 JSON,天然适合 AI 代理和脚本集成。首次使用需通过 OAuth 完成身份认证。',
en: 'gws is the official CLI for Google Workspace. It dynamically generates commands from Google Discovery Service, automatically covering all Workspace APIs. Output is structured JSON, making it ideal for AI agents and scripting. First-time use requires OAuth authentication.',
},
useCases: {
zh: ['管理 Google Drive 文件(上传、下载、搜索)', '读取和发送 Gmail 邮件', '操作 Google Sheets 数据', '管理 Google Calendar 日程', '在 CI/脚本中自动化 Google Workspace 操作'],
en: ['Manage Google Drive files (upload, download, search)', 'Read and send Gmail messages', 'Operate on Google Sheets data', 'Manage Google Calendar events', 'Automate Google Workspace operations in CI/scripts'],
},
guideSteps: {
zh: ['安装 gws:npm install -g @googleworkspace/cli', '运行 gws auth setup 配置 Google Cloud 项目并登录', '运行 gws auth login 完成 OAuth 认证', '使用 gws drive files list 等命令操作 Workspace 资源'],
en: ['Install gws: npm install -g @googleworkspace/cli', 'Run gws auth setup to configure Google Cloud project and log in', 'Run gws auth login to complete OAuth authentication', 'Use commands like gws drive files list to operate on Workspace resources'],
},
examplePrompts: [
{ label: 'List Drive files', promptZh: '用 gws 列出我 Google Drive 根目录下的文件', promptEn: 'Use gws to list files in my Google Drive root directory' },
{ label: 'Send email', promptZh: '用 gws 发送一封测试邮件', promptEn: 'Use gws to send a test email via Gmail' },
{ label: 'Read spreadsheet', promptZh: '用 gws 读取 Google Sheets 表格中的数据', promptEn: 'Use gws to read data from a Google Sheets spreadsheet' },
],
homepage: 'https://github.com/googleworkspace/cli',
repoUrl: 'https://github.com/googleworkspace/cli',
officialDocsUrl: 'https://github.com/googleworkspace/cli#readme',
supportsAutoDescribe: true,
},
];
/**
* Well-known CLI binaries to detect beyond the curated catalog.
* These are probed at detection time to surface tools already on the system
* that don't have a full catalog entry but are useful for AI workflows.
* Each entry: [id, displayName, binName]
*/
export const EXTRA_WELL_KNOWN_BINS: Array<[string, string, string]> = [
['wget', 'wget', 'wget'],
['curl', 'curl', 'curl'],
['git', 'Git', 'git'],
['python3', 'Python 3', 'python3'],
['node', 'Node.js', 'node'],
['go', 'Go', 'go'],
['rustc', 'Rust', 'rustc'],
['docker', 'Docker', 'docker'],
['kubectl', 'kubectl', 'kubectl'],
['terraform', 'Terraform', 'terraform'],
['gh', 'GitHub CLI', 'gh'],
['aws', 'AWS CLI', 'aws'],
['gcloud', 'Google Cloud CLI', 'gcloud'],
['sox', 'SoX', 'sox'],
['sqlite3', 'SQLite', 'sqlite3'],
['htop', 'htop', 'htop'],
['tmux', 'tmux', 'tmux'],
['bat', 'bat', 'bat'],
['fd', 'fd', 'fd'],
['fzf', 'fzf', 'fzf'],
];
+37
View File
@@ -0,0 +1,37 @@
import { detectAllCliTools } from './cli-tools-detect';
import { CLI_TOOLS_CATALOG, EXTRA_WELL_KNOWN_BINS } from './cli-tools-catalog';
/**
* Build a concise CLI tools context block for chat system prompt injection.
* Includes both catalog tools and extra well-known binaries detected on the system.
* Returns null if no tools are installed.
*/
export async function buildCliToolsContext(): Promise<string | null> {
try {
const { catalog, extra } = await detectAllCliTools();
const catalogLines = catalog
.filter(t => t.status === 'installed')
.map(t => {
const def = CLI_TOOLS_CATALOG.find(c => c.id === t.id);
if (!def) return null;
const ver = t.version ? ` (v${t.version})` : '';
return `- ${def.name}${ver}: ${def.summaryEn}`;
})
.filter(Boolean);
const extraLines = extra.map(t => {
const entry = EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === t.id);
const name = entry?.[1] ?? t.id;
const ver = t.version ? ` (v${t.version})` : '';
return `- ${name}${ver}`;
});
const allLines = [...catalogLines, ...extraLines];
if (allLines.length === 0) return null;
return `<available_cli_tools>\nThe following CLI tools are installed on this system and available for use:\n${allLines.join('\n')}\n</available_cli_tools>`;
} catch {
return null;
}
}
+141
View File
@@ -0,0 +1,141 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { isWindows, getExpandedPath } from './platform';
import { CLI_TOOLS_CATALOG, EXTRA_WELL_KNOWN_BINS } from './cli-tools-catalog';
import type { CliToolRuntimeInfo, CliToolDefinition } from '@/types';
const execFileAsync = promisify(execFile);
// Module-level cache with TTL
let _cache: { catalog: CliToolRuntimeInfo[]; extra: CliToolRuntimeInfo[]; timestamp: number } | null = null;
const CACHE_TTL = 120_000; // 2 minutes
/**
* Detect a single CLI tool — checks binNames with which/where, then runs --version
*/
export async function detectCliTool(tool: CliToolDefinition): Promise<CliToolRuntimeInfo> {
const expandedPath = getExpandedPath();
const env = { ...process.env, PATH: expandedPath };
for (const bin of tool.binNames) {
try {
// Find binary path
const whichCmd = isWindows ? 'where' : '/usr/bin/which';
const { stdout: binPath } = await execFileAsync(whichCmd, [bin], {
timeout: 5000,
env,
shell: isWindows,
});
const resolvedPath = binPath.trim().split(/\r?\n/)[0]?.trim();
if (!resolvedPath) continue;
// Get version
let version: string | null = null;
try {
const { stdout: versionOut, stderr: versionErr } = await execFileAsync(resolvedPath, ['--version'], {
timeout: 5000,
env,
});
const versionText = (versionOut || versionErr).trim();
// Extract version number from first line
const match = versionText.split('\n')[0]?.match(/(\d+\.\d+[\w.-]*)/);
version = match ? match[1] : versionText.split('\n')[0]?.slice(0, 50) || null;
} catch {
// Binary exists but --version failed — still mark as installed
}
return {
id: tool.id,
status: 'installed',
version,
binPath: resolvedPath,
};
} catch {
// bin not found, try next
}
}
return {
id: tool.id,
status: 'not_installed',
version: null,
binPath: null,
};
}
/**
* Detect a single binary by name (for extra well-known tools outside catalog)
*/
async function detectBinary(id: string, bin: string): Promise<CliToolRuntimeInfo> {
const expandedPath = getExpandedPath();
const env = { ...process.env, PATH: expandedPath };
try {
const whichCmd = isWindows ? 'where' : '/usr/bin/which';
const { stdout: binPath } = await execFileAsync(whichCmd, [bin], {
timeout: 3000,
env,
shell: isWindows,
});
const resolvedPath = binPath.trim().split(/\r?\n/)[0]?.trim();
if (!resolvedPath) {
return { id, status: 'not_installed', version: null, binPath: null };
}
let version: string | null = null;
try {
const { stdout: vOut, stderr: vErr } = await execFileAsync(resolvedPath, ['--version'], {
timeout: 3000,
env,
});
const vText = (vOut || vErr).trim();
const match = vText.split('\n')[0]?.match(/(\d+\.\d+[\w.-]*)/);
version = match ? match[1] : null;
} catch { /* version extraction optional */ }
return { id, status: 'installed', version, binPath: resolvedPath };
} catch {
return { id, status: 'not_installed', version: null, binPath: null };
}
}
export interface DetectAllResult {
catalog: CliToolRuntimeInfo[];
extra: CliToolRuntimeInfo[];
}
/**
* Detect all catalog tools + extra well-known binaries in parallel, with 2-minute cache.
* Returns separate arrays so the UI can distinguish catalog vs system-detected tools.
*/
export async function detectAllCliTools(forceRefresh = false): Promise<DetectAllResult> {
const now = Date.now();
if (!forceRefresh && _cache && now - _cache.timestamp < CACHE_TTL) {
return { catalog: _cache.catalog, extra: _cache.extra };
}
// Collect catalog tool IDs so we skip duplicates from the extra list
const catalogBins = new Set(CLI_TOOLS_CATALOG.flatMap(t => t.binNames));
const [catalogResults, extraResults] = await Promise.all([
Promise.all(CLI_TOOLS_CATALOG.map(tool => detectCliTool(tool))),
Promise.all(
EXTRA_WELL_KNOWN_BINS
.filter(([, , bin]) => !catalogBins.has(bin))
.map(([id, , bin]) => detectBinary(id, bin))
),
]);
// Only keep extra tools that are actually installed
const extraInstalled = extraResults.filter(t => t.status === 'installed');
_cache = { catalog: catalogResults, extra: extraInstalled, timestamp: now };
return { catalog: catalogResults, extra: extraInstalled };
}
/**
* Invalidate the detection cache
*/
export function invalidateDetectCache(): void {
_cache = null;
}
+16
View File
@@ -101,6 +101,14 @@ export interface VendorPreset {
category?: 'chat' | 'media';
/** Icon key for UI */
iconKey: string;
/**
* True for providers that only support the Claude Code SDK wire protocol
* (e.g. Kimi /coding/, GLM /api/anthropic).
* These providers cannot be used with the Vercel AI SDK text generation path
* (streamText / generateText) because they don't implement the standard
* Anthropic Messages API.
*/
sdkProxyOnly?: boolean;
}
// ── Default Anthropic models ────────────────────────────────────
@@ -176,6 +184,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'zhipu',
sdkProxyOnly: true,
},
// ── Zhipu GLM (Global) ──
@@ -195,6 +204,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'zhipu',
sdkProxyOnly: true,
},
// ── Kimi ──
@@ -212,6 +222,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'kimi',
sdkProxyOnly: true,
},
// ── Moonshot ──
@@ -229,6 +240,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'moonshot',
sdkProxyOnly: true,
},
// ── MiniMax (China) ──
@@ -250,6 +262,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'minimax',
sdkProxyOnly: true,
},
// ── MiniMax (Global) ──
@@ -271,6 +284,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'minimax',
sdkProxyOnly: true,
},
// ── Volcengine Ark ──
@@ -286,6 +300,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
defaultModels: [], // User must specify model_names
fields: ['api_key', 'model_names'],
iconKey: 'volcengine',
sdkProxyOnly: true,
},
// ── Aliyun Bailian ──
@@ -309,6 +324,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [
],
fields: ['api_key'],
iconKey: 'bailian',
sdkProxyOnly: true,
},
// ── AWS Bedrock ──
+70 -10
View File
@@ -254,8 +254,10 @@ export function toClaudeCodeEnv(
export interface AiSdkConfig {
/** Which AI SDK factory to use */
sdkType: 'anthropic' | 'openai' | 'google' | 'bedrock' | 'vertex';
/** API key to pass to the SDK */
/** API key to pass to the SDK (mutually exclusive with authToken for Anthropic) */
apiKey: string | undefined;
/** Auth token (Bearer) for Anthropic auth_token providers (mutually exclusive with apiKey) */
authToken: string | undefined;
/** Base URL to pass to the SDK */
baseUrl: string | undefined;
/** The model ID to request (upstream/API model ID) */
@@ -300,21 +302,59 @@ export function toAiSdkConfig(
const headers = resolved.headers;
// Resolve Anthropic auth credentials.
// @ai-sdk/anthropic supports apiKey (x-api-key header) and authToken (Bearer header),
// and they are mutually exclusive. We must pick the right one based on authStyle.
const resolveAnthropicAuth = (): { apiKey: string | undefined; authToken: string | undefined } => {
if (provider) {
// Configured provider — use authStyle to decide
if (resolved.authStyle === 'auth_token') {
return { apiKey: undefined, authToken: provider.api_key || undefined };
}
return { apiKey: provider.api_key || undefined, authToken: undefined };
}
// Env mode — check env vars and legacy DB settings.
// ANTHROPIC_AUTH_TOKEN takes precedence (it's the Claude Code SDK auth path).
const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN || getSetting('anthropic_auth_token');
if (envAuthToken) {
// If we also have an API key, prefer auth_token (matches Claude Code SDK behavior)
return { apiKey: undefined, authToken: envAuthToken };
}
const envApiKey = process.env.ANTHROPIC_API_KEY;
return { apiKey: envApiKey || undefined, authToken: undefined };
};
// @ai-sdk/anthropic builds request URLs as `${baseURL}/messages`.
// Its default is 'https://api.anthropic.com/v1', so if we pass
// 'https://api.anthropic.com' (without /v1) the request goes to
// /messages instead of /v1/messages and 404s.
// Normalise here so callers don't need to know about the SDK's URL scheme.
const normaliseAnthropicBaseUrl = (url: string | undefined): string | undefined => {
if (!url) return undefined;
const cleaned = url.replace(/\/+$/, '');
if (cleaned === 'https://api.anthropic.com') return 'https://api.anthropic.com/v1';
return cleaned;
};
switch (protocol) {
case 'anthropic':
case 'anthropic': {
const auth = resolveAnthropicAuth();
const rawBaseUrl = provider?.base_url || process.env.ANTHROPIC_BASE_URL || getSetting('anthropic_base_url') || undefined;
return {
sdkType: 'anthropic',
apiKey: provider?.api_key || undefined,
baseUrl: provider?.base_url || undefined,
...auth,
baseUrl: normaliseAnthropicBaseUrl(rawBaseUrl),
modelId,
headers,
processEnvInjections,
};
}
case 'openrouter':
return {
sdkType: 'openai',
apiKey: provider?.api_key || undefined,
authToken: undefined,
baseUrl: provider?.base_url || 'https://openrouter.ai/api/v1',
modelId,
headers,
@@ -325,6 +365,7 @@ export function toAiSdkConfig(
return {
sdkType: 'openai',
apiKey: provider?.api_key || undefined,
authToken: undefined,
baseUrl: provider?.base_url || undefined,
modelId,
headers,
@@ -337,6 +378,7 @@ export function toAiSdkConfig(
return {
sdkType: 'openai',
apiKey: provider.api_key || 'dummy',
authToken: undefined,
baseUrl: provider.base_url,
modelId,
headers,
@@ -346,6 +388,7 @@ export function toAiSdkConfig(
return {
sdkType: 'bedrock',
apiKey: undefined,
authToken: undefined,
baseUrl: undefined,
modelId,
headers,
@@ -358,6 +401,7 @@ export function toAiSdkConfig(
return {
sdkType: 'openai',
apiKey: provider.api_key || 'dummy',
authToken: undefined,
baseUrl: provider.base_url,
modelId,
headers,
@@ -367,6 +411,7 @@ export function toAiSdkConfig(
return {
sdkType: 'vertex',
apiKey: undefined,
authToken: undefined,
baseUrl: undefined,
modelId,
headers,
@@ -378,21 +423,24 @@ export function toAiSdkConfig(
return {
sdkType: 'google',
apiKey: provider?.api_key || undefined,
authToken: undefined,
baseUrl: provider?.base_url || undefined,
modelId,
headers,
processEnvInjections,
};
default:
default: {
const auth = resolveAnthropicAuth();
return {
sdkType: 'anthropic',
apiKey: provider?.api_key || undefined,
baseUrl: provider?.base_url || undefined,
...auth,
baseUrl: normaliseAnthropicBaseUrl(provider?.base_url),
modelId,
headers,
processEnvInjections,
};
}
}
}
@@ -410,18 +458,30 @@ function buildResolution(
getSetting('anthropic_auth_token')
);
const model = opts.model || opts.sessionModel || getSetting('default_model') || undefined;
// Env mode uses short aliases (sonnet/opus/haiku) in the UI.
// Map them to full Anthropic model IDs so toAiSdkConfig can resolve correctly.
const envModels: CatalogModel[] = [
{ modelId: 'sonnet', upstreamModelId: 'claude-sonnet-4-20250514', displayName: 'Sonnet 4.6' },
{ modelId: 'opus', upstreamModelId: 'claude-opus-4-20250514', displayName: 'Opus 4.6' },
{ modelId: 'haiku', upstreamModelId: 'claude-haiku-4-5-20251001', displayName: 'Haiku 4.5' },
];
// Resolve upstream model from the alias table
const catalogEntry = model ? envModels.find(m => m.modelId === model) : undefined;
return {
provider: undefined,
protocol: 'anthropic',
authStyle: 'api_key',
model,
upstreamModel: model, // identity mapping for env mode
modelDisplayName: undefined,
upstreamModel: catalogEntry?.upstreamModelId || model,
modelDisplayName: catalogEntry?.displayName,
headers: {},
envOverrides: {},
roleModels: {},
hasCredentials: envHasCredentials,
availableModels: [],
availableModels: envModels,
settingSources: ['user', 'project', 'local'],
};
}
+9 -1
View File
@@ -22,6 +22,11 @@ export interface StreamTextParams {
* Provider resolution is fully delegated to the unified resolver.
* No fallback logic here — the resolver's chain (explicit → session → global default → env)
* is the single source of truth, matching the Claude Code SDK path.
*
* NOTE: Do NOT expand model aliases (sonnet/opus/haiku) here.
* toAiSdkConfig() resolves model IDs through the provider's availableModels catalog,
* which uses the short alias as modelId. Expanding aliases would break that lookup
* for SDK proxy providers (Kimi, GLM, MiniMax, etc.) that expect short aliases.
*/
export async function* streamTextFromProvider(params: StreamTextParams): AsyncIterable<string> {
const resolved = resolveProviderUnified({ providerId: params.providerId });
@@ -44,7 +49,10 @@ export async function* streamTextFromProvider(params: StreamTextParams): AsyncIt
switch (config.sdkType) {
case 'anthropic': {
const anthropic = createAnthropic({
apiKey: config.apiKey,
// apiKey and authToken are mutually exclusive in @ai-sdk/anthropic
...(config.authToken
? { authToken: config.authToken }
: { apiKey: config.apiKey }),
baseURL: config.baseUrl,
...(hasHeaders ? { headers: config.headers } : {}),
});
+51
View File
@@ -137,6 +137,8 @@ export interface ProviderModelGroup {
provider_id: string; // provider DB id, or 'env' for environment variables
provider_name: string;
provider_type: string;
/** True if this provider only supports Claude Code SDK wire protocol, not standard Messages API */
sdkProxyOnly?: boolean;
models: Array<{
value: string; // internal/UI model ID
label: string; // display name
@@ -883,3 +885,52 @@ export interface ClaudeStreamOptions {
/** When true, this is an auto-trigger turn (invisible to user) — skip rewind point emission */
autoTrigger?: boolean;
}
// ==========================================
// CLI Tools Types
// ==========================================
export type CliToolStatus = 'not_installed' | 'installed' | 'needs_auth' | 'ready';
export type CliToolCategory = 'media' | 'data' | 'search' | 'download' | 'document' | 'productivity';
export type InstallMethod = 'brew' | 'npm' | 'pipx' | 'cargo';
export type CliToolPlatform = 'darwin' | 'linux' | 'win32';
export interface CliToolInstallMethod {
method: InstallMethod;
command: string;
platforms: CliToolPlatform[];
}
export interface CliToolExamplePrompt {
label: string;
promptZh: string;
promptEn: string;
}
export interface CliToolDefinition {
id: string;
name: string;
binNames: string[];
summaryZh: string;
summaryEn: string;
categories: CliToolCategory[];
installMethods: CliToolInstallMethod[];
setupType: 'simple' | 'needs_auth';
detailIntro: { zh: string; en: string };
useCases: { zh: string[]; en: string[] };
guideSteps: { zh: string[]; en: string[] };
examplePrompts: CliToolExamplePrompt[];
homepage?: string;
repoUrl?: string;
officialDocsUrl?: string;
supportsAutoDescribe: boolean;
}
export interface CliToolRuntimeInfo {
id: string;
status: CliToolStatus;
version: string | null;
binPath: string | null;
autoDescription?: { zh: string; en: string } | null;
}