ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

【AI助手开发】【Claude Agent SDK】终端智能助手开发实战2:TypeScript+Ink构建CLI交互界面

【AI助手开发】【Claude Agent SDK】终端智能助手开发实战2:TypeScript+Ink构建CLI交互界面 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 KeyClaude Agent SDK 最终要调模型所以你得先有一个能用的接入点。我这边用的是 TaoToken 的 API 服务它兼容 Anthropic 的接口格式SDK 里改一下baseURL就能对接。先去控制台创建 API Keyhttps://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite创建完把 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: moduleInk 和 SDK 都走 ESM。2.3 配置 tsconfigInk 用的是 React 的 JSX 语法但渲染目标是终端所以jsx要设成react-jsxmoduleResolution用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 设置环境变量在项目根目录建.envTAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://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[] ): AsyncGeneratorstring { 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.tsximport 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] useStateChatMessage[]([]); 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 flexDirectioncolumn padding{1} Box marginBottom{1} Text bold colorcyan CLI Agent /Text Text dimColor (CtrlC 退出)/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 colorgreen{ }/Text Text{input}/Text {streaming ? Text dimColor .../Text : null} /Box /Box ); }几个关键点解释一下。useInput是 Ink 提供的键盘输入钩子key.return对应回车key.ctrl char c处理退出。消息历史用数组存每次流式增量更新最后一条 assistant 消息的内容这样界面上就是逐字冒出来的效果。useApp拿到exit函数CtrlC 时干净退出。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 (CtrlC 退出)下面是一个提示符。输入用 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/apiSDK 会自动拼/v1/messages。如果还是 401去控制台重新生成一个 Key 试试https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite5.5 模型名报错 model not found不同接入点支持的模型名可能不同。如果claude-sonnet-4-20250514报错换成你账号下可用的模型名。可以在模型对话页面先确认可用模型https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite5.6 回车后没反应检查useInput里的key.return分支确认send被调用了。有个隐蔽的坑useInput的回调里如果直接awaitInk 不会等你得用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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档在这里工具调用、流式事件类型、错误码都有说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite最后说个实际经验Ink 的布局系统和 CSS 的 flexbox 很像但终端没有像素概念宽度按字符算。调试布局时把Box的borderStyle打开能直观看到每个盒子的边界比盲猜快得多。
返回列表