装开源 Agent 套件 ECC 之前:状态记在哪、写了什么、怎么卸干净

2026-07-29

本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。

判断一套装在编码 Agent 之上的增强件值不值得装,先别看它有多少能力,先看它有没有一份「我动过哪些文件」的账本,以及这份账本能不能反向执行。 ECC 有,账本叫 install-state,落在每个安装目标的根目录下;它的卸载逻辑只认这份账本里 ownershipmanaged 的记录。这句话既是它的安全边界,也是它的能力上限——账本没记的,卸载不会碰;账本记得不够详细的,卸载也还原不回来。

ECC 是 MIT 许可的开源仓库,代码在 https://github.com/affaan-m/ECC 。仓库里 agents 目录有 67 个 agent,skills 目录有 281 个技能,commands 目录有 94 个命令。这个体量意味着一次「全量安装」会往你的主目录或项目目录里铺开相当多的文件,所以「装之前先搞清楚它写什么」不是洁癖,是必要功课。

一、装之前该问的第一件事:它把文件写到谁的地盘

ECC 不是只支持一个宿主。scripts/lib/install-targets/ 下按目标各有一个适配器文件,注册表 registry.js 里列出的包括 claude-home、claude-project、cursor-project、antigravity-project、codex-home、gemini-project、hermes-home、opencode-home、openclaw-home、codebuddy-project、joycode-project、kimi-project、qwen-home、zed-project。

每个适配器声明两件事:根目录段(rootSegments)和安装状态文件的路径段(installStatePathSegments)。适配器还带一个 kind,取值是 homeproject——前者从用户主目录起算,后者从项目根起算。这个区分决定了文件落在哪:

  • claude-home 的根是 .claude,状态文件是 ecc/install-state.json,也就是 ~/.claude/ecc/install-state.json
  • claude-project 同样用 ecc/install-state.json,但根在你项目下的 .claude
  • cursor-project 的根是 .cursor,状态文件是 ecc-install-state.json
  • antigravity-project 的根是 .agent,codex-home 的根是 .codex,qwen-home 的根是 .qwen,zed-project 的根是 .zed,状态文件名同样是 ecc-install-state.json

也就是说,同一台机器上你可能同时存在多份互不知情的安装记录:主目录一份,每个项目各一份。这是设计使然,但也是最常见的困惑来源。

内容具体落到哪个子目录,由适配器自己决定。以 claude-home 为例,claude-home.js 里的映射逻辑很直白:仓库里 rules/ 下的内容会落到目标根的 rules/ecc/ 下(代码里那个命名空间常量就叫 CLAUDE_ECC_NAMESPACE,值是 ecc),skills/ 下的内容直接落到目标根的 skills/ 下,docs/ 保持原路径。前者带命名空间隔离,后者不带——README 里解释了原因:技能需要平铺在 ~/.claude/skills/<skill-name>/ 才能被宿主发现。代价就是技能目录会和你自己写的技能混在一层,README 也承认了这一点,并说明升级旧安装时只迁移安装状态里记录过的嵌套 skills/ecc/ 文件;如果同名目录是用户自己的,ECC 保留它并打印冲突警告。

二、安装状态文件里到底记了什么

schemas/install-state.schema.json 是这份账本的正式约束,顶层 additionalProperties 为 false,必填字段是 schemaVersioninstalledAttargetrequestresolutionsourceoperations,另有一个可选的 lastValidatedAtschemaVersion 被写死成常量 ecc.install.v1

各块的分工:

  • targetidrootinstallStatePath 必填,另有可选的 targetkind(枚举 home / project)。这块回答「装到哪」。
  • requestprofilemodulesincludeComponentsexcludeComponentslegacyLanguageslegacyMode 六项全部必填。这块回答「你当初要的是什么」,是后续升级和修复能重放安装的原因。
  • resolutionselectedModulesskippedModules。这块回答「你要的东西被解析成了哪些内部模块,哪些因为目标不支持被跳过」。
  • sourcerepoVersionrepoCommitmanifestVersion。这块回答「当时用的是哪一版仓库」。
  • operations:数组,每一项必填 kindmoduleIdsourceRelativePathdestinationPathstrategyownershipscaffoldOnly。注意这里的元素级 additionalProperties 是 true,也就是允许操作项携带 schema 没列出的额外字段——后面会讲到,这个口子正是卸载能否还原的关键。

ownership 这个字段值得单独说。生命周期代码里的 getManagedOperations 只筛 ownership === 'managed' 的操作;doctor、repair、uninstall 三条链路全部走这个筛子。安装执行器里写入的默认值就是 'managed'。这个字段就是「ECC 认领了这个文件」的标记,也是它承诺不动用户文件的技术实现。

组成部分它负责什么对应仓库位置你什么时候会碰到它
安装状态 schema定义账本的必填字段与取值约束schemas/install-state.schema.json想手工看懂状态文件、或排查写入被拒时
目标适配器决定根目录、状态文件路径、文件落点映射scripts/lib/install-targets/(如 claude-home.jscursor-project.js装到非默认宿主、或找不到文件被写到哪时
计划脚本只读地展示 profile、模块、组件与操作计划scripts/install-plan.js装之前预览、以及事后复盘选型
生命周期库实现 doctor / repair / uninstall 的共用逻辑scripts/lib/install-lifecycle.js出现漂移、缺文件、想干净退出时
卸载入口读账本、按操作类型逆向执行、删账本scripts/uninstall.js要退出的时候
安装配置文件的约束与加载让团队把安装意图写成文件提交进自己的仓库schemas/ecc-install-config.schema.jsonscripts/lib/install/config.js想让多人和 CI 装出同一结果时
组件与档案清单用户可见的组件目录与预设组合manifests/install-components.jsonmanifests/install-profiles.json挑选装哪些东西时

三、先看计划,再动手

scripts/install-plan.js 是纯只读的,文件头的注释写得很清楚:Inspect selective-install profiles and module plans without mutating targets。它支持 --list-profiles--list-modules--list-components(可配 --family 过滤)、--profile--modules--with--without--skills--config--target--json。统一 CLI 里它叫 plan,另有 install-plan 别名。

manifests/install-profiles.json 里的档案是 minimal、opencode、core、developer、security、research、full。以 developer 为例,它展开成 rules-coreagents-corecommands-corehooks-runtimeplatform-configsworkflow-qualityframework-languagedatabaseorchestration 九个内部模块;minimal 则明确不含 hooks-runtime,profile 的描述里直接写了「no hook runtime」;opencode 这个 profile 的描述也说明它有意排除 hooks-runtime,需要的话用 --modules hooks-runtime 显式加回。如果你对宿主挂钩子这件事本身有顾虑,这两个 profile 就是给你的入口——关于钩子这一层的机制,可以对照读 Claude Code 钩子机制

manifests/install-components.json 是用户可见的一层。组件 ID 带家族前缀,family 取值包括 baseline、language、framework、capability、agent、skill、locale,例如 baseline:rulesbaseline:hookslang:typescriptskill:tdd-workflowagent:security-reviewerlocale:zh-cn

有一个必须自己确认的落差:设计文档 docs/SELECTIVE-INSTALL-DESIGN.md 明说,当前用户可见组件仍然是更粗的内部安装模块之上的一层别名,「some file-level boundaries remain imperfect」,也就是说 --without 排掉一个组件,未必等于文件级别一个不落地。计划脚本自己打印的那行提示也印证了这点:目标过滤和操作输出反映的是脚手架级别的适配器规划,并非旧 install.sh 拷贝路径的逐字节镜像。把 plan 的输出当作意图预览,别当成最终文件清单。

团队场景可以用 ecc-install.jsonscripts/lib/install/config.js 里默认文件名就是它,当前工作目录存在时会被自动发现;内容用 Ajv 按 schemas/ecc-install-config.schema.json 校验,version 被约束成一个固定常量,target 是前面那批适配器目标的枚举,还有 profilemodulesincludeexcludeoptions。这里有个容易被绊的细节:includeexclude 的字符串模式只允许 baselinelangframeworkcapability 四个前缀,而计划脚本的 --skills 参数会自动补 skill: 前缀。想把技能选择固化进配置文件的人,会在这里撞上校验失败。

四、卸载是怎么执行的

scripts/uninstall.js 的参数只有四个:--target--dry-run--json--help。它把 process.env.HOME || os.homedir() 作为 homeDir、process.cwd() 作为 projectRoot 传进 uninstallInstalledStates。不指定 --target 时,生命周期库里的 normalizeTargets 会返回全部已注册适配器,逐个去看对应位置有没有状态文件。

真正执行时,逻辑分成几层:

先按操作类型逆向。executeUninstallOperationcopy-file 是直接删除目标文件;对 render-templatemerge-json,如果操作记录里带了先前内容(代码里按 previousContentoriginalContentbackupContent 一组候选键去找,JSON 侧按 previousValuepreviousJsonoriginalValue 找),就把先前内容写回去,否则才删除;merge-json 在没有先前内容时走的是「从当前 JSON 里减掉当初合并进去的那份子集」,减完如果对象空了就删文件。对 remove 类型的操作,如果没有记录先前内容,卸载什么也不做——ECC 当初删掉的东西,不会自己回来。

再是路径约束。删除和写入都不走裸的 fs 调用,而是经过 assertWithinTrustedRoot,把目标限制在适配器算出来的可信根内;代码注释直接写明「Install-state is attacker-controllable」,并引用了一个 GHSA 编号。目标如果是符号链接,默认会抛 ECC_FINAL_DESTINATION_SYMLINK 拒绝操作;读写文件时用了 O_NOFOLLOW,写入前还会用文件描述符和父目录的 dev/ino 比对,确认写的过程中目标没被换掉。这套防护是针对「状态文件本身可能被篡改」这个威胁模型设计的:卸载读的是磁盘上的一份 JSON,如果有人改了里面的 destinationPath,指向可信根之外的路径,或者把目标换成一条指向别处的符号链接,那么「删除 ECC 自己的文件」就会变成「删除别的东西」。上面那三道校验堵的是同一类问题。你在评估要不要装的时候,这一段的意义是:它至少把「账本被污染」当成了一个真实的失败模式来处理,而不是默认账本永远可信。

最后是收尾。状态文件本身在所有操作执行完之后才被删除,然后对每个被删路径调用 cleanupEmptyParentDirs,向上清理空目录,遇到非空目录、非目录、符号链接就停。整个过程如果有任何一个目标报错,进程退出码是 1。

--dry-run 会输出计划删除的路径集合,它由所有 managed 操作的 destinationPath 加上状态文件路径去重构成。这是你唯一一次不付代价的核对机会。

五、边界与代价

这套设计的取舍很明确,明确到值得逐条列出来。

它只管账本内的东西。 好处是不会误删你的文件,代价是任何绕过安装器的手动拷贝、任何以插件形式装进宿主的副本,卸载都看不见。README 的排障章节里专门有一条讲「ECC 出现两次或钩子触发两次」,起因就是插件安装之上又跑了一次全量安装;给出的处理顺序是先移除插件安装,再跑卸载,再手工删掉自己拷过的规则目录,最后只用一条路径重装一次。

非拷贝类操作的还原能力取决于安装时记了多少。 schema 里操作项允许额外字段,先前内容就是靠这些额外字段带的。设计文档把「stronger repair/uninstall behavior for non-copy operations」列在 Phase 2,说明作者自己也认为这块还没做完。落到你身上:合并进宿主配置文件的那部分,最好装之前先自己备份一份,别指望卸载百分之百还原。

发现范围绑定当前目录。 卸载脚本用 process.cwd() 当项目根,project 类适配器的状态文件位置是从它算出来的。你在 ECC 检出目录里跑卸载,扫到的是主目录级安装和 ECC 检出目录自身的项目级安装,不是你那十几个业务仓库里的。每个装过的项目都得各自跑一次。

它不管你已经用它改出来的东西。 卸载回收的是 ECC 铺进去的规则、技能、命令、配置,不是这些东西在你代码库里产生的结果。

它不承诺跨版本的账本兼容。 schemaVersion 是常量,source.manifestVersionsource.repoVersion 在 doctor 里只作为告警项比对(对应 manifest-version-mismatchrepo-version-mismatch 两个 code),不做自动迁移。

顺带说清一件事:这套「按账本记录所有权、只回收自己的东西」的做法,本质上是权限与影响面控制在安装层的落地。站内 Agent 权限给太大Agent 最小权限设计 讲的是通用方法论——权限该怎么切、边界该怎么划;本文讲的是一个具体开源项目把这套方法论落成了什么样的字段、脚本和取舍,两边配合着读比单看哪一边都实在。想再看一层隔离手段的,还可以参考 工作区隔离

六、上手与避坑清单

别在第一次就上 full。 会踩是因为 full 展开后铺开的文件量很大,你根本不知道哪些是你要的,日后想减也说不清减掉了什么。怎么避:先 --list-profiles--list-components --family <family> 看目录,从 core 或 minimal 起步,用 --with 逐个加。

装之前一定跑一次只读计划。 会踩是因为多数人直接跑安装,出问题才回头找文件。怎么避:先用计划脚本带 --target--json 输出一份,存下来,作为日后比对的基线;同时记住计划输出是脚手架级规划,不是逐字节文件清单。

先确认你的 HOME 和当前目录。 会踩是因为 home 类适配器从主目录起算、project 类从 process.cwd() 起算,跑命令时人在哪个目录,直接决定文件落在哪、以后又从哪能被找回来。怎么避:安装和卸载都在明确的目录里执行,并把状态文件的实际路径记进项目文档。

别同时用插件安装和脚本安装。 会踩是因为两条路径互不知情,钩子会触发两次,表现是行为诡异而不是报错。怎么避:一台机器一条安装路径;已经叠加了就按 README 那四步顺序清理再装一次。

合并类写入先自己备份。 会踩是因为合并进已有配置文件的操作,卸载时的还原依赖操作记录里带没带先前内容,而这部分能力作者列在后续阶段。怎么避:安装前把要被写入的宿主配置文件复制一份到版本控制外的位置,卸载后拿它做 diff。相关的取舍可以顺带看 让 AI 改配置文件

卸载先 --dry-run,再看清单,再执行。 会踩是因为计划删除的清单里包含状态文件自身,一旦真删了状态文件而某些文件删除失败,你就失去了账本,剩下的残留只能手工找。怎么避:dry-run 的输出先落盘保存,执行后拿它逐条核对。

每个装过的项目各卸一次。 会踩是因为不带 --target 只是遍历所有适配器类型,不是遍历你机器上所有项目。怎么避:先记下装过的项目列表,逐个进去跑一遍。

出问题先跑 doctor 而不是重装。 会踩是因为重装会覆盖状态,把「哪里坏了」的证据一并抹掉。怎么避:doctor 会区分缺文件、内容漂移、源文件缺失、目标根不存在、清单版本不一致、解析结果与记录不一致等不同问题码,看清楚是哪一类再决定用 repair 还是重装。

收束

判断这套安装机制是否可控,你只需要三个动作:找到你那个目标对应的状态文件路径并打开它,看 request 块记的是不是你当初要的;跑一次卸载的 dry-run,看计划删除的清单是不是你能接受的范围;对合并类写入的宿主配置文件先备份一份。三条都过了再装,出了事你有账本、有基线、有退路。

想再往深看,按这个顺序读仓库:schemas/install-state.schema.json 看契约,scripts/lib/install-targets/ 下你那个目标的适配器看落点,scripts/lib/install-lifecycle.js 里的 executeUninstallOperationuninstallInstalledStates 看回收规则,最后 docs/SELECTIVE-INSTALL-DESIGN.md 看作者自己承认的未完成部分。这四个文件读完,你对它会改你什么这件事就不用再猜了。

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

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