开源 Agent 套件 ECC 的安装器设计:先算计划,坏了能修

2026-07-29

本文基于 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 rootInstall-state、逐条 sourceRelativePath -> destinationPath 全打出来。也就是说,「我要装什么」和「我真的装了」在这套设计里是两份可以互相对照的文本。

选择模型分三层,落在 manifests/ 下的三个清单文件里:

  • profile 是入口预设,install-profiles.json 里有 minimal、opencode、core、developer、security、research、full 七个,每个就是一串模块 ID。
  • module 是安装单位,install-modules.json 里每个模块带 kindpathstargetsdependenciesdefaultInstallcoststability 这些字段。targets 决定它能落到哪些 harness 上,不匹配的会被算进 skipped 而不是静默消失。
  • component 是给人看的粒度,install-components.json 里的 ID 带命名空间前缀:baseline:ruleslang:typescriptframework:nextjscapability:databaseskill:plan-canvasagent:architectlocale:zh-cn--with--without 就作用在这一层,在 profile 之上做加减。

计划输出里同时列出 selectedModulesskippedModulesexcludedModules 三份名单——选中的、因目标不支持被跳过的、被你显式排除的,分开记。这个区分很关键:装完之后你回头看,能分清「这东西没装是因为我没要」还是「因为这个 harness 根本不支持」。

站内的 AI 基础设施选型从 vibe coding 到工程化 讲的是通用方法论——怎么挑底座、怎么把随手写的东西收敛成可维护工程。本篇不重复那一层,只做一件事:拿一个你能当场 clone 下来逐行核对的项目,看它的安装链路具体是怎么落到实处的。

二、装了什么,写成一份有 schema 的记录

schemas/install-state.schema.json 定义的对象叫 ecc.install.v1additionalProperties 为 false,必填字段是 schemaVersioninstalledAttargetrequestresolutionsourceoperations。拆开看每一块的分工:

  • targetidrootinstallStatePath,其中 kind 只允许 homeproject——装到家目录还是装到项目目录,是一等公民信息。
  • request 原样保存你当时的意图:profilemodulesincludeComponentsexcludeComponentslegacyLanguageslegacyMode。注意它存的是意图,不是结果。
  • resolution 存结果:selectedModulesskippedModules
  • source 存来源指纹:repoVersionrepoCommitmanifestVersion
  • operations 是逐条文件操作,每条必须有 kindmoduleIdsourceRelativePathdestinationPathstrategyownershipscaffoldOnly

意图和结果分开存,是这份 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.jsdoctor 报了问题想知道判据
修复 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 逐个分析,产出一串带 severitycodemessage 的 issue。code 是枚举化的,missing-target-roottarget-root-mismatchinstall-state-path-mismatchmissing-managed-filesdrifted-managed-filesmissing-source-filesunverified-managed-operationsmanifest-version-mismatchrepo-version-mismatchresolution-drift 各有各的含义。整体状态由 determineStatus 折叠:有 error 就 error,有 warning 就 warning,都没有才 ok。

真正做判断的是 inspectManagedOperation,它对不同 kind 用不同判据,这一点比枚举本身更有意思:

  • copy-file 比对源文件和目标文件的字节是否相等,不等就是 drifted。
  • render-template 拿记录里的渲染内容和磁盘上的文本比。
  • merge-jsonjsonContainsSubset 判断托管的那部分键值是否还在,而不要求整个文件一致——因为这类文件本来就是和别人共享的,你在同一个配置里加自己的东西不应该被判成损坏。
  • remove 反过来:目标还存在才算 drifted。

拿不到判据的操作被标成 unverified,进 warning,而不是装作 ok。这个取舍值得记:一个安装器敢承认「这条我验不了」,比它假装全绿有用得多。

scripts/repair.js 的动作面很窄,窄得刚好。它只处理 ownershipmanaged 的操作,只重建 missing 和 drifted 两类,--dry-run 时输出 plannedRepairs,真跑时输出 repairedPaths,最后重写 install-state 并刷新 lastValidatedAt。摘要行是 checked=... repaired=... errors=...,有 error 时退出码为 1,可以直接串进 CI。

其中 resolution-drift 这个检查是整套设计的收口:它拿你记录里的 request 重新跑一遍解析,把现在算出来的 selectedModulesskippedModules 和当初记的比一比。上游清单改了口径、某个模块换了目标支持范围,这里会告诉你,而不是等你某天发现某个能力莫名其妙不见了。

卸载走的是同一份 operations。对 merge-json,deepRemoveJsonSubset 只在当前值仍然等于托管值时才删那个键——你后来自己往同一个配置里加的东西不会被一起端掉;而当一个对象里的键被删空了,才会整体移除。这类细节决定了一个工具是「能卸载」还是「敢卸载」。

四、它假设那份状态文件是不可信输入

install-lifecycle.jsexecuteRepairOperation 上方有一行注释,大意是: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 遍历的是 ownershipmanaged 的操作。你手动往同一个目录里加的文件,它既不体检也不清理;反过来说,卸载时也不会误伤。这个边界是清楚的,但你得知道它在哪。

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.jsonincludeexclude 的模式是 ^(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.jsonmcp.json 时会被剔除。评估阶段不想让它连外部服务,这是比装完再手删配置更干净的做法。

结尾:三个问题和一条阅读路径

看完这套设计,可以拿三个问题回头审自己手上的安装脚本:装完之后有没有一份机器可读的记录说明装了什么;这份记录有没有结构约束,被手改坏了能不能立刻发现;有没有一条命令能在不重装的前提下判定当前状态是好是坏。三个都答不上来,那套脚本大概率就是下一次「重装解决一切」的源头。

想继续往下读的话,推荐的顺序是:先看 schemas/install-state.schema.json,二十分钟就能读完,它定义了整套设计的骨架;再看 scripts/lib/install-lifecycle.js 里的 inspectManagedOperationanalyzeRecord,那是判据集中的地方;最后回到 scripts/lib/install/apply.js 看实际落盘顺序。这三个文件读通,剩下的适配器和清单都只是填空。至于这套记录能怎么串进日常运维流程,可以对照 Agent 日常运维 的做法一起看。

本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。