从零构建现代化TypeScript CLI工具:AI Agent开发的工程化实践
1. 项目缘起与核心目标
最近几年,AI Agent 这个概念火得不行,从 AutoGPT 到各种智能体框架,感觉不搞点 Agent 相关的东西,都不好意思说自己在做前沿开发。但说实话,很多现成的框架要么太重,要么定制化程度不够,想快速验证一个想法或者集成到现有工作流里,总感觉隔着一层。于是,我就琢磨着,能不能自己动手,从零开始打造一个轻量、可扩展、符合现代工程标准的 Agent CLI 工具?这不仅是学习 Agent 内部运作机制的好机会,更能打造一个完全贴合自己需求的“瑞士军刀”。
这个系列文章,就是记录我如何一步步把这个想法落地的全过程。第一期,我们不谈复杂的 Agent 逻辑,先打好地基——做好项目初始化与工程基建。这听起来可能有点枯燥,但相信我,一个扎实的工程基础,能让你在后续开发中避免无数“坑”,跑得更快、更稳。我们将使用 TypeScript 作为开发语言,配合一系列现代前端/Node.js 工具链,目标是建立一个代码质量高、测试完备、构建流程清晰的 CLI 项目骨架。无论你是想学习如何构建一个专业的 CLI 工具,还是对 Agent 开发感兴趣,希望从工程化角度入手,这篇文章都能给你提供一份可直接复用的“脚手架”方案。
2. 技术栈选型与核心思路拆解
在动手写代码之前,我们先得把“用什么”和“为什么用”想清楚。技术选型不是堆砌时髦名词,而是为项目目标服务。
2.1 核心语言:为什么是 TypeScript?
对于 CLI 工具,尤其是涉及复杂逻辑和未来可能集成 AI 能力的 Agent 来说,JavaScript 的动态类型特性在项目规模增长后会成为维护的噩梦。TypeScript 提供了静态类型检查,能在编码阶段就捕获大量潜在错误(比如拼写错误、参数类型不匹配),这对于提高代码质量和开发体验至关重要。此外,现代 Node.js 生态中许多优秀的库(如 commander , inquirer , ora 等)都提供了良好的 TypeScript 类型定义,集成起来非常顺畅。从长远看,TypeScript 带来的类型安全和智能提示,其收益远大于初期学习曲线带来的成本。
2.2 构建与打包工具:ESBuild 与 Tsup 的权衡
CLI 工具最终需要打包成单个可执行文件(或少量文件)以便分发。常见的打包工具有 Webpack、Rollup、ESBuild 和 Tsup。
- Webpack/Rollup :功能强大,插件生态丰富,但配置相对复杂,对于 CLI 这种相对简单的场景有些“杀鸡用牛刀”。
- ESBuild :用 Go 编写,速度极快,但原生对 TypeScript 的处理仅限于转译(Transpile),不进行类型检查,且插件生态较新。
- Tsup :基于 ESBuild,专为 TypeScript 库和 CLI 工具设计。它“开箱即用”,零配置就能打包 TypeScript,支持生成多种格式(CJS, ESM),并且能自动处理依赖。对于我们的项目, Tsup 是一个更省心、更专注的选择。
2.3 代码质量守护:ESLint & Prettier
团队协作和个人项目的代码风格统一都离不开它们。
- ESLint :负责代码质量检查,发现潜在的错误、不好的代码习惯。我们可以使用像
@typescript-eslint这样的插件来获得针对 TypeScript 的规则。 - Prettier :负责代码格式化,自动将代码整理成统一的风格(缩进、分号、引号等)。让 ESLint 专注于逻辑问题,Prettier 专注于样式问题,二者通过插件配合,可以打造非常流畅的编码体验。
2.4 测试框架:Vitest 的崛起
测试是工程基建不可或缺的一环。Jest 曾是主流,但 Vitest 凭借其与 Vite 生态的完美融合、极快的速度和对 ESM 的原生支持,正在成为许多新项目的首选。它兼容大部分 Jest 的 API,学习成本低,而且热更新速度飞快,特别适合在开发过程中进行 TDD(测试驱动开发)。对于我们的 CLI 工具,需要测试命令解析、函数逻辑、可能有的网络请求模拟等,Vitest 都能很好地胜任。
2.5 项目结构与 Monorepo 考量
我们计划采用一个清晰的项目结构。虽然初期可能只是一个简单的 CLI,但考虑到 Agent 功能模块可能会逐渐增多(例如,不同的工具集、模型接入层、记忆模块等),采用一种易于扩展的结构是明智的。我们不会一开始就上复杂的 Monorepo 工具(如 Turborepo, Nx),但会在目录结构上为未来的模块化留出空间,例如将核心的 Agent 逻辑、工具函数、CLI 命令控制器等分目录存放。
核心思路总结 :我们的目标是建立一个 “类型安全 + 高效构建 + 代码规范 + 测试覆盖” 的现代 TypeScript CLI 项目基底。所有工具的选择都围绕提升开发效率、保障代码质量和简化部署流程展开。
3. 从零开始的工程基建实操
理论说完,我们开始动手。请确保你的系统已经安装了 Node.js(建议 LTS 版本,如 18.x 或 20.x)和 npm/yarn/pnpm 包管理器。我个人推荐使用 pnpm ,因为它更快、更节省磁盘空间。
3.1 初始化项目与基础配置
首先,创建一个新的项目目录并初始化。
mkdir my-agent-cli && cd my-agent-cli
pnpm init
这会生成一个 package.json 文件。接下来,我们安装 TypeScript 作为开发依赖,并初始化 TS 配置。
pnpm add -D typescript @types/node
npx tsc --init
生成的 tsconfig.json 需要根据 CLI 工具的特点进行调整。下面是一个推荐的配置:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true, // 生成 .d.ts 类型声明文件
"declarationDir": "./dist/types",
"sourceMap": true // 方便调试
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
关键点解析 :
"target": "ES2022":使用较新的 ECMAScript 标准,以获得更好的性能和语言特性支持。"module": "ESNext"和"moduleResolution": "node":配置为 Node.js 环境下的 ES 模块解析。"outDir"和"rootDir":明确源代码和输出目录。"declaration": true:为你的库生成类型声明文件,如果未来其他项目引用你的核心逻辑会非常有用。"sourceMap": true:打包后,如果运行出错,错误栈能映射回源代码位置,便于调试。
3.2 集成 ESLint 与 Prettier
安装必要的依赖:
pnpm add -D eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier
@typescript-eslint/parser:ESLint 解析 TypeScript 的解析器。@typescript-eslint/eslint-plugin:提供 TypeScript 相关的 ESLint 规则。eslint-config-prettier:关闭所有与 Prettier 冲突的 ESLint 规则。eslint-plugin-prettier:将 Prettier 作为 ESLint 规则来运行。
创建 .eslintrc.cjs 配置文件(使用 .cjs 扩展名确保在 ESM 项目中也能被正确识别为 CommonJS 模块):
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json',
},
plugins: ['@typescript-eslint', 'prettier'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked', // 使用类型检查的推荐规则
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:prettier/recommended', // 必须放在最后,用于覆盖冲突的格式规则
],
root: true,
env: {
node: true,
es2022: true,
},
ignorePatterns: ['dist', 'node_modules', '*.cjs', '*.mjs'],
rules: {
// 可以在这里添加或覆盖自定义规则
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], // 允许以下划线开头的参数未使用
'prettier/prettier': 'error', // 将 Prettier 问题标记为错误
},
};
创建 .prettierrc 配置文件,定义你喜欢的代码风格:
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf"
}
最后,在 package.json 中添加脚本:
{
"scripts": {
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\""
}
}
现在,你可以运行 pnpm run lint 检查代码问题, pnpm run lint:fix 尝试自动修复, pnpm run format 格式化所有代码。
实操心得 :配置 ESLint 时,很容易遇到解析错误。如果遇到类似“
Parsing error: Cannot read file 'tsconfig.json'”的错误,请检查parserOptions.project的路径是否正确,以及tsconfig.json文件是否存在。另外,确保你的.eslintrc.cjs文件位于项目根目录。
3.3 配置 Vitest 进行单元测试
安装 Vitest 和相关工具:
pnpm add -D vitest @vitest/ui happy-dom
@vitest/ui:提供一个漂亮的浏览器界面来查看和运行测试。happy-dom:一个轻量级的浏览器环境模拟器,如果你需要测试一些涉及 DOM 的代码(虽然 CLI 中不常见),可以用它。这里我们先装上备用。
创建 vitest.config.ts 配置文件:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // 类似 Jest,可以使用 describe, it, expect 等全局 API
environment: 'node', // 测试环境设为 Node.js
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'], // 测试文件匹配模式
coverage: {
provider: 'v8', // 使用 V8 的内置覆盖率工具
reporter: ['text', 'json', 'html'], // 生成多种格式的覆盖率报告
exclude: ['**/node_modules/**', '**/dist/**', '**/*.config.*'], // 排除目录
},
},
});
在 package.json 中添加测试脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
现在,创建一个简单的测试文件来验证配置。在 src/utils/math.test.ts 中:
import { describe, it, expect } from 'vitest';
import { add } from './math'; // 假设我们有一个 math.ts
describe('math utilities', () => {
it('should add two numbers correctly', () => {
expect(add(1, 2)).toBe(3);
expect(add(-1, 5)).toBe(4);
});
});
对应的 src/utils/math.ts :
export function add(a: number, b: number): number {
return a + b;
}
运行 pnpm run test ,你会看到测试通过。运行 pnpm run test:ui 可以打开浏览器界面进行交互式测试。
3.4 使用 Tsup 进行构建与打包
安装 Tsup:
pnpm add -D tsup
在 package.json 中配置构建脚本,并修改 main , module , types 等字段指向构建产物:
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/types/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch"
}
}
创建 tsup.config.ts 文件进行更详细的构建配置:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'], // 入口文件
format: ['cjs', 'esm'], // 生成 CommonJS 和 ES Module 两种格式
dts: true, // 生成类型声明文件 .d.ts
sourcemap: true, // 生成 sourcemap
clean: true, // 每次构建前清理 dist 目录
minify: process.env.NODE_ENV === 'production', // 生产环境压缩
outDir: 'dist',
// 如果需要排除某些依赖,使其成为外部依赖(不打包进bundle)
// external: ['commander', 'openai'],
// 如果需要为 CLI 生成 shebang,可以配置 banner
// banner: { js: '#!/usr/bin/env node\n' },
});
注意事项 :对于 CLI 工具,通常我们最终希望用户通过
npm install -g my-agent-cli安装,然后直接运行my-agent-cli命令。这需要在package.json中配置"bin"字段,并确保入口文件顶部有#!/usr/bin/env node(shebang)。我们会在下一篇文章具体实现 CLI 命令时详细配置。Tsup 的banner选项可以帮我们自动添加 shebang。
现在运行 pnpm run build ,你会在 dist 目录下看到生成的文件: index.cjs , index.mjs , 以及 types 文件夹下的类型声明文件。
3.5 完善开发脚本与 Git 钩子
为了提高团队协作效率和代码提交质量,我们集成 husky 和 lint-staged ,在提交代码前自动运行代码检查和格式化。
pnpm add -D husky lint-staged
初始化 husky:
npx husky init
这会在项目根目录创建 .husky 文件夹,并添加 pre-commit 钩子示例。修改 .husky/pre-commit 文件内容为:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
然后在 package.json 中配置 lint-staged :
{
"lint-staged": {
"src/**/*.ts": [
"prettier --write",
"eslint --fix"
]
}
}
这样,每次执行 git commit 时, lint-staged 会自动对暂存区(staged)的 TypeScript 文件运行 Prettier 格式化和 ESLint 修复,确保提交的代码符合规范。
最后,我们完善 package.json 中的脚本部分,形成一个完整的工作流:
{
"scripts": {
"dev": "tsup --watch", // 开发模式,监听文件变化并构建
"build": "tsup",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build", // 在 npm publish 前自动构建
"prepare": "husky install" // 在 npm install 后自动安装 husky 钩子
}
}
4. 目录结构设计与模块化思考
一个清晰的目录结构是项目可维护性的基础。以下是我为这个 Agent CLI 项目设计的初始结构:
my-agent-cli/
├── .husky/ # Git 钩子
├── dist/ # 构建输出目录(.gitignore)
├── src/
│ ├── cli/ # CLI 命令入口和命令定义
│ │ ├── index.ts # CLI 主入口(注册命令)
│ │ └── commands/ # 各个子命令的实现
│ │ ├── init.ts # 例如:初始化 Agent 配置的命令
│ │ └── run.ts # 运行 Agent 的命令
│ ├── core/ # Agent 核心逻辑
│ │ ├── agent/ # Agent 主循环、推理逻辑
│ │ ├── memory/ # 记忆模块(如对话历史存储)
│ │ ├── tools/ # Agent 可用的工具集(如搜索、计算、文件操作)
│ │ └── llm/ # 大语言模型客户端封装(如 OpenAI, Anthropic)
│ ├── utils/ # 通用工具函数
│ │ ├── logger.ts # 日志工具
│ │ ├── config.ts # 配置文件读写
│ │ └── validator.ts # 数据验证
│ ├── types/ # 全局类型定义
│ │ └── index.ts
│ └── index.ts # 库模式的主出口(如果需要被其他模块引用)
├── tests/ # 集成测试或 E2E 测试(可选,单元测试通常与源码放一起)
├── .eslintrc.cjs
├── .prettierrc
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
├── package.json
└── README.md
设计思路 :
src/cli/:专注于命令行交互。使用像commander或yargs这样的库来解析参数、定义命令和子命令。这部分应该尽可能薄,主要调用core/中的逻辑。src/core/:这是项目的核心,包含 Agent 的所有业务逻辑。严格按功能模块划分,便于独立开发、测试和替换。例如,更换另一个 LLM 提供商,只需修改llm/下的代码。src/utils/和src/types/:存放共享的辅助代码和类型定义,避免循环依赖。- 测试文件 :我倾向于将单元测试文件(
.test.ts或.spec.ts)放在与其测试的源码文件相邻的位置。例如,src/utils/logger.ts的测试文件就是src/utils/logger.test.ts。这样在查看源码时,能很容易地找到对应的测试。
这种结构为未来功能扩展留下了充足空间。当 tools/ 目录下工具越来越多,或者需要支持多种 memory 后端时,模块化的优势就会体现出来。
5. 常见问题与排查技巧实录
在搭建这套基建的过程中,你可能会遇到一些典型问题。这里记录了我踩过的坑和解决方法。
5.1 TypeScript 配置与模块解析问题
问题 :在导入模块时,VS Code 或 TypeScript 编译器报错“找不到模块”或“没有默认导出”。 排查 :
- 检查
tsconfig.json:确保"moduleResolution": "node"已设置。检查"include"字段是否包含了你的src目录。 - 检查包类型 :如果你导入的是一个 CommonJS 包(在
package.json中没有"type": "module"),而你的tsconfig.json中"module"设置为"ESNext",可能需要设置"esModuleInterop": true来兼容。 - 路径别名 :如果项目复杂,可能会配置路径别名(如
@/*)。确保tsconfig.json中的"paths"和打包工具(Tsup)中的别名配置一致。Tsup 可以通过--alias标志或在配置文件中配置。
5.2 ESLint 与 Prettier 规则冲突
问题 :保存文件时,ESLint 和 Prettier 的自动修复互相“打架”,导致格式来回变化。 解决 :这是配置顺序问题。务必确保在 .eslintrc.cjs 的 extends 数组中, 'plugin:prettier/recommended' 放在 最后 。这个配置集成了 eslint-config-prettier 和 eslint-plugin-prettier ,能正确关闭冲突的规则并将 Prettier 作为规则运行。
5.3 Vitest 测试中遇到全局 API 未定义
问题 :在测试文件中使用 describe , it , expect 时,TypeScript 报错“找不到名称”。 解决 :有两种方案:
- 在
vitest.config.ts中设置test.globals: true(我们之前已经做了)。这样 Vitest 会将这些 API 注入全局环境,无需在每个文件导入。同时,你需要在tsconfig.json的"types"数组中添加"vitest/globals"。{ "compilerOptions": { // ... "types": ["node", "vitest/globals"] } } - 不启用
globals,而是在每个测试文件顶部手动导入:
我更喜欢第一种方式,因为它更接近 Jest 的使用习惯,写起来更简洁。import { describe, it, expect } from 'vitest';
5.4 Tsup 打包后运行出错(如 __dirname 问题)
问题 :在代码中使用了 __dirname 或 __filename 来获取文件路径,打包成 ESM 格式( .mjs )后运行报错,因为这些变量在 ES 模块中未定义。 解决 :在 Node.js 的 ES 模块中,应使用 import.meta.url 来构造文件路径。可以创建一个工具函数来统一处理:
// src/utils/path.ts
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
export function getDirname(metaUrl: string): string {
return dirname(fileURLToPath(metaUrl));
}
// 使用方式
const currentDir = getDirname(import.meta.url);
const configPath = join(currentDir, '..', 'config', 'default.json');
对于 CLI 工具,如果确定只打包成 CommonJS( format: ['cjs'] ),可以继续使用 __dirname 。但为了更好的兼容性,建议使用上述方法。
5.5 依赖项处理:Bundle 还是 External?
问题 :使用 Tsup 打包时,不确定是否应该把第三方依赖(如 commander , openai )打包进最终的 bundle 。 决策指南 :
- 打包进去(默认) :生成一个独立的文件,用户无需安装你的依赖。适用于简单工具,希望用户安装即用。但会导致文件体积变大,且如果用户环境中已有相同依赖的不同版本,可能引发冲突。
- 外部化(External) :不打包,在
package.json中声明为dependencies。用户安装你的 CLI 时会同时安装这些依赖。这是 Node.js CLI 工具的推荐做法 ,因为它符合 Node.js 的模块管理机制,能更好地处理版本依赖和模块缓存。
在 tsup.config.ts 中,你可以通过 external 选项来指定外部依赖:
export default defineConfig({
// ...
external: ['commander', 'openai', 'langchain'], // 这些包不会被打包
});
相应的,你需要确保它们在 package.json 的 dependencies 中。
5.6 Git 钩子(Husky)不生效
问题 :执行 git commit 时,没有触发 lint-staged 。 排查步骤 :
- 检查
.husky/pre-commit文件权限 :在 Unix 系统上,需要确保该文件有可执行权限。可以运行chmod +x .husky/pre-commit。 - 检查
lint-staged配置 :确认package.json中的lint-staged路径模式是否正确匹配你的文件。 - 重新安装 Husky :有时钩子链接会失效。可以删除
.husky目录,然后重新运行npx husky init和npm run prepare(或pnpm run prepare)。 - 检查 Git 版本 :确保 Git 版本 > 2.9。
经过以上步骤,一个具备现代化工程基建的 TypeScript CLI 项目骨架就搭建完毕了。它拥有严格的类型检查、自动化的代码格式化与检查、高效的单元测试、快速的构建打包流程,以及保障提交质量的 Git 钩子。这个坚实的基础,将为我们后续实现具体的 Agent CLI 功能扫清工程上的障碍,让我们可以更专注于业务逻辑的开发。在下一篇文章中,我们将在这个基建之上,引入 commander 库来构建命令行界面,并实现第一个简单的 Agent 交互命令。
更多推荐



所有评论(0)