Files

20 KiB

ddev-claude-cli

ddev-claude-cli is a DDEV add-on that runs 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).

Formerly ddev-codex-claude-cli. See Upgrading from ddev-codex-claude-cli.

What it does

  • exposes ddev claude
  • optionally exposes ddev claude-swap for juggling multiple Claude accounts (opt-in; see Multiple Claude accounts)
  • optionally runs a headless Chromium as a sidecar container and wires it into Claude Code as the playwright MCP server (opt-in; see Headless browser)
  • 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 Claude Code CLI during ddev start when 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 start fast by skipping Claude Code installation when the persisted binary is already runnable
  • persists the CLI's binary, HOME, and XDG paths under project-local storage so each DDEV project has its own profile
  • seeds the project-local HOME from the container home on first bootstrap for .gitignore_global when present
  • keeps its own artifacts out of the project's git via a managed block in .git/info/exclude, refreshed on every ddev start and ddev claude (see Keeping add-on files out of git)

Runtime storage

Inside the web container, Claude Code CLI state lives under:

${DDEV_APPROOT}/.ddev/claude-code

On the host, that is your project's .ddev/claude-code/ directory. It survives ddev restart, ddev stop, and ddev poweroff like any other project file. The same directory holds the add-on's install scripts (managed files) alongside runtime data created after the first start.

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-claude-cli

Then restart:

ddev restart

Usage

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"

ddev claude-browser enable         # opt-in; see "Headless browser"

Updates

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:

  1. Terminal 1: you are in ddev claude, working, and hit (or approach) the limit.
  2. Terminal 2 (on the host): ddev claude-swap switch <n|email|alias> (or ddev claude-swap switch --strategy best).
  3. 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. ddev cswap is a shorter alias for ddev claude-swap (e.g. ddev cswap list).

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.

Headless browser

The add-on can run a headless Chromium next to the web container so Claude can open the site, click through it, fill in forms, read the console and network log, and take screenshots. It uses Microsoft's Playwright MCP server image (mcr.microsoft.com/playwright/mcp) and needs no other add-on. It is opt-in per project, because the extra container costs a few hundred MB of memory while the project runs.

ddev claude-browser enable    # copies the compose file into .ddev/ and restarts
ddev claude-browser status
ddev claude-browser disable   # removes it again and restarts

What enabling does:

  • copies .ddev/claude-code/browser/docker-compose.claude-browser.yaml (the template) to .ddev/docker-compose.claude-browser.yaml, which adds a claude-browser service. The first start builds a small layer on top of the upstream image so the browser runs as your user; later starts reuse it.
  • on that restart, and on every ddev claude, install.sh registers the service with Claude Code at user scope as the MCP server playwright (http://claude-browser:8931/mcp). After disable, the next start removes that registration again, but only if it still points at the add-on's URL. A playwright server you added yourself is never touched (and blocks the add-on's registration; the start-up output says so).

Then, inside ddev claude, ask for things like "open the site and check the login form works". /mcp should list playwright as connected. The first browser call in a session asks for permission as usual; allow mcp__playwright in /permissions if you want it unprompted.

How it is set up:

  • The browser reaches the project at its normal URL (https://<project>.ddev.site) through the DDEV router, and at http://web directly. It ignores certificate errors, since DDEV's mkcert CA is not installed in the container.
  • Each MCP session gets a fresh in-memory profile (--isolated), so several ddev claude sessions can use the browser at once. Logins do not survive the session.
  • The project is mounted at /var/www/html, the same path as in the web container. File uploads and saved screenshots use paths Claude can open directly. Auto-named output goes to .ddev/claude-code/browser-output/, which is gitignored with the rest of .ddev/claude-code/.
  • Port 8931 is only exposed on the project's Docker network, never published to the host.
  • The image is pinned to a tested release. To use another one, set CLAUDE_BROWSER_IMAGE in .ddev/.env, e.g. CLAUDE_BROWSER_IMAGE=mcr.microsoft.com/playwright/mcp:v0.0.84, then ddev restart.
  • While the enabled copy still has its #ddev-generated line, install.sh refreshes it from the template after add-on upgrades and asks you to ddev restart. To customise the service, delete that line from .ddev/docker-compose.claude-browser.yaml. Updates then stop, and ddev claude-browser disable leaves the file for you to delete yourself.

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 start and on every ddev claude, install.sh runs 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 failing ddev start.
  • Mirrors are shallow (--depth 50 --single-branch by default), live under .ddev/claude-code/home/refs/ (gitignored, survives ddev 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 origin is skipped, so the project never ends up with a second, stale copy of itself.
  • refs/README.md is 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 claude instead of failing silently.
  • Run ddev claude-refs to sync immediately instead of waiting for the throttle; inside a Claude session, running .ddev/claude-code/refs-sync.sh --force does the same and is already on the permission allowlist.
  • If @mcp is 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

  1. On the Git host, add the bot account as a read-only collaborator to each sibling repo.

  2. Create .ddev/claude-refs.txt with @host, @user, and the mirrors you want. It stays local unless you git add -f it (see above).

  3. Put the bot's token at .ddev/claude-code/home/.gitea-ro-token, mode 0600 (matches the manifest's default @token ~/.gitea-ro-token; never commit it).

  4. ddev restart, then check .ddev/claude-code/home/refs/.sync.log after a minute or so.

  5. 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).

Hosting the gitea MCP server

@mcp expects an MCP endpoint over HTTP that takes the bot token as Authorization: Bearer on every request. The official gitea-mcp server does this in HTTP mode: it serves POST /mcp and forwards the per-request token to Gitea, so it needs no token of its own and every call runs with exactly the bot's access. A setup next to a docker-compose Gitea behind Caddy:

  1. Add the service to Gitea's docker-compose.yml. Point GITEA_HOST at Gitea's internal address so MCP traffic doesn't loop back out through Caddy, and leave GITEA_ACCESS_TOKEN unset on purpose:

    services:
      gitea-mcp:
        image: docker.gitea.com/gitea-mcp-server:latest   # better: pin a release tag
        restart: unless-stopped
        command: ["/app/gitea-mcp", "-t", "http", "-p", "8080"]
        environment:
          GITEA_HOST: http://gitea:3000   # Gitea's service name and internal port
          GITEA_READONLY: "true"          # hides every write tool
        depends_on:
          - gitea
        # Only if Caddy runs on the host rather than in Docker:
        # ports:
        #   - "127.0.0.1:8080:8080"
    
  2. Add a Caddy site. Without a token, requests would reach Gitea anonymously, so refuse them at the proxy:

    mcp.example.com {
    	@noauth not header Authorization *
    	respond @noauth 401
    
    	reverse_proxy gitea-mcp:8080        # Caddy in Docker, on gitea-mcp's network
    	# reverse_proxy 127.0.0.1:8080      # Caddy on the host
    }
    

    Make sure mcp.example.com resolves from the DDEV container, the same way the Git host does.

  3. Give the bot token at least read:repository and read:issue (PRs count as issues), plus read:user and read:organization if those tools should work.

  4. Check from inside the container (ddev ssh):

    curl -sS -X POST https://mcp.example.com/mcp \
      -H "Authorization: Bearer $(tr -d '[:space:]' < ~/.gitea-ro-token)" \
      -H 'Content-Type: application/json' \
      -H 'Accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
    

    A JSON-RPC result with serverInfo means it works. Then /mcp inside ddev claude should list the server as connected.

The server is registered once and the token is copied into ~/.claude.json at that point. After changing the URL or rotating the token, run claude mcp remove <name> -s user and then .ddev/claude-code/refs-sync.sh --setup-only (or just ddev claude) to register it again.

Installed managed files

.ddev/config.claude-cli.yaml
.ddev/commands/web/claude
.ddev/commands/web/claude-swap
.ddev/commands/web/claude-refs
.ddev/commands/host/claude-browser
.ddev/claude-code/install.sh
.ddev/claude-code/install-swap.sh
.ddev/claude-code/refs-sync.sh
.ddev/claude-code/git-exclude.sh
.ddev/claude-code/browser-setup.sh
.ddev/claude-code/browser/docker-compose.claude-browser.yaml

Plus .ddev/docker-compose.claude-browser.yaml while the headless browser is enabled.

Keeping add-on files out of git

Every artifact the add-on drops into a project — .ddev/claude-code/, the command wrappers, .ddev/config.claude-cli.yaml, .ddev/docker-compose.claude-browser.yaml, .ddev/addon-metadata/ddev-claude-cli, .ddev/plans, .ddev/claude-refs.txt, and the legacy .ddev/codex-cli/ — 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, 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-claude-cli >>> / <<< ddev-claude-cli <<< markers is left untouched; only the block between them is rewritten. A block left by the add-on's former name (ddev-codex-claude-cli) is removed. 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 the project-local HOME on first bootstrap; git's core.excludesfile is 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/exclude block instead
  • the first ddev start after 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/claude-code/ and the browser compose file as well (local auth, config, and CLI binary). Back up that directory 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.

Upgrading from ddev-codex-claude-cli

The add-on used to be called ddev-codex-claude-cli and also shipped Codex CLI (ddev codex). Codex has been removed. To upgrade a project, install the new add-on over the old one:

ddev add-on get /path/to/ddev-claude-cli
ddev restart

Do not run ddev add-on remove ddev-codex-claude-cli first. Its removal actions delete .ddev/claude-code/, including your Claude login, settings, and claude-swap accounts.

Installing ddev-claude-cli takes over .ddev/claude-code/ as it is. It also removes the old add-on's managed files (config.codex-claude.yaml, commands/web/codex, codex-cli/install.sh, each only while still marked #ddev-generated) and its addon-metadata entry, so DDEV no longer lists it. .ddev/codex-cli/, which holds Codex auth and config, is left in place, still gitignored, for you to back up or delete.

macOS / Linux / WSL2

This add-on runs Claude Code CLI in the DDEV container, so host OS differences are minimized as long as DDEV works normally.