401 lines
20 KiB
Markdown
401 lines
20 KiB
Markdown
# 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](#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](#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](#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](#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](#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`](https://github.com/realiti4/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](https://github.com/microsoft/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](#keeping-add-on-files-out-of-git) 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:
|
|
|
|
```markdown
|
|
## 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](https://gitea.com/gitea/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:
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```caddy
|
|
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`):
|
|
|
|
```bash
|
|
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](#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.
|