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

+109
View File
@@ -0,0 +1,109 @@
---
title: CLI Tools
description: Manage system CLI tools so Claude can automatically detect and use command-line capabilities on your machine.
---
# CLI Tools
Many AI workflows require command-line tools — FFmpeg for video processing, jq for JSON parsing, ripgrep for code search. CodePilot's CLI Tools feature helps you manage these tools and lets Claude automatically know what's available on your machine.
## Why This Matters
When you tell Claude "convert this video to MP4," it needs to know whether FFmpeg is installed on your system. Without that knowledge, it can only offer generic advice. With it, Claude can give you a ready-to-run command.
The CLI Tools feature does three things:
1. **Detect** — Automatically scans your system for installed command-line tools
2. **Recommend** — Offers a curated list of useful tools with one-click installation
3. **Awareness** — Automatically tells Claude what tools you have during conversations, so it gives more precise answers
## Opening the Tool Manager
Click the **CLI Tools** icon (terminal icon) in the left navigation rail.
The page has two sections:
- **Installed** — Tools detected on your system, showing version and status
- **Recommended** — Curated tools commonly used in AI workflows, ready to install
## Installing Tools
Find the tool you need in the Recommended section and click **Install**.
If a tool supports multiple installation methods (e.g., Homebrew, npm), you'll be asked to choose. The installation progress shows real-time terminal output so you can see exactly what's happening.
Currently recommended tools:
| Tool | Purpose | Install via |
|------|---------|------------|
| FFmpeg | Audio/video transcoding, trimming, merging | Homebrew |
| jq | JSON data parsing and transformation | Homebrew |
| ripgrep | Ultra-fast text search (much faster than grep) | Homebrew |
| yt-dlp | Video downloading | Homebrew / pipx |
| pandoc | Document format conversion (Markdown, Word, PDF) | Homebrew |
## Viewing Tool Details
Click the **Details** button on a tool card to see:
- **Introduction** — What the tool does
- **Use Cases** — Most common scenarios
- **Setup Guide** — Steps from installation to first use
- **Example Prompts** — Ready-to-use prompts you can copy into a conversation
Example prompts are the most practical part. For FFmpeg, you might see:
> "Convert input.mov to MP4 format, keeping original quality"
Click the copy button or "Send to Chat" to start a conversation with that prompt immediately.
## AI-Enhanced Descriptions
Installed tools support AI-generated detailed descriptions. Click the **Auto Describe** button on a tool card, and Claude will generate a bilingual description based on the tool's name and capabilities.
The description is saved locally and will persist across sessions.
## Using Tools in Conversations
### Automatic Awareness (Recommended)
After installing tools, you don't need to do anything extra. CodePilot automatically detects your installed tools before each conversation and includes them in the system prompt.
This means when you say:
> "Convert all .mov files in this directory to .mp4"
Claude knows you have FFmpeg and gives you a ready-to-run command, instead of first asking "Do you have FFmpeg installed?"
### Manual Tool Selection
If you want to explicitly tell Claude to use a specific tool, click the **terminal icon** in the chat input toolbar to open the CLI tool picker.
After selecting a tool:
- If the input is empty, it auto-fills a guide phrase like "I want to use FFmpeg to: " — just add your specific request
- If there's already text in the input, a tool badge is attached to the message, and Claude will prioritize that tool in its response
## Best Practices
### Describe the Goal, Not the Command
Less effective:
> "Run ffmpeg -i input.mov -c:v libx264 output.mp4"
More effective:
> "Convert input.mov to MP4, keep the quality, minimize file size"
Let Claude choose the optimal parameters. It knows FFmpeg's encoding options better than most people.
### Combine Multiple Tools
CLI tools work great together. For example:
> "Download the video from this YouTube link, trim the first 30 seconds, and convert to GIF"
If you have yt-dlp and FFmpeg installed, Claude will chain them together into a complete workflow.
### Start with Example Prompts
Not sure how to use a tool? Open its detail page and start with the example prompts. They cover the most common use cases and are the fastest way to get started.
+1
View File
@@ -8,6 +8,7 @@
"providers",
"mcp",
"skills",
"cli-tools",
"bridge",
"assistant-workspace",
"design-agent",
+109
View File
@@ -0,0 +1,109 @@
---
title: CLI 工具
description: 管理系统 CLI 工具,让 Claude 自动识别并使用你电脑上的命令行能力。
---
# CLI 工具
很多 AI 工作流需要配合命令行工具——用 FFmpeg 处理视频、用 jq 解析 JSON、用 ripgrep 搜索代码。CodePilot 的 CLI 工具功能帮你管理这些工具,并让 Claude 自动知道你的电脑上有什么可用。
## 为什么需要这个功能?
当你跟 Claude 说"帮我把这个视频转成 MP4",Claude 需要知道你的系统上是否安装了 FFmpeg。如果不知道,它只能给出通用建议;如果知道,它可以直接给你一条可以执行的命令。
CLI 工具功能做了三件事:
1. **检测** — 自动扫描你系统上已安装的命令行工具
2. **推荐** — 提供精选工具列表,一键安装
3. **感知** — 在对话中自动告诉 Claude 你有哪些工具,让它给出更精准的回答
## 打开工具管理
点击左侧导航栏的 **CLI Tools** 图标(终端图标)进入管理页面。
页面分为两个区域:
- **已安装** — 系统上检测到的工具,显示版本号和状态
- **推荐** — 精选的常用 AI 工作流工具,可以直接安装
## 安装工具
在推荐区找到你需要的工具,点击 **安装** 按钮。
如果工具支持多种安装方式(如 Homebrew、npm),会先让你选择。安装过程中会实时显示终端日志,你可以看到完整的安装输出。
目前推荐的工具包括:
| 工具 | 用途 | 安装方式 |
|------|------|---------|
| FFmpeg | 音视频转码、剪辑、合并 | Homebrew |
| jq | JSON 数据解析和转换 | Homebrew |
| ripgrep | 极速文本搜索(比 grep 快得多) | Homebrew |
| yt-dlp | 视频下载 | Homebrew / pipx |
| pandoc | 文档格式转换(Markdown、Word、PDF 互转) | Homebrew |
## 查看工具详情
点击工具卡片上的 **详情** 按钮,可以看到:
- **工具简介** — 这个工具能做什么
- **适用场景** — 最常见的使用方式
- **操作引导** — 从安装到上手的步骤
- **示例提示词** — 可以直接复制到对话中使用的提示词
示例提示词是这个功能最实用的部分。比如 FFmpeg 的示例提示词:
> "把 input.mov 转换成 MP4 格式,保持原始质量"
点击旁边的复制按钮或"发送到聊天"按钮,就可以直接用这个提示词开始对话。
## AI 自动完善介绍
已安装的工具支持用 AI 生成更详细的介绍。点击工具卡片上的 **自动完善** 按钮,Claude 会根据工具的名称和用途生成一段中英双语的详细描述。
这个描述会保存在本地,下次打开时自动显示。
## 在对话中使用
### 自动感知(推荐)
安装好工具后,不需要做任何额外操作。CodePilot 会在每次对话时自动检测你的已安装工具,并在 system prompt 中告诉 Claude。
这意味着当你说:
> "帮我把这个目录下所有的 .mov 文件转成 .mp4"
Claude 会知道你有 FFmpeg,直接给出可执行的命令,而不是先问你"是否安装了 FFmpeg"。
### 手动选择工具
如果你想明确告诉 Claude 使用某个特定工具,可以在聊天输入框的工具栏中点击 **终端图标**,打开 CLI 工具选择器。
选择一个工具后:
- 如果输入框为空,会自动预填一段引导文字,比如"我想用 FFmpeg 工具完成:" — 你只需补充具体需求
- 如果输入框已有内容,会在消息上附加一个工具标记,Claude 会优先使用这个工具来回答
## 最佳实践
### 描述需求而不是命令
不好的用法:
> "运行 ffmpeg -i input.mov -c:v libx264 output.mp4"
好的用法:
> "把 input.mov 转成 MP4,画质保持不变,文件尽量小"
让 Claude 来选择最优的参数组合。它比大多数人更了解 FFmpeg 的编码选项。
### 组合多个工具
CLI 工具之间可以组合使用。比如:
> "从这个 YouTube 链接下载视频,然后裁剪前 30 秒,转成 GIF"
如果你安装了 yt-dlp 和 FFmpeg,Claude 会把它们串联起来,给你一个完整的工作流。
### 用示例提示词起步
不确定怎么用一个工具?打开它的详情页,从示例提示词开始。这些提示词覆盖了最常见的使用场景,是快速上手的好方法。
+1
View File
@@ -8,6 +8,7 @@
"providers",
"mcp",
"skills",
"cli-tools",
"bridge",
"assistant-workspace",
"design-agent",
+1
View File
@@ -12,3 +12,4 @@
| bridge-system.md | 多 IM 远程桥接系统架构(目录结构、数据流、设计决策) |
| assistant-workspace.md | 助理工作区:人格/记忆文件、对话式引导、自动触发、确定性落盘 |
| theme-system.md | 主题家族系统:两层架构、JSON schema、代码高亮三条渲染链、12 个主题清单 |
| cli-tools.md | CLI 工具管理:静态 catalog、系统检测、一键安装、AI 描述、聊天上下文注入、输入框选择器 |
+139
View File
@@ -0,0 +1,139 @@
# CLI Tools — 系统 CLI 工具管理与聊天感知
## 核心思路
AI 工作流中 CLI 工具(ffmpeg、jq、ripgrep 等)是重要基础设施,但用户往往不知道装什么、怎么装。本功能在侧边栏新增 "CLI Tools" 页面,提供精选工具推荐、一键安装、AI 补全描述,并在聊天时自动将已安装工具注入 system prompt,让 Claude 知道用户系统上有哪些工具可用。
## 目录结构
```
src/lib/
├── cli-tools-catalog.ts # 静态精选 catalog(6 个核心 + EXTRA_WELL_KNOWN_BINS)
├── cli-tools-detect.ts # 系统检测逻辑(which/where + --version)
├── cli-tools-context.ts # 聊天上下文构建(system prompt 注入块)
src/app/api/cli-tools/
├── catalog/route.ts # GET — 返回完整 catalog 列表
├── installed/route.ts # GET — 检测并返回已安装工具的 runtime info
└── [id]/
├── status/route.ts # GET — 单个工具状态 + 版本
├── install/route.ts # POST — SSE 流式安装日志
├── detail/route.ts # GET — 详情弹窗数据
└── describe/route.ts # POST — AI 生成双语工具描述
src/components/cli-tools/
├── CliToolsManager.tsx # 主管理容器(已安装区 + 推荐区)
├── CliToolCard.tsx # 工具卡片(installed / recommended 两种 variant)
├── CliToolDetailDialog.tsx # 详情弹窗(简介 / 场景 / 引导 / 示例提示词)
├── CliToolInstallDialog.tsx # SSE 安装进度弹窗
├── CliToolBatchDescribeDialog.tsx # 批量 AI 描述生成
└── CliToolExtraDetailDialog.tsx # 额外检测工具的详情弹窗
src/app/cli-tools/page.tsx # 页面入口
```
## 数据流
### 工具检测
```
页面加载 → CliToolsManager 并行请求:
GET /api/cli-tools/catalog → CLI_TOOLS_CATALOG(静态数据)
GET /api/cli-tools/installed → detectAllCliTools()
→ 遍历 catalog binNames + EXTRA_WELL_KNOWN_BINS
→ which/where 查找 → --version 提取版本
→ 模块级缓存(TTL 2 分钟)
→ 合并 catalog + runtime info → 分区渲染
```
### 工具安装
```
用户点击"安装" → 选择安装方式(brew/npm/pipx)
→ POST /api/cli-tools/[id]/install { method }
→ 服务端 spawn 子进程执行 catalog 中声明的 command
→ SSE 流式返回 stdout/stderr
→ CliToolInstallDialog 实时显示日志
→ 完成后重新检测工具状态
```
安全约束:只执行 catalog 中声明的 command,不接受用户自定义命令。
### AI 描述生成
```
用户点击"自动完善介绍" → POST /api/cli-tools/[id]/describe { providerId }
→ 校验 tool.supportsAutoDescribe === true
→ generateTextViaSdk() 生成中英双语介绍(60s 超时)
→ 返回 { zh, en } 文本
→ 前端存入 localStorage(key: cli-tool-desc-{id})
→ 刷新后从 localStorage 恢复
```
### 聊天上下文注入
```
用户发送消息 → POST /api/chat
→ buildCliToolsContext()
→ detectAllCliTools()(命中缓存则直接返回)
→ 格式化已安装工具为 <available_cli_tools> XML 块
→ 追加到 system prompt 末尾
→ Claude 获知用户系统上可用的 CLI 工具
```
### 聊天侧 CLI 选择器
```
聊天输入框工具栏 → 点击 Terminal 图标 → popoverMode = 'cli'
→ 异步 fetch /api/cli-tools/installed + /api/cli-tools/catalog
→ 搜索框过滤 → 选择工具
→ 若输入框为空:预填 "我想用 {tool} 工具完成:" (zh) / "I want to use {tool} to: " (en)
→ 若输入框有内容:附加 CliBadge { id, name }
→ 发送时 CliBadge → systemPromptAppend 注入到 system prompt(不显示在对话中)
```
## 类型定义
关键类型在 `src/types/index.ts`:
| 类型 | 用途 |
|------|------|
| `CliToolStatus` | `'not_installed' \| 'installed' \| 'needs_auth' \| 'ready'` |
| `CliToolCategory` | `'media' \| 'data' \| 'search' \| 'download' \| 'document' \| 'productivity'` |
| `InstallMethod` | `'brew' \| 'npm' \| 'pipx' \| 'cargo'` |
| `CliToolDefinition` | 完整的工具定义(名称、binNames、摘要、分类、安装方式、详情、示例提示词) |
| `CliToolRuntimeInfo` | 运行时检测结果(状态、版本、路径、AI 描述) |
| `CliToolInstallMethod` | 包含 `method`、`command`、`platforms` |
## 设计决策
### 为什么用静态 catalog 而不是动态发现?
安全性——只允许执行预审过的安装命令。可预测性——工具列表、描述、示例提示词都是策划过的内容,质量可控。
### 为什么检测用 which 而不是直接运行?
Electron 桌面环境中 PATH 往往不完整。通过 `getExpandedPath()`(`src/lib/platform.ts`)展开 `/usr/local/bin`、`~/.cargo/bin` 等常见路径后传给 `which`,兼容桌面和终端两种启动方式。
### 为什么 AI 描述存 localStorage 而不是 DB?
描述是锦上添花,不是核心数据。避免 schema 迁移负担,丢失可重新生成。
### 聊天侧 CLI 选择器的 popover 模式
采用 `PopoverMode = 'file' | 'skill' | 'cli' | null` 枚举,CLI 是 button-triggered(点击工具栏图标触发),与 skill 的 text-triggered(输入 `/` 触发)不同。button-triggered 弹窗的焦点策略是将焦点交给弹窗内的搜索框,而 text-triggered 弹窗焦点保留在 textarea。
### EXTRA_WELL_KNOWN_BINS
除 catalog 中的 6 个精选工具外,`cli-tools-catalog.ts` 还导出 `EXTRA_WELL_KNOWN_BINS` 数组——常见但不需要详情页的工具(如 python、node、go、docker 等),用于聊天上下文注入,让 Claude 知道系统上还有哪些工具。
## 修改文件清单
| 文件 | 改动 |
|------|------|
| `src/types/index.ts` | 新增 CLI tool 相关类型定义 |
| `src/components/layout/NavRail.tsx` | navItems 增加 CLI Tools 导航项 |
| `src/i18n/en.ts` | 新增 `nav.cliTools` + `cli_tools.*` 英文 key |
| `src/i18n/zh.ts` | 新增 `nav.cliTools` + `cli_tools.*` 中文 key |
| `src/app/api/chat/route.ts` | system prompt 末尾追加 CLI tools context |
| `src/components/chat/MessageInput.tsx` | CLI 选择器弹窗 + CliBadge + systemPromptAppend |
+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;
}