12: replace the Codex port with OpenCode

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.
Dai Ha
2026-08-12 20:59:06 +02:00
parent 3d62b1d569
commit 1164be3bc2
3 changed files with 143 additions and 228 deletions
-227
@@ -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<br/>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 <the project>
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:<your-bridge-server-name> --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:<your-bridge-server-name> --apply
```
Backups land under `~/.ai-config-sync-manager/backups/<timestamp>/`.
### 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 <json>` | `-c mcp_servers.<n>.url="…"`, or `codex mcp add <n> --url` |
| MCP bearer token | server config | `--bearer-token-env-var <ENV>` |
| Unattended tool calls | permission settings | **`--approve-for-me`** |
| Resume a conversation | `--resume <uuid>` | `codex resume <uuid \| name>` — **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.
+142
@@ -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<br/>(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.
+1 -1
@@ -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