
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_sourcetaotoken_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两个状态渲染逻辑完全独立。这样无论终端怎么变文本状态始终是对的。ShiftEnter 插入换行实现多行输入普通 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 位说明变量已设置。然后启动 REPLnode src/index.js正常的话会看到顶部横幅和›提示符。输入一句测试› 用一句话解释什么是 REPL预期现象是助手消息逐字出现而不是等整段返回后一次性刷出。如果是一次性出现检查stream是否为true以及响应解析里是否漏了delta.text字段。流式生效时你能明显看到文字像打字一样推进这就是终端渲染在每次 chunk 到达后重绘的结果。再验证斜杠命令和错误兜底。输入/model claude-sonnet-4-20250514切换模型再发一句确认返回正常。然后故意把 Key 改错重启后发请求应该看到[请求失败] HTTP 401这样的提示而不是进程崩溃。错误被 catch 住并渲染成消息是 REPL 稳定性的底线。最后测多行输入输入第一行后按 ShiftEnter再输入第二行按 Enter 提交。两条内容应该作为一条消息发出。这一步验证的是输入缓冲区的换行处理是否正确。6. 本篇常见报错排查401 UnauthorizedKey 没读到或已失效。先echo $TAOTOKEN_API_KEY确认环境变量再检查配置里api_key_env的名字是否和导出的一致。注意 Key 前后不要有空格。404 Not Foundbase 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 的 CtrlR 思路维护一个历史数组从末尾向前匹配杀环Kill Ring用数组存每次删除的文本CtrlY 粘贴、AltY 循环切换shell 模式用!前缀触发spawn执行命令把输出作为 tool 消息塞回对话。这些都不需要动核心的输入缓冲和渲染逻辑正好验证了前面“输入与渲染解耦”的设计价值。如果你打算把这个原型长期用下去编码类任务调用频繁可以看看 Coding Plan 是否更合适日常调试模型连通性模型对话页面足够快接入细节和 SDK 用法在接入文档里都有。把配置里的 Key 换成你自己的这套 REPL 骨架就能直接跑起来剩下的就是按你的习惯往里加命令了。