开源 Agent 套件 ECC 的安装器设计:先算计划,坏了能修
本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。
把安装器写成一次性 shell 脚本,是 Agent 工具链最常见的技术债起点:装了什么没人记得,改坏了只能重装,重装又把你手动调过的东西一起冲掉。 ECC 这套装在编码 Agent 之上的增强件(MIT 许可证)在这件事上给出了另一种做法——把「安装」拆成解析选择、生成计划、落盘执行、记录状态、校验状态、修复状态六个互不重叠的环节,每一环都有单独的入口文件和单独的数据结构。这套结构本身比它装的内容更值得看,因为任何一个要往用户机器里写文件的 Agent 工具链,迟早都要面对同一批问题。
一、先算清楚,再动手
scripts/install-plan.js 的文件头注释只有一句话:在不改动目标的前提下检查选择性安装的 profile 与模块计划。它是纯读的。你可以用 --list-profiles 看有哪些预设,用 --list-modules 看模块清单,用 --list-components(可配 --family 过滤)看面向人的组件 ID,也可以直接 --profile developer --target cursor 让它把最终计划算出来给你看。加 --json 就是机器可读的一坨结构。
真正会写磁盘的是 scripts/install-apply.js。但它自己也带 --dry-run:走这条路时调的是 previewInstallPlan,不是 applyInstallPlan,输出里明确标注 Dry-run install plan,并把 Install root、Install-state、逐条 sourceRelativePath -> destinationPath 全打出来。也就是说,「我要装什么」和「我真的装了」在这套设计里是两份可以互相对照的文本。
选择模型分三层,落在 manifests/ 下的三个清单文件里:
- profile 是入口预设,
install-profiles.json里有 minimal、opencode、core、developer、security、research、full 七个,每个就是一串模块 ID。 - module 是安装单位,
install-modules.json里每个模块带kind、paths、targets、dependencies、defaultInstall、cost、stability这些字段。targets决定它能落到哪些 harness 上,不匹配的会被算进 skipped 而不是静默消失。 - component 是给人看的粒度,
install-components.json里的 ID 带命名空间前缀:baseline:rules、lang:typescript、framework:nextjs、capability:database、skill:plan-canvas、agent:architect、locale:zh-cn。--with和--without就作用在这一层,在 profile 之上做加减。
计划输出里同时列出 selectedModules、skippedModules、excludedModules 三份名单——选中的、因目标不支持被跳过的、被你显式排除的,分开记。这个区分很关键:装完之后你回头看,能分清「这东西没装是因为我没要」还是「因为这个 harness 根本不支持」。
站内的 AI 基础设施选型 和 从 vibe coding 到工程化 讲的是通用方法论——怎么挑底座、怎么把随手写的东西收敛成可维护工程。本篇不重复那一层,只做一件事:拿一个你能当场 clone 下来逐行核对的项目,看它的安装链路具体是怎么落到实处的。
二、装了什么,写成一份有 schema 的记录
schemas/install-state.schema.json 定义的对象叫 ecc.install.v1,additionalProperties 为 false,必填字段是 schemaVersion、installedAt、target、request、resolution、source、operations。拆开看每一块的分工:
target记id、root、installStatePath,其中kind只允许home或project——装到家目录还是装到项目目录,是一等公民信息。request原样保存你当时的意图:profile、modules、includeComponents、excludeComponents、legacyLanguages、legacyMode。注意它存的是意图,不是结果。resolution存结果:selectedModules和skippedModules。source存来源指纹:repoVersion、repoCommit、manifestVersion。operations是逐条文件操作,每条必须有kind、moduleId、sourceRelativePath、destinationPath、strategy、ownership、scaffoldOnly。
意图和结果分开存,是这份 schema 里最值得抄的一处。因为仓库会升级,清单会变,同一份 request 在半年后重新解析出来的模块列表可能和当初不一样——把两份都留着,你才有资格在事后判断「是我改了要求,还是上游改了口径」。
再看一个反直觉的实现细节。scripts/lib/install-state.js 里没有用 ajv 校验 install-state,而是手写了一个校验器,文件顶部注释写明了理由:安装闭包不能 require 任何非内置包,企业供应链审查的要求是「被审过的字节必须就是被安装的字节」。而校验 ecc-install.json 配置文件的 scripts/lib/install/config.js 用的就是 ajv。同一个仓库里两套校验策略,边界划在「这段代码会不会在安装时被执行」上。这是个可以直接借走的判断标准。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 计划查看 CLI | 只读地列出 profile/模块/组件,并把最终计划算出来 | scripts/install-plan.js | 装之前想知道会动哪些文件 |
| 安装入口 | 解析参数、拼请求、按 --dry-run 分流到预览或执行 | scripts/install-apply.js | 每次装或升级 |
| 落盘执行体 | 逐条执行 copy-file 与 merge-json,写 hooks,最后持久化状态 | scripts/lib/install/apply.js | 排查「文件为什么长这样」 |
| 状态结构定义 | 定义 ecc.install.v1 的字段与必填约束 | schemas/install-state.schema.json | 想自己解析或消费安装记录 |
| 状态校验器 | 不依赖第三方包的手写校验,读写状态前后都跑 | scripts/lib/install-state.js | 状态文件被手改坏之后 |
| 体检/修复/卸载共用逻辑 | 发现已安装状态、逐条判定健康度、执行修复与卸载 | scripts/lib/install-lifecycle.js | doctor 报了问题想知道判据 |
| 修复 CLI | 按记录重建缺失或漂移的托管文件 | scripts/repair.js | 误删了文件、或改乱了想还原 |
| 配置文件结构定义 | 约束 ecc-install.json 里能写什么 | schemas/ecc-install-config.schema.json | 把安装意图提交进代码仓库 |
| 清单三件套 | profile、module、component 的事实来源 | manifests/install-profiles.json 等三个文件 | 想知道某个组件到底带来什么 |
三、doctor 与 repair:把「坏了」变成可判定的状态
scripts/lib/install-lifecycle.js 里的 buildDoctorReport 会把当前上下文能找到的 install-state 逐个分析,产出一串带 severity、code、message 的 issue。code 是枚举化的,missing-target-root、target-root-mismatch、install-state-path-mismatch、missing-managed-files、drifted-managed-files、missing-source-files、unverified-managed-operations、manifest-version-mismatch、repo-version-mismatch、resolution-drift 各有各的含义。整体状态由 determineStatus 折叠:有 error 就 error,有 warning 就 warning,都没有才 ok。
真正做判断的是 inspectManagedOperation,它对不同 kind 用不同判据,这一点比枚举本身更有意思:
copy-file比对源文件和目标文件的字节是否相等,不等就是 drifted。render-template拿记录里的渲染内容和磁盘上的文本比。merge-json用jsonContainsSubset判断托管的那部分键值是否还在,而不要求整个文件一致——因为这类文件本来就是和别人共享的,你在同一个配置里加自己的东西不应该被判成损坏。remove反过来:目标还存在才算 drifted。
拿不到判据的操作被标成 unverified,进 warning,而不是装作 ok。这个取舍值得记:一个安装器敢承认「这条我验不了」,比它假装全绿有用得多。
scripts/repair.js 的动作面很窄,窄得刚好。它只处理 ownership 为 managed 的操作,只重建 missing 和 drifted 两类,--dry-run 时输出 plannedRepairs,真跑时输出 repairedPaths,最后重写 install-state 并刷新 lastValidatedAt。摘要行是 checked=... repaired=... errors=...,有 error 时退出码为 1,可以直接串进 CI。
其中 resolution-drift 这个检查是整套设计的收口:它拿你记录里的 request 重新跑一遍解析,把现在算出来的 selectedModules/skippedModules 和当初记的比一比。上游清单改了口径、某个模块换了目标支持范围,这里会告诉你,而不是等你某天发现某个能力莫名其妙不见了。
卸载走的是同一份 operations。对 merge-json,deepRemoveJsonSubset 只在当前值仍然等于托管值时才删那个键——你后来自己往同一个配置里加的东西不会被一起端掉;而当一个对象里的键被删空了,才会整体移除。这类细节决定了一个工具是「能卸载」还是「敢卸载」。
四、它假设那份状态文件是不可信输入
install-lifecycle.js 的 executeRepairOperation 上方有一行注释,大意是:install-state 是攻击者可控的,不管状态文件里写了什么,都不许写到或删到 adapter 推导出的可信根之外,并且注了对应的 GHSA 编号。围绕这条,代码里的手段是成套的:
- 所有目标路径都要过
assertWithinTrustedRoot。 - 修复来源的
sourceRelativePath必须是相对路径,含..段或绝对路径直接抛错。 - 目标如果是符号链接本体,拒绝写入,错误码是
ECC_FINAL_DESTINATION_SYMLINK。 - 写文件时用带
O_NOFOLLOW的标志打开,再用fstat比对 dev/ino 确认句柄指向的还是同一个文件,父目录也重新lstat过一遍。 - 逐级
mkdir之后每一级都重新校验,不是直接mkdirSync(recursive)了事。
注释里还诚实地写了一句:路径检查无法消除写入前的竞态窗口,只能收窄。这种承认边界的写法,比宣称「已加固」可信。
这对你意味着什么?你评估任何一个要往 ~/.claude、~/.codex、~/.opencode 这类目录写东西的工具时,上面这几条可以直接当检查项用。装这类套件本身就是把写权限交出去,配合 最小权限设计 的思路先看清它到底往哪写、以什么身份写,比装完再后悔便宜。
五、边界与代价:它明确不管的那些事
计划输出不是逐字节承诺。 install-plan.js 打印计划时会自带一句提示:目标过滤和操作输出反映的是 scaffold 级别的适配器规划,不是老 install.sh 拷贝路径的逐字节镜像。拿它当心理预期可以,拿它当审计凭证不行——要审计就看 apply 之后落地的 install-state。
它只管自己记在册上的东西。 doctor 和 repair 遍历的是 ownership 为 managed 的操作。你手动往同一个目录里加的文件,它既不体检也不清理;反过来说,卸载时也不会误伤。这个边界是清楚的,但你得知道它在哪。
drift 是 warning,repair 是覆盖。 你手改过的托管文件会被判成 drifted(只是警告),而一旦你跑 repair,它会拿仓库里的内容盖回去。这套设计的立场是「托管文件的事实来源在仓库」,本地修改属于要被消除的偏差。如果你的用法是长期本地魔改,这套机制和你是拧着的。
没有事务,只有可重试。 apply 是顺序执行的循环,中途失败就是半装状态。代码里的兜底是在开始写之前先持久化一份桥接状态,让「失败之后重试」和「失败之后卸载干净」都成立,但它不提供快照回滚。
copy-file 同目的地只留最后一条。 dedupeCopyFileOperations 里那段注释解释了原因:多个来源打到同一个目标路径时,只有最后一次写入决定内容,把被覆盖的早期写入也记进状态,会让 doctor 永远报漂移、让 repair 拿通用来源去盖掉特化覆盖。这个取舍解决了误报,代价是状态里看不到「谁被谁覆盖了」的完整链路。
它不管 harness 本身怎么跑。 模型选择、服务商额度、限流策略,这套安装器一概不碰,那些是各家规则不同且会调整的东西,以官方最新说明为准。它管的边界很清楚:把文件放对地方,记住放了什么,能查能修能删。
装它就是让它往你的机器里写东西。 帮助文本里列了十几个目标,claude 装进 ~/.claude/、claude-project 装进 ./.claude/、cursor 装进 ./.cursor/、codex 装进 ~/.codex/、opencode 装进 ~/.opencode/,等等。里面包含 hooks 运行时和 MCP 配置——hooks 意味着有代码会在 Agent 生命周期里被触发,MCP 配置意味着可能连出去。这些不是缺陷,是这类工具的固有代价,装之前应该看明白而不是装完再查。
六、上手与避坑清单
位置参数写语言名和 --profile 混用会直接报错。 会踩是因为两种语法在帮助文本里挨着列,看上去像能叠加。normalizeInstallRequest 里明确抛错:遗留的语言参数不能和 --profile、--modules、--with、--without 或清单配置同时用。怎么避:动手前先决定走哪种模式,别在同一条命令里混。
当前目录有 ecc-install.json 会被自动捡起来。 会踩是因为 findDefaultInstallConfigPath 在你没显式传 --config 时会去 cwd 找默认文件,你以为在裸装,实际带了一整套配置。怎么避:先跑一次 --dry-run,看输出里的 Profile: 和 Requested modules: 两行是不是你预期的。
配置文件里写不了 skill: / agent: / locale: 开头的组件。 会踩是因为这三类组件 ID 在命令行上完全可用,你自然会想搬进 ecc-install.json。但 ecc-install-config.schema.json 里 include/exclude 的模式是 ^(baseline|lang|framework|capability):[a-z0-9-]+$,另外三个前缀过不了校验,报错信息是 schema 层面的,不会告诉你「这类组件请走命令行」。怎么避:这三类继续用命令行参数,配置文件只放前四类。
--skills 会自动补前缀,--with 不会。 会踩是因为两个参数看着都是「加东西」。--skills 会把 continuous-learning-v2 这样的裸目录 ID 自动补成 skill:continuous-learning-v2,而 --with 要求你写完整 ID。怎么避:不确定就先 --list-components --family skill 看一眼真实 ID。想理解这层能力到底是什么,可以参考 Claude Code 技能机制。
--locale 只能配 claude 或 claude-project。 会踩是因为它看起来像个全局开关。代码里对目标做了硬检查,配别的目标直接抛错。怎么避:需要本地化文档就固定用这两个目标之一。
opencode 这个 profile 默认不带 hooks 运行时。 会踩是因为你按 profile 名字选完,会以为该有的都有了。清单文件的描述里明说了这是有意排除的,要就用 --modules hooks-runtime 显式加回来。怎么避:选完 profile 后用 --list-profiles 或 --dry-run 核对模块清单,别靠名字猜。
repair 之前先 --dry-run。 会踩是因为 repair 的语义是「按记录重建」,而不是「按你现在的样子保留」。你手动优化过的托管文件会被无声盖回仓库版本。怎么避:先跑 dry-run 看 Planned repairs 列表,确认里面没有你不想丢的文件,再执行真修复。
install-state 文件本身丢了,doctor、repair、uninstall 一起失忆。 会踩是因为它藏在安装目录的子路径下(claude 目标是 ~/.claude/ecc/install-state.json),清理目录时容易被当垃圾删掉。怎么避:把它当作这套安装的账本对待——它没了,你就只能靠记忆和 git status 判断哪些文件是它装的。
MCP 服务可以在安装时按名字过滤掉。 这条是省事而不是坑:scripts/lib/install/apply.js 会读环境变量 ECC_DISABLED_MCPS,被列进去的服务在写 .mcp.json 或 mcp.json 时会被剔除。评估阶段不想让它连外部服务,这是比装完再手删配置更干净的做法。
结尾:三个问题和一条阅读路径
看完这套设计,可以拿三个问题回头审自己手上的安装脚本:装完之后有没有一份机器可读的记录说明装了什么;这份记录有没有结构约束,被手改坏了能不能立刻发现;有没有一条命令能在不重装的前提下判定当前状态是好是坏。三个都答不上来,那套脚本大概率就是下一次「重装解决一切」的源头。
想继续往下读的话,推荐的顺序是:先看 schemas/install-state.schema.json,二十分钟就能读完,它定义了整套设计的骨架;再看 scripts/lib/install-lifecycle.js 里的 inspectManagedOperation 和 analyzeRecord,那是判据集中的地方;最后回到 scripts/lib/install/apply.js 看实际落盘顺序。这三个文件读通,剩下的适配器和清单都只是填空。至于这套记录能怎么串进日常运维流程,可以对照 Agent 日常运维 的做法一起看。
本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题。