From 1164be3bc298f091b638dc36ed52b35464f8983f Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Wed, 12 Aug 2026 20:59:06 +0200 Subject: [PATCH] 12: replace the Codex port with OpenCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode reads CLAUDE.md natively (and ~/.claude/CLAUDE.md globally), so the translate-then-hand-fix cycle that made the Codex port fragile does not exist. The chapter is now one JSON mapping plus the rules for what must not cross. Keeps four verified Codex findings in an appendix — no CLAUDE.md fallback, no config interpolation, a scrubbed MCP child environment, and trust-gated project config — because they are what the comparison rests on and were established by hand. --- 12-Claude-to-Codex.md | 227 --------------------------------------- 12-Claude-to-OpenCode.md | 142 ++++++++++++++++++++++++ _Sidebar.md | 2 +- 3 files changed, 143 insertions(+), 228 deletions(-) delete mode 100644 12-Claude-to-Codex.md create mode 100644 12-Claude-to-OpenCode.md diff --git a/12-Claude-to-Codex.md b/12-Claude-to-Codex.md deleted file mode 100644 index b1c4271..0000000 --- a/12-Claude-to-Codex.md +++ /dev/null @@ -1,227 +0,0 @@ -# 12 — Porting a Claude Code workspace to Codex - -**What this page is for.** Taking a project that a Claude Code session already works in, and making -an OpenAI Codex session a first-class participant in the same workspace — same instructions, same -skills, same bridge mount. It is written as a procedure so it can become a plugin skill verbatim. - -**Why it exists.** The bridge is a provider-neutral message bus, but a peer is only useful if it -knows the same rules. A Codex session with no ported config is not a peer — it is a stranger with -shell access. Everything below was verified by hand against `codex-cli 0.147.0` and -`ai-config-sync-manager 0.1.10`; nothing here is inferred from documentation. - -## The mapping - -Both hosts express the same four concepts in different files. - -| Concept | Claude Code | Codex | -|---|---|---| -| Instructions | `CLAUDE.md` | `AGENTS.md` | -| Skills | `.claude/skills/` | `.agents/skills/` (project) · `$CODEX_HOME/skills/` (user) | -| MCP servers | `.mcp.json` | `[mcp_servers.*]` in `config.toml` | -| Settings | `.claude/settings.json` | `config.toml` | -| Config isolation | `CLAUDE_CONFIG_DIR` | `CODEX_HOME` | - -```mermaid -flowchart LR - subgraph C["Claude Code workspace"] - C1["CLAUDE.md"] - C2[".claude/skills/"] - C3[".mcp.json"] - end - subgraph X["Codex workspace"] - X1["AGENTS.md"] - X2[".agents/skills/"] - X3[".codex/config.toml"] - end - C1 -->|"port"| X1 - C2 -->|"port"| X2 - C3 -->|"port — FILTERED"| X3 - C3 -.->|"machine-local servers
must NOT cross"| STOP["blocked on purpose"] - classDef danger fill:#b7791f,stroke:#7b341e,color:#ffffff; - class STOP danger -``` - -*Three of the four categories port wholesale. The MCP category is the one that must be filtered — -see [What must never cross](#what-must-never-cross).* - -## Procedure - -### 1. Install the porting tool - -```bash -npm install -g ai-config-sync-manager -ai-config-sync --version -``` - -It performs a continuous bidirectional sync with backups and a diff-first workflow. For this -procedure we use it **one-directional and project-scoped only**. - -### 2. Inspect before changing anything - -```bash -cd -ai-config-sync status --scope project --compact -``` - -Read every diff it reports. `--scope project` is deliberate: the global scope syncs your *user* -configuration between the two CLIs, which is a different decision with different blast radius (see -[Scope](#scope-project-vs-global)). - -### 3. Dry-run with an explicit include list - -Never sync "everything". Name what crosses: - -```bash -ai-config-sync sync --scope project --from claude --to codex \ - --include instructions,skills,mcp: --dry-run -``` - -The include syntax is `category` or `category:item` — so `mcp:bridged` ports exactly one MCP -server and leaves the rest behind. Read the change preview and the patch preview before applying. - -### 4. Apply - -```bash -ai-config-sync sync --scope project --from claude --to codex \ - --include instructions,skills,mcp: --apply -``` - -Backups land under `~/.ai-config-sync-manager/backups//`. - -### 5. Review the generated instructions — every time - -**The terminology map is naive about file paths, and it will produce sentences that are wrong -rather than merely awkward.** Diff the result and read the changed lines: - -```bash -diff CLAUDE.md AGENTS.md -``` - -Two real examples from this repo's port: - -| Generated | Why it is wrong | -|---|---| -| `Never commit ~/.codex/config.toml [mcp_servers]` | The source rule was *"never commit `.mcp.json`"* — a repo file. You cannot "never commit" a path in `~/`, so the sentence lost both its meaning and its protection. | -| skills row rewritten to `.codex/skills/**` | The tool had just written the skills to `.agents/skills/`. The document contradicted the tool's own output. | - -Fix these by hand. **`AGENTS.md` is a generated artifact that is not safe to blind-regenerate** — -re-running the sync reintroduces the same errors, so the diff review is part of the procedure, not -an optional polish step. - -### 6. Verify against the running agent, not the file - -A file on disk proves nothing about what the agent read. Ask it cold, in a fresh session: - -```bash -codex exec -s read-only "In one line: what is the ONLY channel you may use to answer the sender?" -``` - -The answer must reflect your ported instructions. If it answers generically, the instructions did -not reach the model and every later step is built on sand. - -Confirm the MCP mount separately: - -```bash -codex mcp list # your bridge server, status `enabled` -``` - -## What must never cross - -**Machine-local MCP servers.** An IDE index, a language server, an editor bridge — anything bound -to *your* checkout — must stay out of the ported config. - -The generated file lands at the **project root**, and Codex discovers it from the working directory -— so its blast radius is decided by one thing: whether you commit it. - -- **Left untracked** (the default the sync leaves you in) it stays local to your own checkout. A - git worktree cut from the project does *not* receive untracked files, so no peer sees it. -- **Committed**, every worktree gets it, and a worker in its own worktree mounts servers whose every - path points back into the primary's checkout. - -The second case is not hypothetical: it produced a worker that made all 59 of its edits in the -primary's tree while running its build in its own — so every build passed, and every change went -nowhere. See [Features](11-Features) → *Worker tool-surface isolation*. - -So the hazard arms on `git add`, not on `ai-config-sync --apply`, and an untracked file is one -`git add -A` away from armed. Close it explicitly rather than relying on nobody doing that: - -```gitignore -.codex/config.toml -``` - -Do this even if you filtered the sync correctly — the ignore rule protects the *next* person to run -it without `--include`. Note that gitignore is the right tool only while the file is untracked; a -config file already tracked for other reasons needs `git update-index --skip-worktree` instead, -which is how this repo pins `.mcp.json`. - -Port the bridge server. Leave the rest. - -**`status` will report the gap forever, and that is correct.** Once you have filtered, every later -`ai-config-sync status --scope project` shows the withheld servers as unresolved drift: - -``` -project/mcp [safe] missing-in-codex: intellij-index [exact] - missing-in-codex: jetbrains [exact] -``` - -That is the steady state, not a to-do list. The failure mode is a later session reading it as one -and "fixing" it with an unfiltered sync. - -## Scope: project vs global - -| Scope | What it touches | Use it for | -|---|---|---| -| `project` | `CLAUDE.md`/`AGENTS.md`, `.claude/skills`/`.agents/skills`, `.mcp.json`/`.codex/config.toml` | making one workspace bi-host — **the procedure on this page** | -| `global` | `~/.claude/**` ↔ `~/.codex/**` | keeping the two CLIs *you* drive interactively in step | - -Keep global sync away from anything the bridge spawns. The bridge deliberately gives each peer an -isolated tool surface; a user-level bidirectional sync works against that by design, not by bug. - -## What Codex does differently - -Porting config is not the whole story — Codex reaches the same outcomes through different seams. -A launcher or skill that assumes Claude's flags will fail silently. - -| Need | Claude Code | Codex | -|---|---|---| -| Standing instruction | `--append-system-prompt` (inline) | **`AGENTS.md` in `$CODEX_HOME`** — a file, no flag exists | -| Mount an MCP server | `--mcp-config ` | `-c mcp_servers..url="…"`, or `codex mcp add --url` | -| MCP bearer token | server config | `--bearer-token-env-var ` | -| Unattended tool calls | permission settings | **`--approve-for-me`** | -| Resume a conversation | `--resume ` | `codex resume ` — **names work natively** | -| Isolate config | `CLAUDE_CONFIG_DIR` | `CODEX_HOME` | -| Pick a model | `--model` | `-m` | - -Three of these are load-bearing enough to call out: - -**`--approve-for-me` is mandatory for any unattended peer.** Without it, every MCP tool call -returns `user cancelled MCP tool call` — even with `approval: never` and a read-only sandbox. MCP -tools sit behind a separate trust gate. The flag routes approvals through automatic review and -**keeps the sandbox on**; it is not the same as -`--dangerously-bypass-approvals-and-sandbox`, which disables sandboxing entirely and should not be -used to work around this. - -**`CODEX_HOME` is a complete boundary, and a fresh one has no credentials.** It holds config, -sessions, skills, plugins, and state. Point a peer at a new empty home and Codex fails with an -opaque `401 Unauthorized` mid-run rather than a startup error — so a launcher must provision -credentials into the home, and should **copy rather than symlink** so a peer cannot write back -through to the operator's real credential. - -**`AGENTS.md` in `$CODEX_HOME` is genuinely read and obeyed.** Verified by planting a distinctive -codeword as a standing instruction and asking an unrelated question in a fresh session; the answer -came back with the codeword. This is the only delivery mechanism for a reply charter on Codex. - -## Gotchas - -- **Identity comes from the terminal, not the runtime.** A Codex process started inside the - primary's pane resolves as the **primary** and inherits its authority. A Codex *peer* must be - spawned into its own pane — you cannot create one by running `codex` in a terminal. -- **The subscription guard does not apply.** The guard is `ANTHROPIC_BASE_URL`-shaped; Codex has no - such seam and authenticates through its own credentials. Codex peers bill their own provider, and - `maxLoad` is the only spend control that reaches them. -- **Model selection lives in the host's own config, not the bridge.** Porting instructions does not - port model choice, and the bridge should not own it — see [Features](11-Features) for why a second - source of truth hides drift instead of fixing it. -- **`.codex/config.toml` is local config.** Treat it like `.mcp.json` and keep it out of commits — - gitignore it, per [What must never cross](#what-must-never-cross). "It only has the bridge mount - today" is not a reason to commit it; the file is what the *next* sync writes into. diff --git a/12-Claude-to-OpenCode.md b/12-Claude-to-OpenCode.md new file mode 100644 index 0000000..8e8d544 --- /dev/null +++ b/12-Claude-to-OpenCode.md @@ -0,0 +1,142 @@ +# 12 — Porting a Claude Code workspace to OpenCode + +**What this page is for.** Taking a project a Claude Code session already works in, and making an +OpenCode session a first-class participant in the same workspace — same instructions, same MCP +servers, same bridge mount. + +**The short version: there is almost nothing to port.** OpenCode reads `CLAUDE.md` natively. The +only artifact you create is one `opencode.json` mapping MCP servers. Everything below was verified +against `opencode 1.18.16`; the procedural form ships as the `port-to-opencode` skill. + +## What you get for free + +OpenCode's instruction search order: + +``` +1. walking up from cwd: AGENTS.md , then CLAUDE.md +2. global: ~/.config/opencode/AGENTS.md +3. Claude Code global: ~/.claude/CLAUDE.md (unless disabled) +``` + +> *"The first matching file wins in each category."* + +So a project `CLAUDE.md` **and** your user-level `~/.claude/CLAUDE.md` are already read. No +translation step, no generated second copy, no sync tool — and therefore none of the drift that +comes with them. + +```mermaid +flowchart LR + subgraph W["Claude Code workspace"] + C1["CLAUDE.md"] + C2[".mcp.json"] + C3[".claude/skills/"] + end + subgraph O["OpenCode"] + O1["read natively"] + O2["opencode.json
(mcp)"] + O3["not read"] + end + C1 -->|"no port needed"| O1 + C2 -->|"map — FILTERED"| O2 + C3 -.->|"no equivalent"| O3 + classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff; + class O3 warn +``` + +*Instructions cross for free; MCP servers need a small mapping; skills do not cross at all.* + +## The one file you write + +Project config is `opencode.json` in the repo root; the global one is +`~/.config/opencode/opencode.json`. **Configs merge rather than replace**, so machine-local servers +belong in the global file and shared ones in the project file. + +| `.mcp.json` | `opencode.json` | +|---|---| +| `"type": "http"` / `"sse"` | `"type": "remote"`, `"url"` | +| `"type": "stdio"` | `"type": "local"`, `"command": ["bin", "arg"]` | +| `"command"` + `"args"` | single `"command"` array | +| `"env"` | `"environment"` | +| `"headers"` | `"headers"` | + +```json +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CLAUDE.md"], + "mcp": { + "bridged": { "type": "remote", "url": "http://127.0.0.1:8765/mcp", "enabled": true } + } +} +``` + +Set `instructions` explicitly even though `CLAUDE.md` is found anyway — the fallback applies only +while no `AGENTS.md` exists. + +## Secrets: reference, never embed + +OpenCode substitutes at load time, in both `headers` and `environment`: + +``` +{env:VARIABLE_NAME} from the environment +{file:~/.secrets/token} from a file +``` + +This is what makes `opencode.json` safe to commit — and it **must** be committable, because a peer +running in a git worktree receives tracked files only. No credential ever belongs in the file. + +## What must never cross + +Machine-local MCP servers — an IDE index, a language server, an editor bridge. A committed project +config reaches every worktree, and a worker mounting servers whose paths point into the primary's +checkout will edit the primary's files while building in its own: every build passes, every change +lands in the wrong tree. See [Features](11-Features) → *Worker tool-surface isolation*. + +Keep those in the global config if you want them personally. + +## Verify against the running agent + +A file on disk proves nothing about what the agent loaded. + +```bash +opencode run "In one line: state a rule from this project's instructions." +``` + +The answer must reflect the real `CLAUDE.md`. Then list the MCP servers and confirm each expected +one is connected — and that nothing you withheld appears. + +## Gotchas + +- **A leftover `AGENTS.md` silently shadows `CLAUDE.md`.** If one exists from an earlier port, + delete it, or opencode reads the stale copy instead of the live rules. Check this first. +- **Skills do not port.** OpenCode uses its own agent markdown under `.opencode/agents/`; + `.claude/skills/**` is not read. A delegation brief that says "load the *implementer* skill" has + no effect on an opencode peer — spell the procedure out in the brief instead. +- **Identity comes from the terminal, not the runtime.** A peer must be spawned into its own pane; + running `opencode` in the primary's pane resolves as the primary. +- **The subscription guard doesn't apply.** It is `ANTHROPIC_BASE_URL`-shaped; opencode + authenticates through its own credentials, so `maxLoad` is the only spend control that reaches it. + +--- + +## Appendix — the Codex port (superseded) + +Chapter 12 previously documented porting to OpenAI Codex. That path was abandoned in favour of +opencode, which the bridge already supports through `OpenCodeLauncher`. Four findings are kept +because they were verified by hand and explain *why* the comparison went this way: + +- **Codex has no `CLAUDE.md` fallback.** Instructions had to be translated into `AGENTS.md` by a + third-party tool, whose terminology map rewrote file paths into sentences that were wrong rather + than merely awkward — turning "never commit `.mcp.json`" into a rule about a path in `~/` that + cannot be committed at all. The generated file was never safe to regenerate. +- **Codex config has no variable interpolation.** `${VAR}` passes through literally, so a secret in + a config file had to be a literal value. +- **Codex scrubs the environment for MCP servers.** A stdio MCP child receives only a core set + (`HOME LANG LOGNAME PATH PWD SHELL SHLVL TERM TMPDIR USER …`) — neither the parent environment nor + `shell_environment_policy.set` reaches it. A per-server `env_vars` whitelist exists to pass named + variables through; it was documented and accepted by the CLI, but never confirmed end-to-end here. +- **Project config is gated on trust.** `.codex/config.toml` is read only when the checkout is + marked `trust_level = "trusted"` in the home config, and the two layers merge. + +Contrast with opencode's `{env:VAR}` and native `CLAUDE.md` reading: the same three problems — rules +delivery, secret handling, and tool-surface control — cost one small JSON file instead of an adapter, +a provisioner, and a translation tool. diff --git a/_Sidebar.md b/_Sidebar.md index 045ec32..9ec486f 100644 --- a/_Sidebar.md +++ b/_Sidebar.md @@ -15,7 +15,7 @@ 9. [Implementation](9-Implementation) — as-built code map · classes · flows · state machines 10. [Cross-Host Messaging](10-Cross-Host-Messaging) — broker topology · exchanges · queues per entity 11. [Features](11-Features) — what it can do · the knob that turns it on · why · the gotcha -12. [Claude → Codex](12-Claude-to-Codex) — porting a workspace to a second host +12. [Claude → OpenCode](12-Claude-to-OpenCode) — porting a workspace to a second host --- 🟢 herdr-centric `bridged` · AgentAPI = fallback