4
12 Claude to OpenCode
Dai Ha edited this page 2026-08-31 10:35:11 +07:00

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 fleet 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.

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"
{
  "$schema": "https://opencode.ai/config.json",
  "instructions": ["CLAUDE.md"],
  "mcp": {
    "fleet": { "type": "remote", "url": "http://127.0.0.1:8765/mcp", "enabled": true }
  }
}

The mount name here must be fleet, not fleetd. Every launcher writes this same constant name (PeerLauncher.MCP_MOUNT_NAME = "fleet", PeerLauncher.java:34) into the config it builds for a spawned worker — ClaudeCodeLauncher into an inline --mcp-config (ClaudeCodeLauncher.java:348), OpenCodeLauncher into the opencode.json it writes (OpenCodeLauncher.java:345). A hand-written config using a different key mounts a server under a name the role-detection ladder in CLAUDE.md does not check, so an opencode peer set up by hand must match this name exactly.

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.

The trap: {env:…} reads OpenCode's process environment, and OpenCode has no env store of its own. Claude Code supplies this project's secrets through env blocks in its settings cascade — files opencode never reads. Verified here: a clean login shell (env -u VAR zsh -lc …) has none of CONTEXT7_TOKEN, GITEA_ACCESS_TOKEN, GITEA_HOST; they exist only inside processes Claude Code spawned. Run opencode mcp list from such a process and everything is green; run it from a real terminal and context7 is ⚠ needs authentication.

Worse, the two launch paths disagree on names. A fleetd-spawned worker gets GITEA_TOKEN + GITEA_HOST from applyGitToken (see Features → the git-forge token grant; the method lives at HerdrPeerLauncher.java:893-900 and both launchers call it), not GITEA_ACCESS_TOKEN — so an opencode.json written against the terminal's environment fails inside a worker, and vice versa.

So the supply route follows from who launches opencode:

Launcher Route Here
a human, from a terminal {file:.secrets/…} gitignored .secrets/, workspace-scoped
a fleetd-spawned worker {env:…} applyGitToken + the profile's env: block

For the terminal case this repo uses {file:} with workspace-relative paths — verified: opencode resolves them against the project root, so no shell export and no direnv is needed, and the secret never enters an rc file that every process inherits.

.secrets/ being gitignored is the point and the limitation: a worker in a git worktree does not receive it, by the same tracked-files-only rule that makes opencode.json worth committing. Workers are fed by the launcher instead — which is also where the name mismatch above must be reconciled.

Keeping Claude Code on the same source of truth

Claude Code expands ${VAR} in .mcp.json but cannot read a file, so left alone it needs the same tokens duplicated as literals in .claude/settings.local.json. A committed .autoenv (directory-scoped env loader — autoenv here, direnv elsewhere) closes that gap by exporting .secrets/ into the environment, holding no secret of its own:

flowchart LR
    S[".secrets/<br/>(gitignored, 0600)"]
    S -->|"{file:...}"| OC["opencode.json"]
    S -->|".autoenv exports"| E["process env"]
    E -->|"${VAR}"| MJ[".mcp.json"]
    L["launcher env"] -->|"{env:...}"| WK["spawned worker"]
    classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
    class L warn

One store, three readers — the worker path stays separate because a worktree has no .secrets/.

Two verified caveats. The loader fires on entering the directory and when a shell starts there, so a terminal opened directly in the repo is covered — but a Claude Code launched outside a login shell is not, and an unset ${VAR} degrades silently to the literal text ${VAR} rather than failing. And leaving the directory does not unload the variables unless leave-files are enabled.

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 → 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.

opencode run "In one line: state a rule from this project's instructions."

The answer must reflect the real CLAUDE.md. Then opencode mcp list — and treat its verdict with suspicion: "connected" does not mean "working". A stdio server missing its credential completes the handshake and reports green; only a tool call exposes it. Verified: gitea showed ✓ connected with no token, then failed the first call with token is required. Exercise one authenticated tool per server, and confirm 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/ (HerdrPeerLauncher.java:322-326 builds that path for both launchers); .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 and lives only in ClaudeCodeLauncher; OpenCodeLauncher.buildLaunch never calls it. 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 fleetd 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.