第 11 课:Scripts — Hook 的底层实现

12 阅读7分钟

所属阶段:第二阶段「组件精讲」(第 4-14 课) 前置条件:第 10 课 本课收获:能编写符合规范的 Hook 脚本并编写测试


一、本课概述

上节课我们学习了 Hook 的事件类型和配置格式。本课深入实现层scripts/ 目录。这里存放着所有 Hook 的实际代码。

本课回答三个问题:

  1. scripts 目录怎么组织? — 三个子目录各司其职
  2. Hook 脚本怎么写? — 标准模式、run-with-flags.js 包装器
  3. 怎么测试? — 测试规范和实战

二、目录结构

2.1 整体布局

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

依赖关系

ci/ scripts
    └── require → lib/ (共享函数)

hooks/ scripts
    └── require → lib/ (共享函数)

lib/ 内部
    └── 模块之间也有 require 关系

三、代码约定

3.1 CommonJS Only

ECC 的所有脚本使用 CommonJS 模块系统,不使用 ESM

// 正确: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.jsontype 字段等复杂性。

3.2 const 优先,禁止 var

// 好
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 行,说明它做了太多事情。正确做法:

# 差: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 中的调用方式

{
  "command": "node scripts/hooks/run-with-flags.js \"post:edit:console-warn\" \"scripts/hooks/post-edit-console-warn.js\" \"standard,strict\""
}

三个参数

参数示例说明
hookIdpost:edit:console-warnHook 的唯一标识
scriptPathscripts/hooks/post-edit-console-warn.js实际脚本的相对路径
profilesstandard,strict在哪些 Profile 下启用

4.2 run-with-flags.js 的作用

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

关键价值

  1. 统一管理启用/禁用 — 所有 Hook 的 Profile 检查在一处完成
  2. 支持环境变量控制 — 不改 hooks.json 就能调整 Hook 行为
  3. 统一 stdin 解析 — 不需要每个脚本自己解析 JSON

4.3 Hook 脚本的标准写法

'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 实现了一个精心设计的优先级链来检测项目使用的包管理器:

优先级从高到低:

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 支持的包管理器

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 目录结构:

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 运行测试

# 运行所有测试
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

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 分钟)

在项目根目录运行测试,确认所有测试通过:

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 中的一个函数编写额外测试用例。

建议测试的边界情况:

// 例如为 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;

运行测试验证:

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/ 目录浏览几个命令文件的格式。