最近我把大部分业余时间都花在了一个叫 t3code 的命令行工具上。名字里的 T3 不是 T3 Stack 那个 TypeScript + Tailwind + tRPC 的 T3,而是我自己定义的三个词:Type-safe、Test-driven、Trusted——类型安全、测试驱动、变更可信可复现。说白了,我想要的不是一个更聪明的代码补全器,而是一个能自己在终端里读代码、改代码、跑测试,然后带着 diff 回来找你确认的编码执行器。
如果你已经厌烦了在编辑器里来回切换窗口复制粘贴错误信息,或者对那种“改了十来个文件结果编译直接挂掉”的 AI 重构体验深有体会,那么这个工具的设计思路应该对你有用。文章适合三类人:想了解终端 AI 编码工作流的开发者、准备自己搭 agent 工具的动手派,以及单纯想看看“类型安全 + 测试闭环”到底能解决什么问题的围观群众。我不会把 t3code 包装成什么成熟产品,它目前就是个跑在 Node 下的实验性 CLI,但里面的思路已经在我两个真实项目上验证过一轮,值得拿出来聊聊。
1. 为什么我会自己写一个终端编码工具:从“补全”到“带目标地执行”
AI 编程工具这几年的形态变化很有意思。最开始大家都堆编辑器里的内联补全,后来聊天框里能贴代码了,再后来出现了能在终端里跑任务的 agent。工具越来越“主动”,但一个尴尬的事实是:大多数人的日常工作流,其实仍停留在“补全 + 粘贴”这个阶段。这不是用户懒,而是补全和粘贴这两种形态的天花板明显。
1.1 三种主流形态各自卡在哪
编辑器内联补全,我到现在都还在用,它在“你正写到一半、脑子里已经有了下一行”的场景里效率最高。可一旦任务变成“把用户模块的加载逻辑迁到一个自定义 hook 上”,它就彻底使不上劲了——因为这类改动跨文件、依赖类型定义、还必须在改完后验证行为没变,补全模型只看得到你光标前面的几百个 token。
对话式 AI 擅长单点输出,比如“给我写一个带重试的 fetch 封装”。但它有个致命问题:代码离开聊天窗口后,没有人替你验证它能不能编译、能不能跑通。你得手动建文件、装依赖、处理报错,再把报错粘回对话框,循环往复。偶尔一次还好,任务一多就变成了纯体力活。
终端里的任务型 agent 是这三类里我最看好的,因为它围绕“目标”工作而不是围绕“字词”工作:你说“把软删除加上”,它自己去读代码、规划、改文件、跑测试。但这类工具的默认实现有一个普遍短板——模型在靠“常识”写代码,而不是靠项目里最可靠的事实来源:类型定义和测试用例。我见过大量 agent 失败案例,失败原因不是模型笨,而是它根本看不到 tsc 的报错,改完也不跑测试,最后交回来一个“看起来对但一编译就挂”的烂摊子。
1.2 t3code 的切入点:让模型先看到事实,再动手
t3code 的起点就是补上这个短板:在终端 agent 的工作循环里,把类型检查、测试执行、diff 审查做成强制步骤,而不是可选项。任务进来先做环境探测,改动之前先读类型约束,改完之后必须过 tsc 和测试,没过就把失败信息回填给模型继续修。这套东西听起来不复杂,但把“AI 生成代码”这件事从“猜”变成了“验证驱动”。
另一个选择是为什么做成终端工具而不是 IDE 插件。终端天然适合做可审计的工作流:每一步操作都能被记录、回放,可以集成进 CI,也可以导出成日志给同事看。IDE 插件当然体验更顺滑,但“鼠标点完就不知道发生了什么”的工作方式,对要跑在无人值守场景下的编码工具来说是个致命伤。做终端工具的成本也低,一个小项目只需要 Node、一个 CLI 入口,不需要适配各种编辑器 API。
2. t3code 的核心设计:把类型安全当作一等公民
这一节讲设计层面。t3code 不是一个大模型封装,它更像一个“会使用 tsc、vitest、git 的机器人操作员”。
2.1 一条命令的完整生命周期
在终端输入:
t3code run "把用户列表页的 useEffect 数据加载逻辑迁移到 useApiQuery,渲染结果不变,vitest 通过"t3code 会按下面这条流水线执行。
| 阶段 | 主要动作 | 产出 |
|---|---|---|
| 环境探测 | 读 package.json、tsconfig、pnpm-workspace,检查 git 状态与 Node 版本 | 项目事实清单 |
| 上下文组装 | 按需读取相关文件,构建 token 预算 | 初始上下文 |
| 规划 | 模型给出改动方案与文件清单 | 任务计划 |
| 编码循环 | 读文件、写文件、跑 tsc / vitest、回填失败信息 | 通过验证的改动 |
| 结果汇报 | 输出 diff --stat、完整 diff、测试结果 | 需要人确认的变更集 |
你可能会问:为什么最后一步不直接自动提交?原因后面会具体讲,但核心是可信需要人审。自动提交完全可以,但必须保证“审计成本 < 节省的时间成本”,一旦模型改动的文件数超过你的 review 能力,自动提交就是给自己埋雷。这个判断来自一次真实事故,后面第 5 节会展开说。
2.2 类型信息为什么是“免费的信号”
这是 t3code 最重要的一个设计判断:编译错误是最好的反馈信号,而且它完全免费。模型生成代码时最常见的失败模式,就是瞎猜某个函数的参数类型,或者在一个联合类型字段上直接调用了不存在的方法。这类错误人类一眼能看出来,但模型看不见,除非你主动把错误信息喂给它。
tsc --noEmit 的输出恰好是结构化、可解析、可截断的。我把错误信息按“文件:行号:列号 - 错误描述”标准化之后,直接拼进下一轮对话的上下文。模型看到“UserList.tsx:42 - Argument of type X is not assignable to parameter of type Y”,大部分时候下一轮就能给出精准修复,而不是重写整个文件。这就是“让模型先看到自己的失败,再让它说话”。
Type-safe 原则的完整含义是:项目里已经存在的类型约束,必须无条件成为 AI 修改代码时的参考基准。模型不该发明类型,应该适配类型。这也是为什么 t3code 的上下文组装器会把 TypeScript 相关的配置和近邻文件的类型定义当成最高优先级读取对象,而不是让模型从海量文件里碰运气。
2.3 Test-driven 和 Trusted 落到代码上
Test-driven 在 t3code 里的含义很朴素:任务描述必须包含验收标准,验收标准最终会变成至少一条可执行的测试命令。没有验收标准的任务,t3code 会反问你要“什么样的结果算完成”,答不上来就不开工。这个“反问”功能最初只是为了省我的事,后来发现是整条工作流里性价比最高的一段代码。
Trusted 原则体现在三件小事上:
- 每次文件修改都会生成可回滚的 diff,绝不原地覆盖。
- 所有执行过的命令和读写过的路径都会写进任务日志。
- 写操作分“局部副作用”和“全局副作用”,全局副作用(比如安装依赖)默认要人工确认。
这三件事单独拿出来都不稀奇,合在一起的效果是:这台机器人每次干完活,你都能回答“它动了什么、为什么动、能不能回滚”。换句话说,t3code 不是让 AI 替你拍板,而是让 AI 在极其透明的约束下帮你执行,最终拍板的依然是你。这个定位决定了它和那些“一键全自动”工具的理念差异。
3. 关键模块的实现链路:从提示词组装到沙箱验证
设计再好,落不了地也是白搭。这一节我把 t3code 最核心的四个模块逐个拆开,说说实现思路和踩过的坑。
3.1 上下文组装:不把一个仓库都塞给模型
一开始我图省事,直接把仓库文件列表和 README 全塞给模型,token 爆炸不说,模型还容易被无关信息带偏——它看到一百个文件,就倾向于“雨露均沾”地乱改。后来我改成“按需阅读”策略:
初始上下文只包含环境探测阶段拿到的项目事实:package.json 里的脚本、tsconfig 关键选项、工作区结构、测试命令。接下来使用一个只读工具 grep_search 定位任务涉及的符号,再顺着 import 关系读近邻文件。每个读过的文件都会放进一个 LRU 缓存,同一文件重复读取不重复消耗 token。
token 预算我分成三块:项目事实、任务描述、运行日志。任何一块超限,先截断最旧的运行日志,绝不截断任务描述——因为模型一旦“忘了”任务目标,后面所有修改都会跑偏。这里有个真实教训:有一次运行日志里积压了二十多条 vitest 输出,模型开始对着一条无关的 deprecation 警告研究起来,完全忘了任务是加软删除。从那以后,日志块只保留最近四轮的结果。
3.2 工具协议:稳定、可审计、可回滚
模型和仓库交互的全部能力都抽象成一个工具集合,每个工具必须满足同一份接口描述:
interface Tool { name: string; description: string; inputSchema: JSONSchema; sideEffects: 'none' | 'local' | 'global'; }当前实现里有六七个工具:read_file、write_file、grep_search、list_directory、run_command、git_diff。前四个是“无副作用或局部副作用”,run_command 是“全局副作用”——凡是声明为 global 的命令,t3code 在真正执行前会把完整命令打印出来,除非你加了 --yes,否则必须手动确认。
为什么这么设计?因为模型很容易在“安装依赖”这种看似无害的操作里给你的全局环境留下东西。pnpm 装包、npm 升级、修改 shell 配置,这些行为一旦跑在无人值守模式下,事后排查成本远高于当场确认一次。工具层做统一描述,也是为了让将来接入 MCP(Model Context Protocol)时不用重写框架——工具协议和 MCP 的工具模型本来就很接近。
3.3 沙箱验证:在不动你仓库的前提下让代码跑起来
改完代码必须验证,但在原仓库里直接跑测试有几个隐患:模型可能在 package.json 里装了一堆临时依赖,可能把配置文件改坏,也可能触发了 Next.js 的缓存导致“改了代码但行为没变”的幻觉。我的方案是给每次任务创建临时工作区。
实操上我用 git worktree 从当前 commit 拉一个干净目录,把模型改动的文件同步过去,再在临时目录里跑 tsc 和 vitest。这个方案有两个已知的坑。第一个是 monorepo:pnpm workspace 的软链结构在临时目录里经常行为不一致,必须先读根目录的 pnpm-workspace.yaml 并重建 workspace。第二个是缓存:Next.js 的 .next、Vite 的 node_modules/.vite 这类目录必须排除,否则验证的是缓存而不是你的代码。
命令大概长这样(示意,不涉及具体仓库):
git worktree add ../t3code-verify HEAD # 同步模型改写过的文件到临时目录 # 在临时目录中执行 pnpm install --frozen-lockfile # 执行 pnpm tsc --noEmit && pnpm vitest run git worktree remove ../t3code-verify --force3.4 失败回填:编译错误与测试输出的“二次利用”
模型不是一次就能改对的,这很正常,关键在于失败之后怎么处理。t3code 会捕获 tsc 和 vitest 的完整输出,然后执行一次清洗:过滤掉 deprecation 警告、第三方包内部的报错、以及“文件:行号”格式无法匹配的冗余信息,只保留最近 40 行有效输出,拼成“验证事实”送回模型。
这个清洗步骤非常关键。原始输出里混着大量噪音,模型如果盯着一条无关警告死磕,会浪费好几轮。清洗之后,模型看到的是“UserList.tsx:42 类型不匹配”“Pagination.test.tsx 第 3 个用例失败:期望 0 收到 1”这种可以直接行动的信息。
我还加了一个失败策略切换:同一类错误连续出现两次,就暂停当前修复路径,强制模型先重新读相关文件、重新规划,再继续改。比如它连续两次把 enabled 字段传成布尔值而不是对象属性,第三次就不会让它继续在这个方向上打补丁,而是要求它先把 useApiQuery 的类型定义完整读一遍再动手。避免“用同一个错误方案撞墙两次”是最基本的止损手段。
4. 实测复盘:一个重构任务从下命令到合并的全过程
光讲设计是不够的,我拿一个真实跑过的任务来复盘。项目是一个中等规模的 Next.js + tRPC + Prisma 应用,用户列表页和用户详情页共用一套基于 useEffect 的数据加载逻辑,而项目里其实已经封装好了 useApiQuery 这个自定义 hook。我要验证的,就是 t3code 能不能自动完成这次迁移。命令是:
t3code run "把 UserList.tsx 与 UserDetail.tsx 的 useEffect 数据加载逻辑迁移到 useApiQuery,对外渲染行为不变,tsc --noEmit 与 vitest 全部通过,diff 尽量小,不修改 eslint 配置"4.1 前两轮:读代码、定方案、撞上类型约束
第一轮,t3code 用 grep_search 定位了 useEffect 的所有出现位置,确认两个文件确实在用同一套逻辑。它给出的方案是抽到公共逻辑里,两个页面共用 useApiQuery,方案本身符合我的预期。规划阶段它没有急着改代码,而是先把两个文件的 import 区域和对应 hook 的引用情况列了出来,这个行为模式很重要——好的 agent 应该先画清楚改动范围,而不是直接开写。
第二轮它就直接动手了。UserList.tsx 被整体重写,乍看逻辑没问题,但 tsc 报了一个错:useApiQuery 要求传入带 enabled 字段的 options,而原逻辑在条件不满足时直接不发请求。第一版编译失败。注意这个失败其实是好事,因为模型在第一轮根本没有读 useApiQuery 的类型定义,它只是凭函数名和形式上推断;tsc 的报错给了它精确的修正方向,第二轮这个失败直接省掉了后面潜在的运行时 bug。
4.2 第三轮:最小修复与“无关 diff”的翻车
第三轮里,模型没有重写整个文件,而是在 options 里补了 enabled: false。为什么行为这么收敛?我复盘发现,上下文里携带了“上一轮 diff”作为近期修改记录,模型能清楚看到自己上一轮改了哪些行,从而倾向于最小改动。这一点非常重要,diff 回填是对抗“模型爱乱改”最有效的手段。
但这一轮也翻车了:模型顺带改了 UserDetail.tsx 里的一个样式类名。原因是在 grep 结果里它看到了这个类名和加载逻辑出现在同一个文件中,下意识觉得“顺手一起改了吧”。这正是我坚持在结果汇报阶段必须展示每个文件的 diff 行数的原因——光看总体 diff 很难发现一个不该动的文件被改了 5 行。我在 review 时一眼抓住,手动还原了那一处。这类无关改动不会让测试失败,但会污染 git 历史,只能靠人在最后把关。
4.3 与补全、对话式工具的体感差异
这类任务用编辑器内联补全基本无解,因为改动横跨两个文件、依赖外部 hook 的类型签名。用对话式工具能做,但你需要手动把两个文件的相关代码、hook 的类型定义、编译报错一遍遍粘贴过去,改一轮至少五分钟,而且过程中你始终是“那个负责夹在 AI 和编译器之间的中间人”。
t3code 的整个流程我没有干预,只在最后 review。我认为体感差别最大的不是速度,而是失败变得可见了:它不会把改错的东西直接盖在你代码上,而是先放在临时目录里跑验证,验证不过就自己修,修完再把 diff 拿给你看。对于一个对代码质量有要求的人来说,这个“隔着一层沙箱”的距离感,比“模型在你工作区里直接写文件”要安心太多。
我也跑过失败的案例。一次涉及 monorepo 的任务,t3code 没先读根目录的 pnpm-workspace.yaml,按照单一 package 的方式处理,结果临时目录里依赖安装失败,白费了三轮。这个教训后来直接变成了环境探测阶段的一项强制逻辑:检测到 workspace 文件就优先重建 workspace,再谈其他。
5. 目前踩过的坑与正在做的三件事
工具没有被吹到天上,它现在的成熟度大概相当于“一个比较靠谱的初级开发,需要 code review”。继续用的过程中,我又攒了几个坑。
5.1 token 噪音与“死磕同一条错误”
模型窗口够大不代表上下文质量高。运行日志里塞满第三方库的 deprecation 警告时,模型的表现会明显下降,甚至做出“研究警告”这种偏离任务的事情。我现在对输出做两级过滤:先按文件路径白名单去掉 node_modules 和缓存目录内的信息,再按“error / fail / expected”这类关键词提取有效行。过滤之后,模型收到的每一条信息都值得它花一次注意力。
死磕问题也是真实存在的:同一编译错误连续出现时,模型会下意识换个写法再试一次,然后继续死磕。t3code 的失败策略切换解决了这个问题的另一半:连续两次看到同类失败,就必须回到“重新读文件、重新规划”的暂停状态,而不是继续在同一个方案上打转。这种强制暂停看起来打断了流畅性,但长远看反而省 token,因为模型不再用同一套错误逻辑反复撞击同一面墙。
5.2 为什么我保留了手动确认而不是全自动
我试过 --yes 模式,体验确实爽,直到某次任务里模型把 package.json 的 devDependencies 重排了,合并后 git blame 一片混乱;另一次它删了一个看起来“没被引用”的导出函数,结果一个运行时才加载的模块直接崩了。这两件事提醒我:全自动适合“只改单文件且测试全过”的极简场景,不适合任何涉及配置或跨模块的任务。
现在的默认策略是:改动文件数超过 5 个时,必须分批确认;涉及 package.json 的修改永远确认;只有改动文件少于等于 3 个且验证全过的任务,才允许 --yes。这个策略不酷,但很稳。工具的价值在于让人少做重复劳动,而不是让人完全退出决策,全自动的收益再大,也比不上一次线上事故的代价。
5.3 下一步要做的事
我正在改三件事。第一件是项目档案:在 .t3code/project.json 里缓存技术栈、常用命令、测试入口、工作区结构,让后续任务免去反复探测。第二件是重放日志:每次任务的输入、模型输出、验证结果全部记录成 JSONL,方便复现问题和在 CI 上做回归。第三件是 MCP 互通:既然工具协议已经长得像个简化版 MCP,不如直接注册成 MCP server,让其他支持 MCP 的终端 agent 也能调用 t3code 的读文件和验证工具,不把自己困在小生态里。
最后分享一个我在实际使用中体会最深的东西:任务描述里只要带上了可验证的验收标准,成功率会高一个量级。“给用户列表加一个删除按钮”和“给用户列表加删除按钮,点击后该用户从列表移除并弹出成功提示,vitest 通过”,对 agent 来说是两种完全不同的任务。前者它只能猜,后者它有明确的终点。我现在写任务描述时,至少会问自己一句:做完以后,我用什么命令能立刻知道它做对了?这个问题问清楚了,t3code 这类工具才能真正跑起来。