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

+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,
});
}