From f665448376c79b54a6568dd940acb2069bbacdf7 Mon Sep 17 00:00:00 2001 From: Myfro9 <36755290+Myfro9@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:16:31 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20BOWEI=202026-09-07=20tools-sync=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20junction=20=E6=A8=A1=E5=9E=8B=EF=BC=88regi?= =?UTF-8?q?stry=20=E5=8F=8C=E5=90=91=20ingest=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync_repos.ps1: 每个 skill 只在 ~\.claude\skills 有一份物理副本,其余 registry 一律 junction;新增 registry-only skill 的自动 ingest、MISDIRECTED 链接修复、 同名内容分叉的 CONFLICT 报告,以及只对 -MirrorRegistry 做全量镜像。 - SKILL.md: 重写阶段 1/2,补 Codex MCP 存储模型(config.toml 惰性, codex mcp list --json 是唯一权威口径)与 CLI 升级会清空注册表的事实。 - sync_mcp_registrations.ps1 + references/codex_mcp_wanted_keys.json: 报告 codex-cli 无接口可设的键(tool_timeout_sec / env_vars)为 "registered, but incomplete" 而非失败。 - tests/test_sync_repos_registries.ps1: registry 对账的回归测试。 - .gitignore: orchestration(Orca 自带的发现桩,同 orca-cli)列为 local-only。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01438Zbcx49e4o46uSA71mFF --- .gitignore | 1 + tools-sync/SKILL.md | 134 ++++++--- .../references/codex_mcp_wanted_keys.json | 9 + tools-sync/references/skills_env_template.md | 21 +- tools-sync/scripts/sync_mcp_registrations.ps1 | 72 ++++- tools-sync/scripts/sync_repos.ps1 | 268 ++++++++++++++---- .../tests/test_sync_mcp_registrations.ps1 | 4 +- .../tests/test_sync_repos_registries.ps1 | 69 +++++ 8 files changed, 467 insertions(+), 111 deletions(-) create mode 100644 tools-sync/references/codex_mcp_wanted_keys.json create mode 100644 tools-sync/tests/test_sync_repos_registries.ps1 diff --git a/.gitignore b/.gitignore index 838513c..7f716a6 100644 --- a/.gitignore +++ b/.gitignore @@ -370,5 +370,6 @@ __pycache__/ /vercel-react-view-transitions/ /web-design-guidelines/ /orca-cli/ +/orchestration/ /.stfolder/ /.claude/ diff --git a/tools-sync/SKILL.md b/tools-sync/SKILL.md index 1407eb1..5bbed09 100644 --- a/tools-sync/SKILL.md +++ b/tools-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: tools-sync -description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills、MCP server 和开发环境:将 ~\.claude\skills 和 ~\.codex\skills 与 git.rpchannel.com 上的 bowei/skills-win 仓库保持同步,审计 skills 中的硬编码参数并迁移到本机 ~\skills_env.md,校验 langgraph conda 环境,双向发现并补齐 Claude Code 与 Codex 的用户 MCP 注册,同时确保 E:\MCP_design 下所有 MCP 均已注册。同步的传输一旦出问题,本 skill 还负责登录 gitea 服务器(上海 mac 的 Docker 容器)诊断并修复,直到同步真正成功。当用户提到"同步skills"、"skills同步"、"tools sync"、"tools-sync"、"同步codex"、"同步MCP"、"环境同步"、"skills-win"、"把skills推上去/拉下来"、"新电脑配置claude/codex环境"、"检查MCP注册"、"skills_env",或者 git.rpchannel.com 出现"clone/fetch 失败"、"bad pack header"、"early EOF"、"RPC failed"、"推不上去/拉不下来"、"gitea 坏了/连不上/要修"时,必须使用此技能,即使用户没有明确说"skill"二字。 +description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills(~\.claude\skills / ~\.codex\skills / ~\.agents\skills)、用户级 MCP 注册和开发环境:主副本与 git.rpchannel.com 上的 bowei/skills-win 仓库保持同步(其余目录一律是 junction),审计 skills 中的硬编码参数并迁移到本机 ~\skills_env.md,校验 langgraph conda 环境,双向发现并补齐 Claude Code 与 Codex 的用户 MCP 注册,同时确保 E:\MCP_design 下所有 MCP 均已注册。同步的传输一旦出问题,本 skill 还负责登录 gitea 服务器(上海 mac 的 Docker 容器)诊断并修复,直到同步真正成功。当用户提到“同步skills”、“skills同步”、“tools sync”、“tools-sync”、“同步codex”、“codex 里的 skill 怎么进仓库”、“把 codex 新写的 skill 收进来”、“同步MCP”、“MCP 注册/更新/补齐”、“检查MCP注册”、“codex mcp 没了”、“环境同步”、“skills-win”、“把skills推上去/拉下来”、“新电脑配置claude/codex环境”、“skills_env”、“skills 硬编码路径”,或者 git.rpchannel.com 出现“clone/fetch 失败”、“bad pack header”、“early EOF”、“RPC failed”、“推不上去/拉不下来”、“gitea 坏了/连不上/要修”时,必须使用此技能,即使用户没有明确说“skill”二字。也用于处理 junction 指错、两侧 skill 分叉、以及 skills 里的硬编码路径/密码需要迁进 ~\skills_env.md 的场合。 --- # tools-sync:Claude Code / Codex 工具链同步 @@ -11,12 +11,22 @@ description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills、M 注册表。这个 skill 的职责是让它们保持一致,并且可以在一台新电脑上从零复原整套配置: - **skills 的唯一真源**是 `http://git.rpchannel.com/bowei/skills-win.git`(remote 名 `origin`)。 - `~\.claude\skills` 是唯一的 git 检出(主副本);`~\.codex\skills` 下的每个 skill - 是指向主副本对应目录的 **junction(目录联接)**——两个工具共享同一份物理文件, - 从机制上杜绝两侧漂移,git 操作只发生在主副本一处。 - Codex 没有注册命令——它在会话启动时自动扫描 `~\.codex\skills` 下所有含 `SKILL.md` - 的目录(junction 对它透明),所以"注册到 Codex" = "junction 存在且指向的目录含 - SKILL.md",新开的 Codex 会话才会扫到新 skill。 + `~\.claude\skills` 是唯一的 git 检出(主副本);其余目录里的每个 skill 都是指向主副本 + 对应目录的 **junction(目录联接)**——所有工具共享同一份物理文件,从机制上杜绝漂移, + git 操作只发生在主副本一处。 + 受管的 registry 有两个:`~\.codex\skills`(Codex 自己的目录,主副本在这里做全量镜像)和 + `~\.agents\skills`(Codex / Copilot CLI / Gemini CLI 都会扫的跨工具别名目录)。 + **`~\.agents\skills` 只做发现与修复,不做全量镜像**:Codex 两个目录都扫,全量铺进去会让 + 同一个 skill 注册两遍。 +- **同步是双向的**。Codex 侧新建的 skill(真实目录、含 SKILL.md、主副本里没有同名)会被 + **自动 ingest**:移进 `~\.claude\skills` 成为主副本,原地换成 junction,随后跟着阶段 1 入库。 + 这是"Codex 里写的 skill 如何进仓库"的唯一正规路径,不需要人工搬运。 + Codex 没有注册命令——它在会话启动时自动扫描上述目录下所有含 `SKILL.md` 的目录 + (junction 对它透明),所以"注册到 Codex" = "junction 存在且指向的目录含 SKILL.md", + 新开的 Codex 会话才会扫到新 skill。 +- **Codex 插件(`codex plugin list`)自带的 skill 和 MCP 归 marketplace 所有**,不属于可移植 + 用户配置,任何阶段都不要 ingest、不要复制到 Claude 侧。同理 `~\.agents\skills` 里指向 + `~\.matlab\agentic-toolkits` 的 junction 是 MathWorks 装的,脚本只计数不动它。 - **机器相关的参数不进 git**。每台电脑的软件安装路径、IP、账号等都记录在 `~\skills_env.md`(用户主目录下,天然在两个仓库检出之外,不会被 git 同步)。 skills 里引用参数时写"见 ~\skills_env.md 的 XXX 条目",不写死路径。 @@ -137,23 +147,38 @@ powershell -File \scripts\gitea_doctor.ps1 -Fix # 检查→登 第三步能直接看出是 gitea 丢了 pack 还是 git 自己没产出——上次就是这一步定的案。 定位到新根因后,把它补进 `gitea_triage.sh` 的检查表,别只修这一次。 -## 阶段 2 — 对齐 ~\.codex\skills(junction 对账) -同一个脚本接着处理 codex 侧。它对主副本的每个顶层 skill 目录做三件事: +## 阶段 2 — registry 对账(ingest + 修复 + 镜像) -- codex 侧没有同名条目 → 创建 junction(`New-Item -ItemType Junction`, - Windows 上不需要管理员权限,Codex 对 junction 完全透明)。 -- 已是 junction → 跳过(内容天然一致,无需任何拷贝)。 -- 是**真实目录**(挡住了链接)→ 脚本停下来列出这些目录。停的原因:真实目录 - 里可能有 codex 独有的、还没入库的内容。处理原则——有价值的内容先复制进 - `~\.claude\skills` 走阶段 1 入库,确认无需保留后用 `-Force` 重跑,脚本会 - 删掉真实目录并建 junction。 +同一个脚本接着扫 `~\.codex\skills` 和 `~\.agents\skills`,对每个顶层条目分类处理: -脚本还会清理"指向已删除主副本目录"的失效 junction(skill 在仓库里被删除或 -改名后残留的链接)。codex 自带的系统 skills(`.system\`)永远不动。 +| 现状 | 脚本动作 | +|------|----------| +| junction 指向主副本,目标存在 | 跳过(正确状态) | +| junction 指向主副本,目标已消失 | 删除失效链接(skill 在仓库里被删或改名后的残留) | +| junction 指向受管 registry 之外(如 `~\.matlab\agentic-toolkits`) | 只计数,不动(宿主自己装的) | +| junction 指向**另一个 registry** 里的副本 | 报 MISDIRECTED,解析那份副本后把链接改指主副本 | +| 真实目录,含 SKILL.md,主副本**无**同名 | **INGEST**:移进主副本 + 原地建 junction(`-NoIngest` 可关闭) | +| 真实目录,与主副本同名且内容**相同** | 直接塌缩成 junction(丢不了东西) | +| 真实目录,与主副本同名但内容**不同** | 报 CONFLICT,两边都不动,退出码 2 | +| 目录里没有 SKILL.md | 忽略并说明 | -校验注册:主副本每个 skill 目录在 codex 侧都应有同名 junction 且透出 -`SKILL.md`(脚本已内置该检查)。提醒用户:Codex 要开新会话才会扫到新 skills。 +最后把主副本每个 skill 镜像进 `-MirrorRegistry`(默认 `~\.codex\skills`)。 +`.system\` 等点开头目录永远不动。 + +判断力要点: + +- **CONFLICT 是唯一需要人裁决的分支。** 内容指纹排除了 `.git`、`__pycache__`、 + `.langgraph_api`、`*.pckl`、`*.sqlite` 这类运行时产物,所以报出来的分叉是真分叉。 + 处理方式:把想留的内容并进主副本,再带 `-Force` 重跑(`-Force` 一律主副本胜出)。 +- **ingest 进来的 skill 可能命中 `.gitignore` 的 local-only 段**(仓库里有一段 + `/orca-cli/` 之类的本机专用规则)。脚本会 `git check-ignore` 并在报告里点名—— + 这种 skill 留在本机、不会推上远端,换电脑时不会出现。要它进仓库就删掉那条规则。 +- **registry 自带 `.git` 是历史遗留的竞争检出。** 本机 `~\.agents\skills` 就有一个指向 + github 镜像的检出。脚本只报告不动手;等它的独有内容都 ingest 完,再由用户决定删。 + +校验注册:主副本每个 skill 在镜像 registry 都应有同名 junction 且透出 `SKILL.md` +(脚本已内置该检查)。提醒用户:Codex 要开新会话才会扫到新 skills。 ## 阶段 3 — 硬编码审计与 skills_env.md 回填 @@ -194,8 +219,31 @@ commit + push 一次(junction 模型下 codex 侧自动同步,无需额外 ## 阶段 5 — MCP server 双向发现、注册与校验(Claude Code ↔ Codex) +### Codex 的 MCP 存储模型(codex-cli 0.150.1 实测,官方无文档) + +动手前必须知道这三条,否则会写出"看起来成功、实际无效"的配置: + +- **`codex mcp list --json` / `add` / `remove` 是唯一有效接口。** 审计读它,注册写它。 +- **`~\.codex\config.toml` 的 `[mcp_servers.*]` 节是惰性的,Codex 不读。** 实测:把 + `node_repl` 的 `startup_timeout_sec` 从 120 改成 137,`codex mcp list` 仍报 120; + 手写一整节则完全不出现。config.toml 里现存的那几节是历史遗留。 + **本 skill 旧版"缺失的补写 TOML 节"是错的,别再那样做——它静默地什么也没干。** +- **这个存储不跨 CLI 升级存活。** 2026-08-29 注册好的 7 个用户 MCP,在升级到 0.150.1 + 后全部消失。所以每次同步都要重新审计 Codex 侧,`~\skills_env.md` 的 MCP 配置节 + 才是可重建的真源。 + +由此还有一个已知缺口:`codex mcp add` 没有 `--tool-timeout-sec` / `env_vars` 之类的参数, +config.toml 又不生效,所以这两个键**目前无任何设置入口**。需要它们的 MCP 记在 +`references/codex_mcp_wanted_keys.json`,脚本注册完会单独报"registered, but incomplete", +不要把它当失败,也不要假装已配好。每次 codex-cli 升级后复查是否已有新接口。 + +另:这两个脚本都要用 PowerShell 跑,不要用 git-bash——MSYS 会改写 `/c` 形式的参数和 +Windows 路径(实测 `codex mcp add ... /c echo hi` 的 `/c` 被改成了 `C:/`)。 + +### 双向审计 + 先运行双向审计脚本。它读取 Claude Code 用户级 `~\.claude.json` 的 `mcpServers` 和 -Codex 用户级 `~\.codex\config.toml` 的 `[mcp_servers.*]`,对用户可配置 MCP 做并集: +`codex mcp list --json`,对用户可配置 MCP 做并集: ```powershell # 只审计;发现差集或漂移时退出码为 2,不修改配置 @@ -231,17 +279,17 @@ powershell -File \scripts\sync_mcp_registrations.ps1 -Apply -Confli 1. **Claude Code 侧**:`claude mcp list` 或读 `~\.claude.json` 的 `mcpServers`, 确认存在对应条目。缺失的用 `claude mcp add ...` 注册,命令、解释器 路径和 env 变量都取自 `~\skills_env.md` 的 MCP 配置节。 -2. **Codex 侧**:读 `~\.codex\config.toml` 的 `[mcp_servers.]` 节。 - 缺失的按 skills_env.md 里的同一份配置补写 TOML 节(stdio 类型, - 记得带 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`,Codex 在 Windows 上 - 没有这两个变量时中文输出会乱码)。 +2. **Codex 侧**:`codex mcp list --json` 确认存在对应条目。缺失的用 + `codex mcp add --env K=V -- ` 注册,配置取自 skills_env.md + 的同一份表(stdio 类型记得带 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`,Codex 在 + Windows 上没有这两个变量时中文输出会乱码)。 3. **可启动性抽查**:对新注册的 MCP,直接运行其 command + args 数秒, 确认进程能起来、没有立刻报 import/路径错误(stdio MCP 起来后会静默等 输入,这就算通过,Ctrl-C 掉即可)。 -注意两侧配置语义相同但格式不同(JSON vs TOML)。先由双向脚本发现差集和漂移,再用 -`mcp_list.md` 查漏,最后以 skills_env.md 和实际可启动结果判定正确值。不要因为某项当前 -只在一侧出现,就假设另一侧没有安装需求。 +先由双向脚本发现差集和漂移,再用 `mcp_list.md` 查漏,最后以 skills_env.md 和实际可启动 +结果判定正确值。不要因为某项当前只在一侧出现,就假设另一侧没有安装需求——尤其 Codex 侧 +可能只是被上一次 CLI 升级清空了。 ### MATLAB / Simulink Agentic Toolkit 专项检查与安装 @@ -256,13 +304,17 @@ powershell -File \scripts\sync_mcp_registrations.ps1 -Apply -Confli 且已安装 Simulink。实际 MATLAB 根目录记录到 `~\skills_env.md` 的 `MATLAB_ROOT`。 2. `~\.matlab\agentic-toolkits\bin\matlab-mcp-server.exe` 存在且 `--version` 可运行; `config.json` 的 `toolkits` 同时包含 `matlab` 与 `simulink`。 -3. Claude `~\.claude.json` 与 Codex `~\.codex\config.toml` 均有用户级 `matlab` MCP, +3. Claude `~\.claude.json` 与 `codex mcp list --json` 均有用户级 `matlab` MCP, command 指向同一二进制,args 同时包含 `--matlab-session-mode=existing` 和正确的 `--extension-file`。Claude 条目应有 `alwaysLoad=true`。 -4. Codex 的 `[mcp_servers.matlab]` 还必须有 `tool_timeout_sec = 600` 和 Windows 所需的 - `env_vars = ["WINDIR"]`;缺少 `WINDIR` 时 Simulink 子进程可能无法正常工作。 -5. `~\.agents\skills` 中存在指向两套 Toolkit 的 junction;Claude Code 的 MATLAB / - Simulink plugin 或等效 skill 注册存在。 +4. Codex 侧本应还有 `tool_timeout_sec = 600` 和 Windows 所需的 `env_vars = ["WINDIR"]` + (缺 `WINDIR` 时 Simulink 子进程可能起不来),但 0.150.1 **没有任何接口能设置它们** + (见本阶段开头的存储模型)。所以这一条是**已知缺口,不作为安装完整性的判定项**: + 脚本报出 "registered, but incomplete" 即可,写进报告的遗留事项,不要标记为失败, + 也不要试图靠改 config.toml 绕过——那不生效。 +5. `~\.agents\skills` 中存在指向两套 Toolkit 的 junction(阶段 2 会把这类指向 + `~\.matlab\agentic-toolkits` 的 junction 识别为宿主自有,只计数不动);Claude Code 的 + MATLAB / Simulink plugin 或等效 skill 注册存在。 缺失时使用 MathWorks 官方统一安装器,不使用旧版 agent-driven 安装法: @@ -290,8 +342,9 @@ setupAgenticToolkit("configure", Agents=["claude-code","codex"], ... Setup” 与 “MATLAB MCP Server Toolbox” 两个官方 Toolbox 根目录加入 path 并 `savepath`,再用全新 MATLAB 进程通过 `which setupAgenticToolkit`、 `which shareMATLABSession` 验证。 -- 安装后重新运行双向审计脚本;`matlab` 必须显示为 Matched。再直接解析两边配置,确认 - Simulink extension、Codex timeout 与 `WINDIR` 未被规范化比较遗漏。 +- 安装后重新运行双向审计脚本;`matlab` 必须显示为 Matched。再直接比对两边的 command/args, + 确认 Simulink extension 未被规范化比较遗漏(Codex timeout 与 `WINDIR` 见上面第 4 条, + 当前无法设置,不必核对)。 - 使用 Simulink 工具前,每个 MATLAB 会话运行 `addpath("~/.matlab/agentic-toolkits/simulink"); satk_initialize`,然后重启 Claude Code / Codex 会话。MCP 健康检查在没有共享 MATLAB 会话时可能显示连接失败,这不等于未安装。 @@ -306,15 +359,17 @@ setupAgenticToolkit("configure", Agents=["claude-code","codex"], ... - ~\.claude\skills: - 远端校验: origin/main = ,与本地一致 / **不一致:N 个 commit 滞留本机** - 服务端: 传输两面均正常 / 医生修复了 <哪几项> / **仍不可用:** -- ~\.codex\skills: +- 从 registry ingest: <被自动收进主副本的 codex/agents 侧 skill;没有则写"无"> +- junction 对账: <修复的 MISDIRECTED、塌缩的重复、清理的失效链接、镜像新建数> ## 硬编码审计 - 本次修复: <清单> - 遗留待改: <清单> ## 环境 - langgraph env: OK / 已重装 / 失败原因 - MCP 注册: +- MCP 已注册但不完整: ## 需要用户决定的事项 -- <冲突、密码泄露、codex 独有 skill 去留……没有则写"无"> +- ``` ## 边界与安全 @@ -324,6 +379,9 @@ setupAgenticToolkit("configure", Agents=["claude-code","codex"], ... - `~\skills_env.md` 含凭据,任何情况下不将其内容或凭据字段写入仓库内文件、 commit message 或对外输出。 - 不动 `~\.codex\skills\.system\`(Codex 自带系统 skills)和 - `~\.claude\skills\.system\`。 + `~\.claude\skills\.system\`;也不动 `codex plugin` 装的 marketplace 插件。 - 修改 `~\.codex\config.toml` 前先备份为 `config.toml.bak-<日期>`,TOML 写坏 会导致 Codex 起不来。 +- **ingest 会 `Move-Item`,`-Force` 会 `Remove-Item -Recurse`。** 先 `-DryRun` 看清单, + 确认没有误判再实跑;CONFLICT 项在用户裁决前脚本不会碰。 +- 两个脚本都从 PowerShell 运行,不要用 git-bash(MSYS 会改写参数里的 `/c` 和 Windows 路径)。 diff --git a/tools-sync/references/codex_mcp_wanted_keys.json b/tools-sync/references/codex_mcp_wanted_keys.json new file mode 100644 index 0000000..fa14d4e --- /dev/null +++ b/tools-sync/references/codex_mcp_wanted_keys.json @@ -0,0 +1,9 @@ +{ + "_comment": "Codex-side [mcp_servers.] keys certain MCPs need that codex-cli 0.150.1 offers NO way to set: `codex mcp add` has no flag for them, and config.toml's [mcp_servers.*] tables are inert (measured -- editing one there does not change what `codex mcp list` reports). sync_mcp_registrations.ps1 reports these after registering so the gap stays visible instead of looking complete. Re-check on each codex-cli upgrade; delete an entry once a real interface exists and the key is set.", + + "matlab": { + "tool_timeout_sec": 600, + "env_vars": ["WINDIR"], + "_why": "Simulink model_edit/model_test round-trips exceed the default tool timeout; the Simulink subprocess needs WINDIR inherited from the parent environment on Windows." + } +} diff --git a/tools-sync/references/skills_env_template.md b/tools-sync/references/skills_env_template.md index e329663..dfa87b4 100644 --- a/tools-sync/references/skills_env_template.md +++ b/tools-sync/references/skills_env_template.md @@ -10,8 +10,9 @@ SKILLS_REPO_URL = http://git.rpchannel.com/bowei/skills-win.git SKILLS_REPO_USER = bowei SKILLS_REPO_PASS = <填写,或留空表示已存入 Windows 凭据管理器> -CLAUDE_SKILLS_DIR = C:\Users\\.claude\skills -CODEX_SKILLS_DIR = C:\Users\\.codex\skills +CLAUDE_SKILLS_DIR = C:\Users\\.claude\skills # 唯一 git 检出(主副本) +CODEX_SKILLS_DIR = C:\Users\\.codex\skills # 全量镜像,全是 junction +AGENTS_SKILLS_DIR = C:\Users\\.agents\skills # 跨工具别名目录;只做发现/修复,不全量铺 ``` 凭据说明:首次 clone 成功后运行 `git config --global credential.helper manager`, @@ -93,11 +94,13 @@ CROSS_VM_PASS = <填写> MCP_DESIGN_ROOT = E:\MCP_design ``` -每个 MCP 在 Claude Code(`~\.claude.json` 的 `mcpServers`)和 Codex -(`~\.codex\config.toml` 的 `[mcp_servers.*]`)中应使用**同一份** command/args/env。 -同步时还要对两侧用户级注册表取并集,确保任一侧新增的可移植 MCP 都同步到另一侧; +每个 MCP 在 Claude Code(`~\.claude.json` 的 `mcpServers`)和 Codex(`codex mcp add`, +读用 `codex mcp list --json`)中应使用**同一份** command/args/env。注意 Codex 侧 +`~\.codex\config.toml` 的 `[mcp_servers.*]` 节**不生效**,手写无用;且它的注册存储 +不跨 codex-cli 升级存活,所以下表是 Codex 侧唯一可重建的真源,每次同步都要核对。 +同步时对两侧用户级注册表取并集,确保任一侧新增的可移植 MCP 都同步到另一侧; `mcp_list.md` 继续用于发现“两侧都漏装”的本地工程。托管连接器、插件动态 MCP、 -Codex 内置运行时 MCP 不进入这个并集。 +Codex 内置运行时 MCP(node_repl / cua_repl)不进入这个并集。 标准配置如下(路径变量引用上文条目): | name | command | args | env | @@ -110,8 +113,10 @@ Codex 内置运行时 MCP 不进入这个并集。 | matlab | {MATLAB_MCP_EXE} | --matlab-session-mode=existing --extension-file=...\simulink\tools\tools.json | | Codex 侧所有 stdio MCP 额外加 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`。 -MathWorks `matlab` MCP 是特例:Codex 还要设置 `tool_timeout_sec=600` 与 -`env_vars=["WINDIR"]`;Claude 侧要设置 `alwaysLoad=true`。MATLAB 与 Simulink 共用该条目。 +MathWorks `matlab` MCP 是特例:Claude 侧要设 `alwaysLoad=true`;Codex 侧本应有 +`tool_timeout_sec=600` 与 `env_vars=["WINDIR"]`,但 codex-cli 0.150.1 无接口可设, +属已知缺口(记录在 tools-sync 的 `references/codex_mcp_wanted_keys.json`)。 +MATLAB 与 Simulink 共用该条目。 ## 本机状态备注(同步时更新) diff --git a/tools-sync/scripts/sync_mcp_registrations.ps1 b/tools-sync/scripts/sync_mcp_registrations.ps1 index 0fc2389..53a28f2 100644 --- a/tools-sync/scripts/sync_mcp_registrations.ps1 +++ b/tools-sync/scripts/sync_mcp_registrations.ps1 @@ -1,6 +1,20 @@ # tools-sync: audit and reconcile user-configured MCP registrations between # Claude Code and Codex. Managed connectors, plugins, and Codex runtime MCPs are # intentionally excluded because they are owned by their host application. +# +# Codex storage model (measured on codex-cli 0.150.1, not documented anywhere): +# * `codex mcp list --json` / `add` / `remove` are the ONLY working interface. +# * config.toml's [mcp_servers.*] tables are INERT. Editing node_repl's startup_timeout_sec +# there from 120 to 137 left `codex mcp list` reporting 120, and a section written by hand +# never appeared at all. Hand-writing TOML sections -- what this skill used to instruct -- +# silently does nothing. The sections still in config.toml are legacy leftovers. +# * `codex mcp add` persists outside config.toml and has no flag for tool_timeout_sec or +# env_vars, so those keys currently have no setting entry point at all. +# * That store is not durable across CLI upgrades: seven user MCPs registered on 2026-08-29 +# were gone after the upgrade to 0.150.1. Re-audit Codex MCPs on every sync; ~/skills_env.md +# is the durable record to re-register from. +# +# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style args and Windows paths. [CmdletBinding()] param( @@ -10,12 +24,21 @@ param( [ValidateSet("None", "Claude", "Codex")] [string]$ConflictAuthority = "None", [string[]]$ExcludeName = @(), - [string]$PythonCommand = "python" + [string]$PythonCommand = "python", + # Where to read Codex's registrations. Auto = `codex mcp list --json`, falling back to + # parsing -CodexConfig when the CLI is unavailable (older codex, or a test fixture). + # Toml forces the fixture path so tests can isolate from the real machine. + [ValidateSet("Auto", "Cli", "Toml")] + [string]$CodexSource = "Auto", + # Codex-side keys some MCPs need that codex-cli offers no way to set (see the note above). + # Resolved after the param block: $PSScriptRoot is not yet bound in default expressions here. + [string]$OverridesFile ) $ErrorActionPreference = "Stop" $script:CodexBackupMade = $false $script:Failures = @() +$script:Unsupported = @() $CodexOnlyEnv = @("PYTHONIOENCODING", "PYTHONUTF8") function Test-TomllibPython([string]$Candidate) { @@ -53,6 +76,19 @@ function Resolve-PythonCommand([string]$Requested, [bool]$Explicit) { $PythonCommand = Resolve-PythonCommand $PythonCommand $PSBoundParameters.ContainsKey('PythonCommand') +if (-not $OverridesFile) { + $here = Split-Path -Parent $MyInvocation.MyCommand.Path + $OverridesFile = Join-Path (Split-Path -Parent $here) "references\codex_mcp_wanted_keys.json" +} + +$script:Overrides = @{} +if (Test-Path -LiteralPath $OverridesFile) { + $raw = Get-Content -Raw -LiteralPath $OverridesFile | ConvertFrom-Json + foreach ($property in $raw.psobject.Properties) { + if ($property.Name -notlike "_*") { $script:Overrides[$property.Name] = $property.Value } + } +} + function ConvertTo-StringMap($Object) { $result = [ordered]@{} if ($null -eq $Object) { return $result } @@ -128,8 +164,11 @@ function Get-CodexMcpsFromCli { } function Get-CodexMcps { - $fromCli = Get-CodexMcpsFromCli - if ($null -ne $fromCli) { return $fromCli } + if ($CodexSource -ne "Toml") { + $fromCli = Get-CodexMcpsFromCli + if ($null -ne $fromCli) { return $fromCli } + if ($CodexSource -eq "Cli") { throw "codex mcp list --json unavailable and -CodexSource Cli forbids the TOML fallback" } + } if (-not (Test-Path -LiteralPath $CodexConfig)) { throw "Codex config not found: $CodexConfig" @@ -234,6 +273,7 @@ function Add-ToCodex($Mcp) { $arguments = @("mcp", "add", $Mcp.name) $envMap = [ordered]@{} foreach ($key in $Mcp.env.Keys) { $envMap[$key] = $Mcp.env[$key] } + # Without these, Codex mangles non-ASCII stdio output on Windows. $envMap["PYTHONIOENCODING"] = "utf-8" $envMap["PYTHONUTF8"] = "1" foreach ($key in $envMap.Keys) { $arguments += @("--env", "$key=$($envMap[$key])") } @@ -241,6 +281,7 @@ function Add-ToCodex($Mcp) { $arguments += $Mcp.command $arguments += @($Mcp.args) Invoke-External "codex add $($Mcp.name)" "codex" $arguments + Report-UnsupportedKeys $Mcp.name return } if ($Mcp.headers.Count -gt 0) { @@ -248,6 +289,17 @@ function Add-ToCodex($Mcp) { } Backup-CodexConfig Invoke-External "codex add $($Mcp.name)" "codex" @("mcp", "add", $Mcp.name, "--url", $Mcp.url) + Report-UnsupportedKeys $Mcp.name +} + +# Some MCPs need Codex keys that `codex mcp add` has no flag for and that config.toml +# cannot supply either (see the storage-model note at the top). Surface them rather than +# pretending the registration is complete. +function Report-UnsupportedKeys([string]$Name) { + if (-not $script:Overrides.ContainsKey($Name)) { return } + $keys = @($script:Overrides[$Name].psobject.Properties | Where-Object { $_.Name -notlike "_*" } | + ForEach-Object { "$($_.Name)=$($_.Value)" }) + if ($keys) { $script:Unsupported += "$Name wants $($keys -join ', ') - no interface in codex-cli to set these" } } function Add-ToClaude($Mcp) { @@ -344,6 +396,20 @@ if ($Apply) { } } +if ($Apply) { + # Codex re-reads config.toml on every invocation, so this confirms what it will actually load. + $after = Get-PortableMcps (Get-CodexMcps) "Codex" + $stillMissing = @($claude.Keys | Where-Object { -not $after.ContainsKey($_) } | Sort-Object) + Write-Host "=== post-apply verification (codex mcp list) ===" + Write-Host "Codex now serves: $(@($after.Keys | Sort-Object) -join ', ')" + if ($stillMissing) { $script:Failures += "still absent from Codex after apply: $($stillMissing -join ', ')" } +} + +if ($script:Unsupported.Count -gt 0) { + Write-Host "=== registered, but incomplete (codex-cli cannot express these keys) ===" + $script:Unsupported | ForEach-Object { Write-Host "- $_" } +} + if ($script:Failures.Count -gt 0) { Write-Host "=== failures ===" $script:Failures | ForEach-Object { Write-Host "- $_" } diff --git a/tools-sync/scripts/sync_repos.ps1 b/tools-sync/scripts/sync_repos.ps1 index 41eb472..c548545 100644 --- a/tools-sync/scripts/sync_repos.ps1 +++ b/tools-sync/scripts/sync_repos.ps1 @@ -1,29 +1,51 @@ -# tools-sync: mechanical sync of skills between Claude Code and Codex. -# Phase 1 -- git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push. -# Phase 2 -- junction reconciliation: every top-level skill dir in the primary gets a -# directory junction of the same name in ~\.codex\skills, so both tools share -# one physical copy and can never drift. Codex auto-registers any dir there -# containing SKILL.md (new sessions only). -# Judgment calls (rebase conflicts, codex-local dirs worth keeping) are left to the caller. +# tools-sync: mechanical sync of skills across Claude Code, Codex, and the +# cross-runtime ~\.agents\skills directory. +# +# Phase 1 — git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push. +# Phase 2 — registry reconciliation. Every skill has exactly ONE physical home (the primary +# checkout); every other registry holds a directory junction pointing at it, so the +# tools cannot drift. This phase is bidirectional: +# * a real skill dir that exists ONLY in a registry is INGESTED into the primary +# (this is how a skill created from the Codex side enters the repo), then linked back; +# * a link that points somewhere other than the primary is repaired; +# * a real dir duplicating a primary skill collapses to a junction when the content +# is identical, and is reported as a conflict when it differs. +# Judgment calls (rebase conflicts, divergent duplicates) are left to the caller. +# +# Exit codes: 0 = clean, 1 = hard failure, 2 = conflicts需要人工裁决. +# +# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style arguments and Windows paths. +# +# A server-side transport fault (bad pack header / early EOF / RPC failed) is not fatal: +# gitea_doctor.ps1 is invoked to repair the gitea host and the push is retried once (-NoDoctor opts out). param( [Parameter(Mandatory = $true)][string]$RepoUrl, [string]$ClaudeSkills = "$env:USERPROFILE\.claude\skills", - [string]$CodexSkills = "$env:USERPROFILE\.codex\skills", + # Registries mirroring the primary. ~\.codex\skills is Codex's own directory; + # ~\.agents\skills is the cross-runtime alias Codex / Copilot CLI / Gemini CLI also scan. + [string[]]$Registry = @("$env:USERPROFILE\.codex\skills", "$env:USERPROFILE\.agents\skills"), + # Registry that gets a junction for EVERY primary skill. The others are only scanned for + # ingest/repair, because linking a skill into two directories Codex scans registers it twice. + [string]$MirrorRegistry = "$env:USERPROFILE\.codex\skills", [string]$Branch = "main", - # A real (non-junction) dir in the codex side that shadows a primary skill blocks - # linking; -Force deletes it and links (caller must have rescued unique content first). + # Resolve divergent duplicates by declaring the primary authoritative (deletes the registry copy). [switch]$Force, - # Report what would happen without committing/pushing/linking. - [switch]$DryRun, # A transport failure normally aborts the run and leaves commits stranded here. By # default we instead log into the gitea host, repair it, and retry once. -NoDoctor # keeps the old abort-and-report behaviour. [switch]$NoDoctor, - [switch]$AllowDestructive + [switch]$AllowDestructive, + # Leave registry-only skills where they are instead of moving them into the primary checkout. + [switch]$NoIngest, + # Report what would happen without committing/pushing/linking/moving. + [switch]$DryRun ) $ErrorActionPreference = "Stop" +$script:Conflicts = @() +$script:Ingested = @() +$script:Notes = @() function Invoke-Git { param([string]$Dir, [string[]]$GitArgs) @@ -68,8 +90,41 @@ function Invoke-Doctor([string]$Why) { return $ok } +function Get-LinkTarget($Item) { + if (-not $Item.Target) { return $null } + return (@($Item.Target) | Select-Object -First 1) +} + +function Test-IsLink($Item) { + return [bool]($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) +} + +function Test-UnderPath([string]$Path, [string]$Root) { + if (-not $Path -or -not $Root) { return $false } + return $Path.TrimEnd('\').ToLowerInvariant().StartsWith($Root.TrimEnd('\').ToLowerInvariant() + '\') +} + +# Content fingerprint used to decide whether a duplicate is a harmless copy or a real fork. +# Runtime junk that legitimately differs between machines is excluded, mirroring .gitignore. +function Get-DirFingerprint([string]$Path) { + $skip = '(\\\.git\\|\\__pycache__\\|\\\.langgraph_api\\|\.pyc$|\.pckl$|\.sqlite(-shm|-wal)?$)' + $files = Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch $skip } + $lines = foreach ($f in $files) { + $rel = $f.FullName.Substring($Path.Length).TrimStart('\').ToLowerInvariant() + "$rel $((Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash)" + } + return (($lines | Sort-Object) -join "`n") +} + +function New-SkillJunction([string]$LinkPath, [string]$TargetPath, [string]$Why) { + if ($DryRun) { Write-Host " (dry-run) would link $LinkPath -> $TargetPath [$Why]"; return } + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null + Write-Host " linked: $LinkPath -> $TargetPath [$Why]" +} + # ---------- Phase 1: primary checkout (~\.claude\skills) ---------- -Write-Host "=== [1/2] primary git sync: $ClaudeSkills ===" +Write-Host "=== [1/3] primary git sync: $ClaudeSkills ===" if (-not (Test-Path (Join-Path $ClaudeSkills ".git"))) { Fail "$ClaudeSkills is not a git repo. Clone $RepoUrl there first (new machine: git clone $RepoUrl `"$ClaudeSkills`")." } @@ -162,61 +217,152 @@ if ($DryRun) { } } -# ---------- Phase 2: junction reconciliation (~\.codex\skills) ---------- -Write-Host "=== [2/2] codex junctions: $CodexSkills ===" -if (-not (Test-Path $CodexSkills)) { New-Item -ItemType Directory -Force $CodexSkills | Out-Null } +# ---------- Phase 2: ingest + repair every registry ---------- +# Walk registries FIRST so skills that only exist on the Codex side land in the primary +# before Phase 3 mirrors the primary back out. +$registries = @($Registry | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }) +Write-Host "=== [2/3] registry ingest + repair ===" -# Left over from the older mirror-checkout approach; junctions need no repo here. -if (Test-Path (Join-Path $CodexSkills ".git")) { - Write-Host "removing stale .git from codex dir (junction model needs no second checkout)" - if (-not $DryRun) { Remove-Item -Recurse -Force (Join-Path $CodexSkills ".git") } -} +foreach ($reg in $registries) { + Write-Host "-- $reg" + if (-not (Test-Path $reg)) { Write-Host " (absent, skipped)"; continue } + $foreign = 0 -$primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" } -$blocked = @() -foreach ($d in $primarySkills) { - $link = Join-Path $CodexSkills $d.Name - if (Test-Path $link) { - $item = Get-Item $link - if ($item.LinkType -eq "Junction") { continue } - # A real dir shadows the primary skill; its content may be unique. - if ($Force) { - if ($DryRun) { Write-Host "(dry-run) would replace real dir with junction: $($d.Name)" ; continue } - Remove-Item -Recurse -Force $link - New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null - Write-Host "replaced real dir with junction: $($d.Name)" - } else { - $blocked += $d.Name + # A registry carrying its own .git is a competing checkout; the junction model needs only one. + if (Test-Path (Join-Path $reg ".git")) { + $remote = (Invoke-Git $reg @("remote", "get-url", "origin")).Out + $script:Notes += "$reg holds its own git checkout (origin: $remote). The junction model needs only the primary checkout; review and remove it once its unique content is ingested." + Write-Host " NOTE: competing git checkout here (origin: $remote) - reported, not touched" + } + + foreach ($entry in Get-ChildItem $reg -Directory -Force | Where-Object { $_.Name -notlike ".*" }) { + # $copyPath is the real directory to resolve; $repairLinks are registry entries that must + # end up pointing at the primary once it does. + $copyPath = $entry.FullName + $repairLinks = @() + + if (Test-IsLink $entry) { + $target = Get-LinkTarget $entry + if (Test-UnderPath $target $ClaudeSkills) { + if (-not (Test-Path $target)) { + # Skill deleted/renamed upstream; the dangling link confuses Codex. + if ($DryRun) { Write-Host " (dry-run) would remove stale link: $($entry.Name)"; continue } + $entry.Delete() + Write-Host " removed stale link: $($entry.Name)" + } + continue # correctly pointed at the primary + } + + $inAnotherRegistry = $false + foreach ($other in $registries) { if (Test-UnderPath $target $other) { $inAnotherRegistry = $true } } + if (-not $inAnotherRegistry) { + # Host-owned catalog (e.g. the MathWorks agentic toolkits). Not ours to manage. + # Counted rather than listed: these run into the hundreds and drown the report. + $foreign++ + continue + } + + # Points at a copy living in another registry: the exact drift this model exists to + # prevent. Resolve that copy, then re-point this link at the primary. + Write-Host " MISDIRECTED link: $($entry.Name) -> $target" + $repairLinks += $entry.FullName + $copyPath = $target } - } else { - if ($DryRun) { Write-Host "(dry-run) would link: $($d.Name)"; continue } - New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null - Write-Host "linked: $($d.Name)" + + $name = Split-Path $copyPath -Leaf + $primaryPath = Join-Path $ClaudeSkills $name + if (-not (Test-Path $copyPath)) { continue } # already resolved earlier this run + $repairLinks += $copyPath + + # --- real directory holding a skill --- + if (-not (Test-Path (Join-Path $copyPath "SKILL.md"))) { + Write-Host " not a skill dir (no SKILL.md), ignored: $name" + continue + } + + $resolved = $false + if (-not (Test-Path $primaryPath)) { + if ($NoIngest) { + $script:Conflicts += "$name exists only in $copyPath; -NoIngest kept it out of the primary." + Write-Host " registry-only skill NOT ingested (-NoIngest): $name" + continue + } + Write-Host " INGEST registry-only skill: $name ($copyPath -> $primaryPath)" + if (-not $DryRun) { Move-Item -LiteralPath $copyPath -Destination $primaryPath } + $script:Ingested += $name + $resolved = $true + } + elseif ((Get-DirFingerprint $copyPath) -eq (Get-DirFingerprint $primaryPath)) { + # Identical copy: collapsing it loses nothing. + Write-Host " duplicate identical to primary, collapsing to junction: $name" + if (-not $DryRun) { Remove-Item -Recurse -Force -LiteralPath $copyPath } + $resolved = $true + } + elseif ($Force) { + Write-Host " -Force: primary wins over divergent copy: $name" + if (-not $DryRun) { Remove-Item -Recurse -Force -LiteralPath $copyPath } + $resolved = $true + } + else { + $script:Conflicts += "$name diverges between $primaryPath and $copyPath. Merge what you want to keep into the primary, then re-run with -Force." + Write-Host " CONFLICT: $name diverges from primary (registry copy left untouched)" + } + + if ($resolved) { + foreach ($lnk in ($repairLinks | Select-Object -Unique)) { + if ((-not $DryRun) -and (Test-Path $lnk)) { (Get-Item $lnk).Delete() } + New-SkillJunction $lnk $primaryPath "repaired" + } + } + } + if ($foreign -gt 0) { Write-Host " $foreign host-owned links left alone (targets outside the managed registries)" } +} + +# ---------- Phase 3: mirror the primary into the mirror registry ---------- +Write-Host "=== [3/3] mirror primary -> $MirrorRegistry ===" +if ($MirrorRegistry) { + if (-not (Test-Path $MirrorRegistry)) { + if ($DryRun) { Write-Host "(dry-run) would create $MirrorRegistry" } + else { New-Item -ItemType Directory -Force $MirrorRegistry | Out-Null } + } + # Left over from the older mirror-checkout approach; junctions need no repo here. + $mirrorGit = Join-Path $MirrorRegistry ".git" + if ((Test-Path $mirrorGit) -and $MirrorRegistry -ne $ClaudeSkills) { + Write-Host "removing stale .git from mirror registry (junction model needs no second checkout)" + if (-not $DryRun) { Remove-Item -Recurse -Force $mirrorGit } + } + + $primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" } + foreach ($d in $primarySkills) { + $link = Join-Path $MirrorRegistry $d.Name + if (Test-Path $link) { continue } + New-SkillJunction $link $d.FullName "mirror" + } + + # Codex only registers a directory that exposes SKILL.md. + $missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $MirrorRegistry "$($_.Name)\SKILL.md")) } + if ($missing -and -not $DryRun) { + Write-Host "WARNING: no SKILL.md visible through these links (Codex will ignore them):" + $missing | ForEach-Object { Write-Host " $($_.Name)" } } } -# Junctions whose primary target is gone (skill deleted/renamed upstream) confuse Codex. -foreach ($item in Get-ChildItem $CodexSkills -Directory | Where-Object { $_.LinkType -eq "Junction" }) { - $target = ($item.Target | Select-Object -First 1) - if ($target -and $target.StartsWith($ClaudeSkills) -and -not (Test-Path $target)) { - if ($DryRun) { Write-Host "(dry-run) would remove stale junction: $($item.Name)"; continue } - # A junction is just a link -- removing it never touches target content. - $item.Delete() - Write-Host "removed stale junction: $($item.Name)" +# An ingested skill matching a local-only .gitignore rule never reaches the remote — +# it would silently fail to appear on the next machine. +foreach ($name in $script:Ingested) { + $ci = Invoke-Git $ClaudeSkills @("check-ignore", "-v", $name) + if ($ci.Code -eq 0) { + $script:Notes += "ingested '$name' is excluded by .gitignore ($($ci.Out)); it stays local-only until you remove that rule." } } -if ($blocked) { - Write-Host "these codex dirs are REAL directories shadowing primary skills (not linked):" - $blocked | ForEach-Object { Write-Host " $_" } - Fail "rescue any unique content into the primary checkout, then re-run with -Force." +Write-Host "=== summary ===" +if ($script:Ingested) { Write-Host "ingested into primary: $($script:Ingested -join ', ')" } +foreach ($n in $script:Notes) { Write-Host "NOTE: $n" } +if ($script:Conflicts) { + Write-Host "conflicts needing a decision:" + $script:Conflicts | ForEach-Object { Write-Host " - $_" } + Write-Host "=== sync_repos finished with conflicts ===" + exit 2 } - -# Registration sanity: every linked skill must expose a SKILL.md for Codex to register it. -$missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $CodexSkills "$($_.Name)\SKILL.md")) } -if ($missing) { - Write-Host "WARNING: no SKILL.md visible through these links (Codex will ignore them):" - $missing | ForEach-Object { Write-Host " $($_.Name)" } -} - Write-Host "=== sync_repos done (Codex picks up changes on its next new session) ===" diff --git a/tools-sync/tests/test_sync_mcp_registrations.ps1 b/tools-sync/tests/test_sync_mcp_registrations.ps1 index a9c838b..543bc35 100644 --- a/tools-sync/tests/test_sync_mcp_registrations.ps1 +++ b/tools-sync/tests/test_sync_mcp_registrations.ps1 @@ -38,8 +38,10 @@ command = "C:\\Users\\tester\\.codex\\runtimes\\node_repl.exe" $oldErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" + # -CodexSource Toml pins the read to the fixture; the default Auto path would query the + # real `codex mcp list` and make this test measure the machine instead of the fixtures. $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptUnderTest ` - -ClaudeConfig $claudeConfig -CodexConfig $codexConfig -Verbose 2>&1 | Out-String + -ClaudeConfig $claudeConfig -CodexConfig $codexConfig -CodexSource Toml -Verbose 2>&1 | Out-String $code = $LASTEXITCODE $ErrorActionPreference = $oldErrorActionPreference if ($code -ne 2) { throw "expected audit exit 2, got $code`n$output" } diff --git a/tools-sync/tests/test_sync_repos_registries.ps1 b/tools-sync/tests/test_sync_repos_registries.ps1 new file mode 100644 index 0000000..5d2350a --- /dev/null +++ b/tools-sync/tests/test_sync_repos_registries.ps1 @@ -0,0 +1,69 @@ +# Covers Phase 2 of sync_repos.ps1 -- the registry reconciliation that decides whether a +# directory found on the Codex side gets ingested, collapsed, or reported. Runs against a +# throwaway primary + registries + bare origin under TEMP; the real machine is never touched. + +$ErrorActionPreference = "Stop" +$scriptUnderTest = Join-Path (Split-Path $PSScriptRoot -Parent) "scripts\sync_repos.ps1" +$root = Join-Path ([IO.Path]::GetTempPath()) ("tools-sync-repos-test-" + [guid]::NewGuid().ToString("N")) + +function New-Skill([string]$Path, [string]$Body) { + New-Item -ItemType Directory -Force -Path $Path | Out-Null + Set-Content -LiteralPath (Join-Path $Path "SKILL.md") -Value $Body -Encoding utf8 +} + +try { + $primary = Join-Path $root "primary" + $codex = Join-Path $root "codex" + $agents = Join-Path $root "agents" + New-Item -ItemType Directory -Force -Path $primary, $codex, $agents | Out-Null + + # One skill of each shape the reconciler must tell apart. + New-Skill (Join-Path $primary "shared") "shared body" + New-Skill (Join-Path $primary "forked") "primary body" + New-Skill (Join-Path $agents "shared") "shared body" # identical duplicate -> collapse + New-Skill (Join-Path $agents "forked") "registry body" # divergent -> conflict + New-Skill (Join-Path $agents "codexborn") "born on the codex side" # registry-only -> ingest + New-Item -ItemType Directory -Force -Path (Join-Path $agents "notaskill") | Out-Null # no SKILL.md + # A link into the other registry rather than the primary: the drift case. + New-Item -ItemType Junction -Path (Join-Path $codex "shared") -Target (Join-Path $agents "shared") | Out-Null + + # A local bare repo stands in for origin so Phase 1 completes and Phase 2 actually runs. + $origin = Join-Path $root "origin.git" + & git init --quiet --bare $origin + & git init --quiet --initial-branch=main $primary + & git -C $primary remote add origin $origin + & git -C $primary -c user.email=t@t -c user.name=t commit --quiet --allow-empty -m init + & git -C $primary push --quiet -u origin main + + # Invoked via -Command, not -File: -File stringifies every argument, so a [string[]] + # parameter silently takes only its first element and the rest slide into later positions. + $ErrorActionPreference = "Continue" + $invocation = "& '$scriptUnderTest' -RepoUrl '$origin' -ClaudeSkills '$primary' " + + "-Registry @('$codex','$agents') -MirrorRegistry '$codex'" + $output = & powershell -NoProfile -ExecutionPolicy Bypass -Command $invocation 2>&1 | Out-String + $ErrorActionPreference = "Stop" + + if ($output -notmatch 'INGEST registry-only skill:\s*codexborn') { throw "codex-side skill was not ingested`n$output" } + if (-not (Test-Path (Join-Path $primary "codexborn\SKILL.md"))) { throw "ingested skill is not in the primary`n$output" } + if ((Get-Item (Join-Path $agents "codexborn")).LinkType -ne "Junction") { throw "ingested skill was not linked back`n$output" } + + if ($output -notmatch 'MISDIRECTED link:\s*shared') { throw "link into another registry not detected`n$output" } + $sharedTarget = (Get-Item (Join-Path $codex "shared")).Target | Select-Object -First 1 + if ($sharedTarget -ne (Join-Path $primary "shared")) { throw "misdirected link not repointed at the primary, got $sharedTarget`n$output" } + if ((Get-Item (Join-Path $agents "shared")).LinkType -ne "Junction") { throw "identical duplicate did not collapse`n$output" } + + if ($output -notmatch 'CONFLICT:\s*forked') { throw "divergent duplicate not reported`n$output" } + if ((Get-Item (Join-Path $agents "forked")).LinkType -eq "Junction") { throw "divergent duplicate must be left untouched`n$output" } + if ((Get-Content -Raw (Join-Path $primary "forked\SKILL.md")) -notmatch 'primary body') { throw "primary was overwritten by a conflict`n$output" } + + if ($output -notmatch 'not a skill dir') { throw "directory without SKILL.md not reported`n$output" } + # Mirror registry must expose every primary skill, including the freshly ingested one. + foreach ($name in @("shared", "forked", "codexborn")) { + if (-not (Test-Path (Join-Path $codex "$name\SKILL.md"))) { throw "mirror missing $name`n$output" } + } + if ($output -notmatch 'primary in sync with origin') { throw "Phase 1 did not complete`n$output" } + + Write-Host "PASS: registry ingest / repair / conflict fixtures" +} finally { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +}