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:
1 parent
4e603323c4
commit
16edf9f59c
37 files changed
+3108
-78
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user