1. 从零复刻 Claude Code CLI:终端 REPL 到底难在哪
Claude Code CLI 用起来像有个结对伙伴坐在终端里,输入一句话,它流式吐代码、能执行 shell、还能记住上下文。很多人第一次用会好奇:这东西底层是不是很复杂?其实拆开看,核心就三块——一个能接住按键的 REPL 循环、一套基于 ANSI 转义序列的终端渲染、一条稳定的模型 API 通道。前两块是终端工程的活,第三块才是大多数人卡住的地方:模型从哪来、Key 怎么管、流式响应怎么接。
这篇就按“本地命令行工具原型开发”的场景,用 AI 编程助手辅助,从零搭一个 Claude Code CLI 的骨架。重点不在堆功能,而在把 REPL 交互、流式渲染、统一 Key 接入这三件事跑通。我会给出可复制的settings.json/config.toml配置骨架,用 TaoToken 作为统一 API 通道接入模型,然后启动 REPL、验证流式输出、处理报错。适合已经会写点 Node.js 或 Python、想搞明白终端 AI 工具内部怎么转的开发者。全程不需要你从零手写每一行,AI 负责编码,你负责提需求和验收。
先说清楚这个原型的边界:它不追求复刻 Claude Code 的全部能力,只实现最小可用的 REPL 骨架——多行输入、斜杠命令、流式打印、错误兜底。把骨架跑通之后,加历史搜索、杀环、shell 模式都是往上叠模块的事。
2. TaoToken 前置:统一 Key 与 API 通道准备
在写 REPL 之前,得先解决模型调用这条链路。自己直连各家模型 API 的问题是:每个模型的 endpoint、鉴权头、流式格式都不一样,REPL 里要写一堆分支判断。用 TaoToken 做统一通道的好处是,一个 Key、一个 base URL,就能切换不同模型,REPL 侧只需要维护一套请求逻辑。
TaoToken 的定位是统一的大模型 API 接入层,官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 。你需要做的准备动作只有两步:注册后在控制台创建一个 API Key,然后记下 API 基地址https://taotoken.net/api。这个地址在配置里会作为base_url使用,注意它不带任何查询参数。
创建 Key 的路径在控制台的 API Keys 页面,生成后复制保存,它只会完整显示一次。如果你打算长期跑编码类任务、Agent 循环调用比较多,可以顺带了解下 Coding Plan,它更适合高频编码场景;只是验证模型连通性的话,用模型对话页面就能快速试。接入文档里有各语言 SDK 的示例,遇到请求格式问题优先查文档。
注意:Key 不要硬编码进源码提交到仓库。本地开发用环境变量或独立的配置文件,配置文件加进
.gitignore。
这里有个容易踩的坑:很多人把 base URL 写成带/v1或带斜杠的变体,导致 404。统一用https://taotoken.net/api,具体路径由 SDK 或请求库拼接。下面配置骨架里我会把这一点标出来。
3. 可复制配置:settings.json 与 config.toml 骨架
配置分两份:一份给 REPL 工具本身读(模型、通道、渲染参数),一份给可能用到的 CLI 生态工具读。先看 JSON 版,适合 Node.js 写的 REPL。
{ "provider": { "name": "taotoken", "base_url": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "default_model": "claude-sonnet-4-20250514", "timeout_ms": 60000, "stream": true }, "repl": { "prompt": "› ", "multiline": true, "history_size": 200, "render_mode": "fullscreen", "show_token_usage": true }, "commands": { "help": "显示帮助", "clear": "清空当前会话上下文", "model": "切换模型,用法 /model <name>", "exit": "退出 REPL" } }几个参数说明:api_key_env指向环境变量名而不是直接写 Key,启动前export TAOTOKEN_API_KEY=你的Key即可。stream: true是流式渲染的前提,关掉它 REPL 就只能等整段返回,体验差很多。render_mode设为fullscreen对应全屏刷新策略,后面渲染章节会讲。
如果你用的是 Python 或 Rust 写的 CLI,或者要给某些遵循 TOML 配置的工具复用,用这份:
[provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" default_model = "claude-sonnet-4-20250514" stream = true [repl] prompt = "› " multiline = true history_size = 200 [render] mode = "fullscreen" cursor_blink = true两份配置的字段语义一致,选你项目语言顺手的格式。加载逻辑建议写成:先读环境变量覆盖文件值,这样 CI 或临时切换 Key 时不用改文件。
// config.js import fs from "node:fs"; export function loadConfig(path = "./settings.json") { const raw = JSON.parse(fs.readFileSync(path, "utf8")); const key = process.env[raw.provider.api_key_env]; if (!key) { throw new Error(`缺少环境变量 ${raw.provider.api_key_env}`); } return { ...raw, provider: { ...raw.provider, api_key: key } }; }这段加载函数做了件重要的事:Key 缺失时立刻抛错,而不是等到发请求才报 401。REPL 启动阶段就把配置问题暴露出来,比运行到一半失败好排查得多。
4. REPL 骨架与流式渲染实现
REPL 的核心是一个循环:读输入、处理、渲染、再读。终端里要接住每个按键,必须把 stdin 切到 raw mode,否则默认的 cooked mode 要等回车才把整行给你。
import readline from "node:readline"; export class Repl { constructor(config) { this.config = config; this.buffer = ""; this.cursor = 0; this.messages = []; } start() { readline.emitKeypressEvents(process.stdin); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on("keypress", (str, key) => this.onKey(str, key)); this.render(); } onKey(str, key) { if (key.ctrl && key.name === "c") { this.buffer = ""; this.cursor = 0; } else if (key.name === "return") { if (key.shift) { this.buffer += "\n"; this.cursor++; } else { this.submit(); } } else if (key.name === "backspace") { if (this.cursor > 0) { this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor); this.cursor--; } } else if (str && !key.ctrl && !key.meta) { this.buffer = this.buffer.slice(0, this.cursor) + str + this.buffer.slice(this.cursor); this.cursor += str.length; } this.render(); } }输入缓冲区只维护buffer和cursor两个状态,渲染逻辑完全独立。这样无论终端怎么变,文本状态始终是对的。Shift+Enter 插入换行实现多行输入,普通 Enter 提交。
渲染用 ANSI 转义序列做全屏刷新。常用序列:\x1b[2J清屏、\x1b[H光标归位、\x1b[row;colH定位光标、\x1b[?25h显示光标。
render() { const lines = []; lines.push("┌─ CodeCLI v0.1.0 ─────────────────────────┐"); lines.push(`│ Model: ${this.config.provider.default_model}`); lines.push("└──────────────────────────────────────────┘"); for (const m of this.messages) { lines.push(`${m.role === "user" ? "›" : "·"} ${m.content}`); } lines.push(`› ${this.buffer}`); process.stdout.write("\x1b[2J\x1b[H"); process.stdout.write(lines.join("\n")); const row = lines.length; const col = 3 + this.cursor; process.stdout.write(`\x1b[${row};${col}H`); }流式渲染的关键在提交后的处理:请求带上stream: true,逐块读取响应,每来一个 chunk 就追加到当前消息并重绘。
async submit() { const input = this.buffer.trim(); this.buffer = ""; this.cursor = 0; if (!input) return this.render(); if (input.startsWith("/")) return this.handleCommand(input); this.messages.push({ role: "user", content: input }); const assistant = { role: "assistant", content: "" }; this.messages.push(assistant); this.render(); try { const res = await fetch(`${this.config.provider.base_url}/v1/messages`, { method: "POST", headers: { "content-type": "application/json", "x-api-key": this.config.provider.api_key, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: this.config.provider.default_model, max_tokens: 2048, stream: true, messages: this.messages.filter(m => m.content) }) }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const parts = buf.split("\n\n"); buf = parts.pop(); for (const part of parts) { const line = part.replace(/^data: /, "").trim(); if (!line || line === "[DONE]") continue; try { const evt = JSON.parse(line); const delta = evt.delta?.text || ""; if (delta) { assistant.content += delta; this.render(); } } catch { /* 忽略不完整分片 */ } } } } catch (err) { assistant.content = `[请求失败] ${err.message}`; this.render(); } }斜杠命令用一个简单路由器处理,/clear清空messages,/model改default_model,/exit退出进程。命令补全可以在输入以/开头时,把匹配的命令名渲染在输入行下方。
5. 验证请求与流式输出结果
配置和代码就位后,按顺序验证。先确认环境变量生效:
export TAOTOKEN_API_KEY=你的Key echo $TAOTOKEN_API_KEY | head -c 8输出前 8 位说明变量已设置。然后启动 REPL:
node src/index.js正常的话会看到顶部横幅和›提示符。输入一句测试:
› 用一句话解释什么是 REPL预期现象是:助手消息逐字出现,而不是等整段返回后一次性刷出。如果是一次性出现,检查stream是否为true,以及响应解析里是否漏了delta.text字段。流式生效时,你能明显看到文字像打字一样推进,这就是终端渲染在每次 chunk 到达后重绘的结果。
再验证斜杠命令和错误兜底。输入/model claude-sonnet-4-20250514切换模型,再发一句确认返回正常。然后故意把 Key 改错,重启后发请求,应该看到[请求失败] HTTP 401这样的提示,而不是进程崩溃。错误被 catch 住并渲染成消息,是 REPL 稳定性的底线。
最后测多行输入:输入第一行后按 Shift+Enter,再输入第二行,按 Enter 提交。两条内容应该作为一条消息发出。这一步验证的是输入缓冲区的换行处理是否正确。
6. 本篇常见报错排查
401 Unauthorized:Key 没读到或已失效。先echo $TAOTOKEN_API_KEY确认环境变量,再检查配置里api_key_env的名字是否和导出的一致。注意 Key 前后不要有空格。
404 Not Found:base URL 写错了。统一用https://taotoken.net/api,不要自己加/v1或结尾斜杠,路径交给请求代码拼接。如果换了模型名报 404,检查模型标识是否拼写正确。
流式输出卡住不动:多半是分片解析问题。SSE 的分片不保证按\n\n边界到达,必须用缓冲区累积再切分,代码里buf那段就是干这个的。另外确认reader.read()循环没有提前 break。
终端显示错乱、光标乱跳:ANSI 序列用错或没在 TTY 环境运行。setRawMode前判断process.stdin.isTTY,非 TTY 环境(比如管道)直接降级为普通输出。光标定位的row要按实际渲染行数算,多行输入时行数会变。
中文输入乱码或光标错位:中文字符宽度是 2,光标列计算要按显示宽度而非字符数。简单处理是渲染时用string-width这类库算宽度,别直接用length。
请求超时:长回复或网络慢时,把配置里的timeout_ms调大,或在 fetch 上加 AbortController 做超时控制,超时后渲染友好提示而不是静默挂起。
排查顺序建议固定:先看 Key 和环境变量,再看 base URL,再看请求体格式,最后看流式解析。大部分问题集中在前两步。
7. 继续往下叠:把骨架变成顺手的工具
骨架跑通后,往上加功能就是模块化的事。历史搜索可以照 Emacs 的 Ctrl+R 思路,维护一个历史数组,从末尾向前匹配;杀环(Kill Ring)用数组存每次删除的文本,Ctrl+Y 粘贴、Alt+Y 循环切换;shell 模式用!前缀触发spawn执行命令,把输出作为 tool 消息塞回对话。这些都不需要动核心的输入缓冲和渲染逻辑,正好验证了前面“输入与渲染解耦”的设计价值。
如果你打算把这个原型长期用下去,编码类任务调用频繁,可以看看 Coding Plan 是否更合适;日常调试模型连通性,模型对话页面足够快;接入细节和 SDK 用法在接入文档里都有。把配置里的 Key 换成你自己的,这套 REPL 骨架就能直接跑起来,剩下的就是按你的习惯往里加命令了。