☰
【AI助手开发】【Claude Agent SDK】终端智能助手开发实战2:TypeScript+Ink构建CLI交互界面
2026/9/26 10:41:45 网站建设 项目流程

1. 从一次终端报错说起:为什么要在 CLI 里做智能助手

你在终端敲下bun run dev,屏幕上刷出一段 TypeScript 报错,红字里夹着Type 'string' is not assignable to type 'number'。这时候如果有个助手能直接读报错、定位文件、改代码、再跑一遍验证,你就不用切窗口、不用复制粘贴到网页对话框。这就是 Claude Agent SDK 搭配 TypeScript 与 Ink 想解决的问题:把「对话 + 工具调用 + 流式输出」塞进一个终端界面里。

Claude Agent SDK 负责的是 agentic loop——组装上下文、调模型、收流式响应、解析工具调用、执行工具、把结果回传再调模型。Ink 负责的是界面层,它把 React 的组件模型搬到终端,让你用<Box>、<Text>这类组件描述布局,用useState、useEffect管理消息流。两者拼起来,就是一个可交互的 CLI 智能助手原型。

这篇适合谁:已经会写 TypeScript、用过 React,但没在终端里渲染过 UI 的开发者;或者你已经跑通了 SDK 的基础调用,想给它套一个能持续对话的界面。我会给出可复制的 Ink 组件骨架、SDK 初始化配置、本地运行验证步骤,以及几个我踩过的坑。全程不需要你理解终端渲染的底层原理,照着搭就能跑。

核心检索词先摆出来:Claude Agent SDK 是 agent 运行时,TypeScript 提供类型约束,Ink 做 React 式终端渲染,CLI 是最终形态。四者关系是 SDK 管逻辑、Ink 管显示、TS 管类型、CLI 管入口。

2. 前置准备:TaoToken 接入与项目初始化

2.1 拿到可用的 API Key

Claude Agent SDK 最终要调模型,所以你得先有一个能用的接入点。我这边用的是 TaoToken 的 API 服务,它兼容 Anthropic 的接口格式,SDK 里改一下baseURL就能对接。

先去控制台创建 API Key:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite

创建完把 Key 复制出来,形如sk-xxxxxxxx。注意别把它硬编码进源码提交到 git,后面我会用环境变量读。

2.2 初始化 TypeScript 项目

用 bun 还是 npm 都行,我这里用 bun,启动快。先建目录、初始化:

mkdir cli-agent && cd cli-agent bun init -y bun add @anthropic-ai/claude-agent-sdk ink react bun add -d typescript @types/react

如果你用 npm,把bun add换成npm install即可。装完后确认package.json里有"type": "module",Ink 和 SDK 都走 ESM。

2.3 配置 tsconfig

Ink 用的是 React 的 JSX 语法,但渲染目标是终端,所以jsx要设成react-jsx,moduleResolution用bundler:

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["bun-types"] }, "include": ["src"] }

strict: true别关,SDK 的消息类型定义挺细的,开着能帮你少写错字段名。

2.4 设置环境变量

在项目根目录建.env:

TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://taotoken.net/api

然后在.gitignore里加上.env。SDK 初始化时会读这两个值。

3. 可复制配置:SDK 初始化与 Ink 组件骨架

3.1 SDK 客户端初始化

先写一个src/agent.ts,把 SDK 客户端和一次对话的调用封装起来:

import Anthropic from "@anthropic-ai/claude-agent-sdk"; const client = new Anthropic({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }); export type ChatMessage = { role: "user" | "assistant"; content: string; }; export async function* streamReply( history: ChatMessage[] ): AsyncGenerator<string> { const stream = await client.messages.stream({ model: "claude-sonnet-4-20250514", max_tokens: 2048, messages: history.map((m) => ({ role: m.role, content: m.content, })), }); for await (const event of stream) { if ( event.type === "content_block_delta" && event.delta.type === "text_delta" ) { yield event.delta.text; } } }

这里用messages.stream而不是messages.create,因为终端界面要的是逐字冒出来的效果,等整段返回再渲染体验很差。streamReply是个异步生成器,每收到一个文本增量就yield出去,Ink 组件里用for await消费。

3.2 Ink 组件骨架

Ink 的组件写法和 React 几乎一样,区别是标签换成<Box>和<Text>。建src/App.tsx:

import React, { useState, useCallback } from "react"; import { Box, Text, useInput, useApp } from "ink"; import { streamReply, type ChatMessage } from "./agent.js"; export function App() { const { exit } = useApp(); const [history, setHistory] = useState<ChatMessage[]>([]); const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); useInput((char, key) => { if (key.return) { if (input.trim() === "") return; void send(input.trim()); setInput(""); return; } if (key.backspace || key.delete) { setInput((prev) => prev.slice(0, -1)); return; } if (key.ctrl && char === "c") { exit(); return; } if (!key.ctrl && !key.meta) { setInput((prev) => prev + char); } }); const send = useCallback( async (text: string) => { const next: ChatMessage[] = [ ...history, { role: "user", content: text }, ]; setHistory([...next, { role: "assistant", content: "" }]); setStreaming(true); let acc = ""; for await (const chunk of streamReply(next)) { acc += chunk; setHistory((prev) => { const copy = [...prev]; copy[copy.length - 1] = { role: "assistant", content: acc }; return copy; }); } setStreaming(false); }, [history] ); return ( <Box flexDirection="column" padding={1}> <Box marginBottom={1}> <Text bold color="cyan"> CLI Agent </Text> <Text dimColor> (Ctrl+C 退出)</Text> </Box> {history.map((msg, i) => ( <Box key={i} marginBottom={1}> <Text color={msg.role === "user" ? "green" : "white"}> {msg.role === "user" ? "你: " : "AI: "} </Text> <Text>{msg.content}</Text> </Box> ))} <Box> <Text color="green">{"> "}</Text> <Text>{input}</Text> {streaming ? <Text dimColor> ...</Text> : null} </Box> </Box> ); }

几个关键点解释一下。useInput是 Ink 提供的键盘输入钩子,key.return对应回车,key.ctrl && char === "c"处理退出。消息历史用数组存,每次流式增量更新最后一条 assistant 消息的内容,这样界面上就是逐字冒出来的效果。useApp拿到exit函数,Ctrl+C 时干净退出。

3.3 入口文件

建src/cli.tsx:

#!/usr/bin/env bun import React from "react"; import { render } from "ink"; import { App } from "./App.js"; render(<App />);

然后在package.json里加脚本:

{ "scripts": { "dev": "bun run src/cli.tsx" } }

4. 验证请求:本地跑通一次对话

4.1 启动

bun run dev

终端会清屏,顶部出现CLI Agent (Ctrl+C 退出),下面是一个>提示符。输入用 TypeScript 写一个防抖函数,回车。

4.2 预期结果

你应该看到你: 用 TypeScript 写一个防抖函数这行出现,紧接着AI:后面开始逐字冒出代码。流式过程中末尾有个...提示,结束后消失。再输入一句加上类型注解,AI 会基于上文继续改。

如果一切正常,说明 SDK 接入、Ink 渲染、流式消费三条链路都通了。这时候你可以试着输入一个真实的报错信息,比如把某段代码故意写错,让助手帮你定位——这就是 agentic 场景的雏形。

4.3 验证 API 连通性

如果界面起来了但 AI 一直不回复,先单独验证 API 是否通。写个最小脚本:

import Anthropic from "@anthropic-ai/claude-agent-sdk"; const client = new Anthropic({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }); const res = await client.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 64, messages: [{ role: "user", content: "回复 ok" }], }); console.log(res.content);

用bun run verify.ts跑,能打印出内容就说明 Key 和 baseURL 没问题,问题在 Ink 层。

5. 本篇常见错排查

5.1 JSX 报错:Cannot find module 'react/jsx-runtime'

这是tsconfig.json里jsx没设成react-jsx,或者@types/react没装。检查两处:"jsx": "react-jsx",以及bun add -d @types/react是否执行过。

5.2 Ink 渲染后终端乱码或光标错位

多半是render被调用了多次,或者组件里有非 Ink 的console.log。Ink 接管终端后,任何console.log都会打乱布局。排查方法:全局搜console.log,换成 Ink 的<Text>输出。另外确认入口只render一次。

5.3 流式输出不更新界面

如果你用的是messages.create而不是messages.stream,那当然不会逐字更新。另一个可能是setHistory里直接改了原数组,React 检测不到变化。上面代码里我用了[...prev]复制再改,就是为了触发重渲染。还有一点:for await循环里每次setHistory都会触发渲染,如果模型返回很快,可能看起来像一次性出现,这是正常的。

5.4 401 或 403 错误

先确认.env里的 Key 没有多余空格,baseURL结尾没有多余的/。TaoToken 的 API 地址是https://taotoken.net/api,SDK 会自动拼/v1/messages。如果还是 401,去控制台重新生成一个 Key 试试:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite

5.5 模型名报错 model not found

不同接入点支持的模型名可能不同。如果claude-sonnet-4-20250514报错,换成你账号下可用的模型名。可以在模型对话页面先确认可用模型:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite

5.6 回车后没反应

检查useInput里的key.return分支,确认send被调用了。有个隐蔽的坑:useInput的回调里如果直接await,Ink 不会等你,得用void send(...)或者把异步逻辑包一层。上面代码里我用了void send(input.trim()),就是这个原因。

6. 继续往下走:从原型到可用工具

跑通上面这套骨架后,你手里已经有一个能持续对话、流式渲染的终端助手了。但它还只是个聊天界面,没有工具调用能力。Claude Agent SDK 真正的价值在于 agentic loop——让模型自己决定调哪个工具、传什么参数、何时停止。下一步可以做的事:

把streamReply扩展成支持工具调用的循环,解析tool_use事件,执行本地函数(读文件、跑命令),把结果作为tool_result回传。这部分逻辑比界面复杂,建议单独抽一个QueryEngine类管理 turn 生命周期。

如果你打算长期在这个项目上迭代,或者要把它接进 CI/CD 做自动化,可以看看 Coding Plan 的额度方案,比按量计费更适合高频调用:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite

接入文档在这里,工具调用、流式事件类型、错误码都有说明:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite

最后说个实际经验:Ink 的布局系统和 CSS 的 flexbox 很像,但终端没有像素概念,宽度按字符算。调试布局时把<Box>的borderStyle打开,能直观看到每个盒子的边界,比盲猜快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询