所属阶段:第二阶段「组件精讲」(第 4-14 课) 前置条件:第 10 课 本课收获:能编写符合规范的 Hook 脚本并编写测试
一、本课概述
上节课我们学习了 Hook 的事件类型和配置格式。本课深入实现层 --- scripts/ 目录。这里存放着所有 Hook 的实际代码。
本课回答三个问题:
- scripts 目录怎么组织? --- 三个子目录各司其职
- Hook 脚本怎么写? --- 标准模式、run-with-flags.js 包装器
- 怎么测试? --- 测试规范和实战
二、目录结构
2.1 整体布局
perl
scripts/
├── lib/ # 共享库(工具函数)
│ ├── utils.js # 跨平台工具函数
│ ├── package-manager.js # 包管理器检测
│ ├── hook-flags.js # Hook 启用/禁用控制
│ ├── session-manager.js # 会话管理
│ ├── resolve-ecc-root.js # 解析 ECC 安装根目录
│ └── ... # 其他共享模块
│
├── hooks/ # Hook 实现脚本
│ ├── run-with-flags.js # 核心包装器
│ ├── session-start-bootstrap.js
│ ├── pre-bash-commit-quality.js
│ ├── post-edit-console-warn.js
│ ├── stop-format-typecheck.js
│ ├── desktop-notify.js
│ └── ... # 其他 Hook 脚本(30+ 个)
│
├── ci/ # CI/CD 验证工具
│ ├── validate-agents.js
│ ├── validate-skills.js
│ ├── validate-hooks.js
│ └── ... # 其他验证脚本
│
├── ecc.js # CLI 入口
└── doctor.js # 环境诊断工具
2.2 三个子目录的职责
| 目录 | 职责 | 被谁调用 |
|---|---|---|
lib/ |
提供共享的工具函数 | hooks/ 和 ci/ 中的脚本 |
hooks/ |
Hook 事件的具体实现 | hooks.json 中的 command 字段 |
ci/ |
CI 流水线中的验证脚本 | GitHub Actions |
依赖关系:
javascript
ci/ scripts
└── require → lib/ (共享函数)
hooks/ scripts
└── require → lib/ (共享函数)
lib/ 内部
└── 模块之间也有 require 关系
三、代码约定
3.1 CommonJS Only
ECC 的所有脚本使用 CommonJS 模块系统,不使用 ESM:
javascript
// 正确:CommonJS
const fs = require('fs');
const path = require('path');
const { getClaudeDir } = require('../lib/utils');
module.exports = { myFunction };
// 错误:不要用 ESM
import fs from 'fs'; // ✗
export default myFunction; // ✗
原因 :Node.js 18+ 虽然支持 ESM,但 CommonJS 在脚本工具中更简单直接,不需要处理 .mjs 扩展名、package.json 的 type 字段等复杂性。
3.2 const 优先,禁止 var
javascript
// 好
const MAX_STDIN = 1024 * 1024;
const result = processInput(data);
let counter = 0; // 确实需要重新赋值时用 let
// 差
var MAX_STDIN = 1024 * 1024; // ✗ 永远不要用 var
3.3 Hook 脚本 <200 行
如果一个 Hook 脚本超过 200 行,说明它做了太多事情。正确做法:
bash
# 差:300 行的 commit-quality.js
# 好:拆分
scripts/hooks/pre-bash-commit-quality.js (80 行,入口)
scripts/lib/commit-validator.js (120 行,核心逻辑)
scripts/lib/secret-detector.js (60 行,密钥检测)
规则 :Hook 脚本负责"胶水逻辑"(读 stdin、调用库函数、输出结果),核心逻辑提取到 lib/。
四、Hook 脚本标准模式
4.1 通过 run-with-flags.js 运行的模式
大多数 ECC Hook 不直接被 hooks.json 调用,而是通过 run-with-flags.js 包装器运行。
hooks.json 中的调用方式:
json
{
"command": "node scripts/hooks/run-with-flags.js \"post:edit:console-warn\" \"scripts/hooks/post-edit-console-warn.js\" \"standard,strict\""
}
三个参数:
| 参数 | 示例 | 说明 |
|---|---|---|
| hookId | post:edit:console-warn |
Hook 的唯一标识 |
| scriptPath | scripts/hooks/post-edit-console-warn.js |
实际脚本的相对路径 |
| profiles | standard,strict |
在哪些 Profile 下启用 |
4.2 run-with-flags.js 的作用
lua
hooks.json 调用 run-with-flags.js
│
├── 1. 检查 ECC_HOOK_PROFILE 环境变量
│ 当前 Profile 是否在允许列表中?
│ 不在 → exit 0(跳过)
│
├── 2. 检查 ECC_DISABLED_HOOKS 环境变量
│ 当前 hookId 是否被禁用?
│ 是 → exit 0(跳过)
│
├── 3. 读取 stdin(工具调用的 JSON 数据)
│
├── 4. 加载实际脚本
│ require(scriptPath)
│
├── 5. 调用 module.exports.run(rawInput)
│ 或者 spawn 子进程执行
│
└── 6. 转发 exit code
脚本的 exit code → run-with-flags.js 的 exit code
关键价值:
- 统一管理启用/禁用 --- 所有 Hook 的 Profile 检查在一处完成
- 支持环境变量控制 --- 不改 hooks.json 就能调整 Hook 行为
- 统一 stdin 解析 --- 不需要每个脚本自己解析 JSON
4.3 Hook 脚本的标准写法
javascript
'use strict';
function run(rawInput) {
let input;
try {
input = JSON.parse(rawInput);
} catch (err) {
process.stderr.write('[HookName] Failed to parse input\n');
process.exit(0); // 解析失败不阻塞
}
const toolInput = input.tool_input || {};
const filePath = toolInput.file_path || '';
if (!filePath.endsWith('.js')) {
process.exit(0); // 不需要处理
}
try {
doWork(filePath);
process.stderr.write(`[HookName] Processed: ${filePath}\n`);
} catch (err) {
process.stderr.write(`[HookName] Error: ${err.message}\n`);
}
process.exit(0);
}
module.exports = { run };
4.4 关键规则总结
| 规则 | 原因 |
|---|---|
'use strict' |
启用严格模式,捕获更多错误 |
| JSON 解析失败 → exit 0 | 不因输入问题阻塞工具执行 |
stderr 带 [HookName] 前缀 |
方便日志排查 |
| 所有路径用 exit 0 兜底 | 防止意外拦截 |
提取 module.exports.run |
让 run-with-flags.js 能加载和调用 |
五、包管理器检测优先级链
5.1 检测流程
scripts/lib/package-manager.js 实现了一个精心设计的优先级链来检测项目使用的包管理器:
markdown
优先级从高到低:
1. 环境变量 CLAUDE_PACKAGE_MANAGER
│ 用户显式指定,最高优先级
│
2. 项目配置文件 (.claude/config.json 中的 packageManager)
│ 项目级别的配置
│
3. package.json 中的 packageManager 字段
│ Node.js 官方的 corepack 配置
│
4. Lock 文件检测
│ pnpm-lock.yaml → pnpm
│ bun.lockb → bun
│ yarn.lock → yarn
│ package-lock.json → npm
│
5. 全局配置 (~/.claude/config.json)
│ 用户全局偏好
│
6. 默认值:npm
5.2 支持的包管理器
javascript
const PACKAGE_MANAGERS = {
npm: { lockFile: 'package-lock.json', execCmd: 'npx', ... },
pnpm: { lockFile: 'pnpm-lock.yaml', execCmd: 'pnpm dlx', ... },
yarn: { lockFile: 'yarn.lock', execCmd: 'yarn dlx', ... },
bun: { lockFile: 'bun.lockb', execCmd: 'bunx', ... }
};
5.3 Lock 文件检测顺序
Lock 文件的检测顺序是 pnpm → bun → yarn → npm,不是字母顺序。
原因:如果一个项目同时存在多个 lock 文件(这种情况在迁移过程中很常见),应该优先选择更现代的包管理器。
六、共享库 lib/ 详解
6.1 utils.js --- 核心工具函数
scripts/lib/utils.js 提供跨平台的工具函数:
| 函数 | 作用 |
|---|---|
getHomeDir() |
获取用户主目录(兼容 Windows/macOS/Linux) |
getClaudeDir() |
获取 ~/.claude 目录路径 |
getSessionsDir() |
获取会话数据目录 |
readFile(path) |
安全的文件读取(不存在返回 null) |
writeFile(path, content) |
安全的文件写入(自动创建目录) |
commandExists(cmd) |
检查命令是否存在 |
跨平台的关键:HOME(macOS/Linux)和 USERPROFILE(Windows)做了统一处理,优先读环境变量,兜底用 os.homedir()。
6.2 hook-flags.js --- Hook 启用控制
实现了第 10 课讲的 Profile 系统。核心函数 isHookEnabled(hookId, options) 检查两件事:是否被 ECC_DISABLED_HOOKS 显式禁用,以及当前 Profile 是否匹配。
6.3 resolve-ecc-root.js --- 解析安装路径
ECC 可能安装在多个位置,此模块按优先级搜索:CLAUDE_PLUGIN_ROOT 环境变量 → ~/.claude/ → ~/.claude/plugins/ecc/ → 市场安装路径 → 缓存安装路径。
七、测试规范
7.1 测试目录结构
测试目录镜像 scripts 目录结构:
perl
tests/
├── run-all.js # 测试运行器入口
├── lib/
│ ├── utils.test.js # 对应 scripts/lib/utils.js
│ └── package-manager.test.js # 对应 scripts/lib/package-manager.js
└── hooks/
└── hooks.test.js # Hook 集成测试
7.2 运行测试
bash
# 运行所有测试
node tests/run-all.js
# 运行单个测试文件
node tests/lib/utils.test.js
node tests/lib/package-manager.test.js
node tests/hooks/hooks.test.js
7.3 测试编写规范
ECC 使用 Node.js 内置的 assert 模块,不依赖外部测试框架。每个测试用 try/catch 包裹,成功打印 PASS,失败打印 FAIL 并设置 process.exitCode = 1:
javascript
const assert = require('assert');
const { getHomeDir } = require('../../scripts/lib/utils');
try {
const home = getHomeDir();
assert.ok(typeof home === 'string', 'getHomeDir returns string');
assert.ok(home.length > 0, 'getHomeDir returns non-empty string');
console.log(' PASS: getHomeDir');
} catch (err) {
console.error(' FAIL: getHomeDir -', err.message);
process.exitCode = 1;
}
7.4 新脚本必须有测试
这是 ECC 的硬性规则:
| 新增文件位置 | 测试要求 |
|---|---|
scripts/lib/xxx.js |
必须 在 tests/lib/xxx.test.js 添加测试 |
scripts/hooks/xxx.js |
必须 在 tests/hooks/ 添加至少一个集成测试 |
scripts/ci/xxx.js |
建议有测试,但不强制(CI 脚本本身就是验证工具) |
八、本课练习
练习 1:运行测试(5 分钟)
在项目根目录运行测试,确认所有测试通过:
bash
node tests/run-all.js
回答问题:
- 总共有多少个测试?
- 有没有失败的测试?
- 测试输出的格式是什么样的?
练习 2:阅读 run-with-flags.js(15 分钟)
打开 scripts/hooks/run-with-flags.js,回答:它接收几个命令行参数?怎么判断 Hook 是否应该运行?stdin 读取失败时怎么处理?
练习 3:为 utils.js 编写额外测试(20 分钟)
这是本课最重要的练习。
打开 tests/lib/utils.test.js,为 utils.js 中的一个函数编写额外测试用例。
建议测试的边界情况:
javascript
// 例如为 getHomeDir 测试边界情况:
// 1. 当 HOME 环境变量为空字符串时
const originalHome = process.env.HOME;
process.env.HOME = '';
const result1 = getHomeDir();
assert.ok(result1.length > 0, 'getHomeDir handles empty HOME');
process.env.HOME = originalHome;
// 2. 当 HOME 环境变量包含空格时
process.env.HOME = '/Users/my user';
const result2 = getHomeDir();
assert.ok(result2.includes('my user'), 'getHomeDir preserves spaces');
process.env.HOME = originalHome;
运行测试验证:
bash
node tests/lib/utils.test.js
练习 4(选做):追踪 Hook 完整链路
选择 pre:bash:git-push-reminder,从 hooks.json 配置 → run-with-flags.js → 实际脚本,画出完整调用链。
九、本课小结
| 你应该记住的 | 内容 |
|---|---|
| 目录结构 | lib/(共享库)、hooks/(Hook 实现)、ci/(CI 验证) |
| 代码约定 | CommonJS only、const 优先、Hook 脚本 <200 行 |
| Hook 标准模式 | module.exports.run = function(rawInput) {...} |
| run-with-flags.js | 统一管理 Profile 检查、禁用检查、stdin 解析 |
| 包管理器检测 | 环境变量 → 项目配置 → package.json → lock 文件 → 全局配置 → npm |
| 测试规范 | tests/ 镜像 scripts/ 结构,新脚本必须有测试 |
十、下节预告
第 12 课:Commands --- 用户交互入口
下节课我们进入 Commands 组件。Commands 是用户与 ECC 交互的最直接方式 --- 输入 /tdd 就启动 TDD 工作流,输入 /plan 就开始规划。你将了解 79 个命令的分类、命令与 Agent 的映射关系,以及如何创建自定义命令。
预习建议 :在 Claude Code 中输入 / 看看有哪些可用命令。打开 commands/ 目录浏览几个命令文件的格式。