14 KiB
ddev-codex-claude-cli
ddev-codex-claude-cli is a DDEV add-on that runs Codex CLI and Claude Code CLI inside the DDEV web container while keeping runtime state under the project's .ddev directory on the host (bind-mounted into the container).
What it does
- exposes
ddev codex - exposes
ddev claude - optionally exposes
ddev claude-swapfor juggling multiple Claude accounts (opt-in; see Multiple Claude accounts) - optionally keeps read-only mirrors of sibling repos under
.ddev/claude-code/home/refs/for Claude to grep (opt-in; see Sibling repo mirrors) - installs Codex CLI lazily the first time you run
ddev codex(or whenever the persisted binary is missing) by downloading the latest release from GitHub:curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh - installs Claude Code CLI during
ddev startwhen the persisted binary is missing or broken by downloading the stable native build:curl -fsSL https://claude.ai/install.sh | bash -s stable - keeps normal
ddev startfast by skipping Claude Code installation when the persisted binary is already runnable - persists each CLI's binary, HOME, and XDG paths under project-local storage so each DDEV project has its own profiles
- seeds the project-local HOME from the container home on first bootstrap for
.gitignore_globalwhen present - keeps its own artifacts out of the project's git via a managed block in
.git/info/exclude, refreshed on everyddev startandddev claude(see Keeping add-on files out of git) - always runs Codex with
--sandbox danger-full-access --ask-for-approval untrusted
Runtime storage
Inside the web container, Codex CLI state lives under:
${DDEV_APPROOT}/.ddev/codex-cli
Claude Code CLI state lives under:
${DDEV_APPROOT}/.ddev/claude-code
On the host, those are your project's .ddev/codex-cli/ and .ddev/claude-code/ directories. They survive ddev restart, ddev stop, and ddev poweroff like any other project file. The same directories hold the add-on's install scripts (managed files) alongside runtime data created after the first start.
Codex layout:
| Path | Role |
|---|---|
.ddev/codex-cli/install.sh |
Codex install script |
.ddev/codex-cli/bin/codex |
Codex CLI binary |
.ddev/codex-cli/home |
HOME |
.ddev/codex-cli/config |
XDG_CONFIG_HOME |
.ddev/codex-cli/data |
XDG_DATA_HOME |
.ddev/codex-cli/state |
XDG_STATE_HOME |
Claude Code layout:
| Path | Role |
|---|---|
.ddev/claude-code/install.sh |
Claude Code install script |
.ddev/claude-code/home/.local/bin/claude |
Claude Code CLI binary |
.ddev/claude-code/home |
HOME |
.ddev/claude-code/config |
XDG_CONFIG_HOME |
.ddev/claude-code/data |
XDG_DATA_HOME |
.ddev/claude-code/state |
XDG_STATE_HOME |
Authentication and settings are per project: sign in once per project; they persist across container restarts.
Install
From a DDEV project root:
ddev add-on get /path/to/ddev-codex-claude-cli
Then restart:
ddev restart
Usage
ddev codex
ddev codex --version
ddev claude
ddev claude --version
ddev claude update
ddev claude-swap list # opt-in; see "Multiple Claude accounts"
ddev claude-refs # opt-in; see "Sibling repo mirrors"
Updates
Codex CLI is installed lazily when ddev codex runs and the persisted binary is missing or broken.
Claude Code CLI is installed automatically on ddev start only when the persisted binary is missing or broken. Later starts run a fast health check and skip installation when Claude is already runnable.
To check for and install a newer Claude Code stable release, run:
ddev claude update
Multiple Claude accounts
The add-on can integrate claude-swap
(cswap) so one project can hold several Claude accounts and swap between them —
mainly to keep working past a 5-hour or weekly rate limit. It is opt-in: nothing
is installed during ddev start; the first ddev claude-swap call (or first
ddev claude --account …) installs uv and claude-swap into the project-local
Claude profile under .ddev/claude-code/.
Accounts are stored per project, next to the rest of the Claude state:
.ddev/claude-code/data/claude-swap/
They persist across restarts and are deleted when the add-on is removed. Back them up
first with ddev claude-swap export backup.cswap if you need to keep them.
Swapping accounts without losing your session
A running ddev claude conversation is not tied to the auth token, and Claude Code
re-reads its credentials file whenever it changes. So:
- Terminal 1: you are in
ddev claude, working, and hit (or approach) the limit. - Terminal 2 (on the host):
ddev claude-swap switch <n|email|alias>(orddev claude-swap switch --strategy best). - Back in terminal 1: send the next message — it goes out on the new account, in the same session with all its context. If a request just failed with the limit error, resend it.
To automate step 2, run ddev claude-swap auto in a spare terminal (or an
ddev ssh shell); it watches usage and switches around 90%.
One-time setup
ddev claude # sign in as your first account
ddev claude-swap add --alias a # capture it
ddev claude # /login inside Claude Code as the next account, then exit
ddev claude-swap add --alias b # capture it
ddev claude-swap list # all accounts with usage and reset times
ddev claude-swap status # currently active account
ddev claude-swap forwards every argument straight to cswap, so any cswap
subcommand works (switch, run, map, disable, export, import, tui, …).
ddev claude-swap upgrade updates claude-swap to the latest release.
Starting a session pinned to an account
Separately from the hot-swap flow, you can launch a session bound to one account (with its own per-account chat history):
ddev claude --account b
ddev claude --account b --resume # remaining args pass straight to Claude Code
Set a project default so plain ddev claude always uses it:
echo b > .ddev/claude-code/claude-swap-account
# or export DDEV_CLAUDE_SWAP_ACCOUNT=b
Remove the file (or unset the variable) to go back to the normal single-account launch.
Requirements
The first install needs outbound network access to astral.sh, pypi.org,
files.pythonhosted.org, and github.com (for the uv-managed Python 3.12).
claude-swap captures whichever account is signed in at cswap add time, so the
initial multi-account setup is a few manual /login round-trips.
Sibling repo mirrors
If a project works alongside sibling repos on the same self-hosted Git host, Claude can be given
read-only, auto-refreshing local clones of them to grep for prior art — plus, optionally, the
gitea MCP server for their issues and PRs. This is opt-in per project and driven entirely by
one manifest, .ddev/claude-refs.txt; projects without that file see no change at all.
.ddev/claude-refs.txt is local by default: the add-on's managed git-exclude
block ignores it, since it typically names a LAN-only Git host,
a bot token, and mirrors specific to one person's sandbox. A project that wants the manifest shared
with the rest of the team can still track it with git add -f .ddev/claude-refs.txt.
Manifest: .ddev/claude-refs.txt
# Settings: one per line, starting with @
@host git.example.com # required
@user claude-ro # bot account; with @token, drives git credentials
@token ~/.gitea-ro-token # default; 0600 file in the sandbox home, never committed
@depth 50 # default
@mcp gitea https://mcp.example.com/mcp # optional: name + URL, registered with the token as Bearer
# Mirrors: <dir-name> <owner/repo> <description, rest of line, becomes the README row>
sibling-api org/sibling-api Go service this project talks to over HTTP.
shared-lib org/shared-lib Shared component library, imported as a dependency.
Inline # comments are only allowed on setting lines, after all the values the setting reads.
Keep them off mirror lines, or they end up in the description.
Environment overrides, mainly for testing: CLAUDE_REFS_MANIFEST, CLAUDE_REFS_HOME,
CLAUDE_REFS_MAX_AGE (seconds), CLAUDE_REFS_TIMEOUT (seconds per clone or fetch), and
CLAUDE_REFS_DETACHED (set by install.sh; sends output to refs/.sync.log).
What happens once a project has a manifest
- On
ddev startand on everyddev claude,install.shruns the sync in the background (setsid nohup) so neither command waits on the network. A stamp file throttles real syncs to at most one every 6h (CLAUDE_REFS_MAX_AGE), written only after a fully successful run, so a failure retries next launch. If the Git host is unreachable, the sync logs a line and exits cleanly rather than failingddev start. - Mirrors are shallow (
--depth 50 --single-branchby default), live under.ddev/claude-code/home/refs/(gitignored, survivesddev restart), and are throwaway: every sync hard-resets them to the remote's current state, so local changes are lost and nothing is ever merged. A directory that drops out of the manifest is left in place with a warning rather than deleted. - An entry whose repo resolves to the project's own
originis skipped, so the project never ends up with a second, stale copy of itself. refs/README.mdis regenerated from the manifest on every sync — descriptions come from the manifest, so the table can't drift from what's on disk.- The bot account's credential file and git config are rewritten from the manifest on every run,
so a renamed bot account heals itself on the next
ddev claudeinstead of failing silently. - Run
ddev claude-refsto sync immediately instead of waiting for the throttle; inside a Claude session, running.ddev/claude-code/refs-sync.sh --forcedoes the same and is already on the permission allowlist. - If
@mcpis set, the MCP server is registered once at user scope (claude mcp add --scope user), and required permission-allowlist entries are added once — both idempotently, so re-runs never duplicate them.
Access control (which repos the mirrors can see) stays entirely in the Git host: the bot account's collaborator access decides what's visible. The manifest only names repos; it grants nothing by itself.
Adopting in a project
-
On the Git host, add the bot account as a read-only collaborator to each sibling repo.
-
Create
.ddev/claude-refs.txtwith@host,@user, and the mirrors you want. It stays local unless yougit add -fit (see above). -
Put the bot's token at
.ddev/claude-code/home/.gitea-ro-token, mode0600(matches the manifest's default@token ~/.gitea-ro-token; never commit it). -
ddev restart, then check.ddev/claude-code/home/refs/.sync.logafter a minute or so. -
Paste this into the project's
CLAUDE.md, adding a sentence about the most relevant sibling:## Sibling repos Read-only shallow clones of sibling projects live in `.ddev/claude-code/home/refs/` (gitignored, declared in `.ddev/claude-refs.txt`). Grep them for prior art before re-deriving a solution; `refs/README.md` says what each one is. They are reference material: never commit or push from them. They sync in the background at most every 6h; run `ddev claude-refs` before trusting anything time-sensitive.
The container still needs to resolve and reach the Git host (and the MCP host, if used).
Installed managed files
.ddev/config.codex-claude.yaml
.ddev/commands/web/codex
.ddev/commands/web/claude
.ddev/commands/web/claude-swap
.ddev/commands/web/claude-refs
.ddev/codex-cli/install.sh
.ddev/claude-code/install.sh
.ddev/claude-code/install-swap.sh
.ddev/claude-code/refs-sync.sh
.ddev/claude-code/git-exclude.sh
Keeping add-on files out of git
Every artifact the add-on drops into a project — .ddev/claude-code/, .ddev/codex-cli/, the
commands/web/* wrappers, .ddev/config.codex-claude.yaml, .ddev/addon-metadata/ddev-codex-claude-cli,
.ddev/plans, and .ddev/claude-refs.txt — should stay out of the project's git history.
.ddev/claude-code/git-exclude.sh maintains a marker-delimited block for this in the project's
.git/info/exclude (not .gitignore): a per-clone file that every git process honours, including
git run from the host, regardless of $HOME or container config. install.sh runs it on every
ddev start and every ddev claude (and the codex installer runs the same script too), so the
block always matches the add-on's current file list — nothing to copy or keep in sync by hand.
Content outside the >>> ddev-codex-claude-cli >>> / <<< ddev-codex-claude-cli <<< markers is
left untouched; only the block between them is rewritten. Personal patterns (.DS_Store, .idea,
CLAUDE.md, …) are not part of this block — they belong in your own ~/.gitignore_global, wired
up via core.excludesfile as described below, since whether a given project tracks its own
CLAUDE.md is not the add-on's call.
- if the container home has
.gitignore_global(typically from host~/.ddev/homeadditions/.gitignore_global), it is copied into each project-local CLIHOMEon first bootstrap; git'score.excludesfileis pointed at that project-local copy — this only affects git run inside the container, which is why the add-on's own artifacts use the.git/info/excludeblock instead - the first
ddev codexin a project can take some time because it downloads the Codex CLI binary; later runs reuse.ddev/codex-cli/bin/codex - the first
ddev startafter installing the add-on can take some time because it downloads Claude Code; later starts reuse.ddev/claude-code/home/.local/bin/claude
Removing the add-on deletes .ddev/codex-cli/ and .ddev/claude-code/ as well (local auth, config, and CLI binaries). Back up those directories first if you need to keep them. The .git/info/exclude block is left in place; an orphaned entry for a path that no longer exists is harmless.
macOS / Linux / WSL2
This add-on runs Codex CLI and Claude Code CLI in the DDEV container, so host OS differences are minimized as long as DDEV works normally.