Compare commits

...
3 Commits
13 changed files with 348 additions and 169 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {
"version": "1.0.5",
"resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
"integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
// For format details, see https://aka.ms/devcontainer.json.
{
"name": "ddev-claude-cli",
"image": "mcr.microsoft.com/devcontainers/go:2-1.27-bookworm",
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
// DDEV needs a Docker daemon; this runs one inside the devcontainer.
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
// Official DDEV feature: installs ddev (and mkcert) for testing the add-on end to end.
"ghcr.io/ddev/ddev/install-ddev:latest": {}
},
// ShellCheck for the add-on's bash scripts; bats + helpers are what the DDEV add-on template's tests use.
"postCreateCommand": "sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck bats bats-assert bats-support bats-file"
}
+99 -44
View File
@@ -1,45 +1,30 @@
# ddev-codex-claude-cli
# ddev-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).
`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 codex`
- 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 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 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 each CLI's binary, HOME, and XDG paths under **project-local** storage so each DDEV project has its own profiles
- 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))
- 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:
Inside the web container, 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.
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.
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:
Layout:
| Path | Role |
|------|------|
@@ -56,7 +41,7 @@ Authentication and settings are **per project**: sign in once per project; they
From a DDEV project root:
ddev add-on get /path/to/ddev-codex-claude-cli
ddev add-on get /path/to/ddev-claude-cli
Then restart:
@@ -64,10 +49,6 @@ Then restart:
## Usage
ddev codex
ddev codex --version
ddev claude
ddev claude --version
@@ -78,9 +59,9 @@ Then restart:
ddev claude-refs # opt-in; see "Sibling repo mirrors"
## Updates
ddev claude-browser enable # opt-in; see "Headless browser"
Codex CLI is installed lazily when `ddev codex` runs and the persisted binary is missing or broken.
## 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.
@@ -131,6 +112,7 @@ To automate step 2, run `ddev claude-swap auto` in a spare terminal (or an
`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
@@ -155,6 +137,57 @@ The first install needs outbound network access to `astral.sh`, `pypi.org`,
`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
@@ -305,41 +338,63 @@ changing the URL or rotating the token, run `claude mcp remove <name> -s user` a
## Installed managed files
.ddev/config.codex-claude.yaml
.ddev/commands/web/codex
.ddev/config.claude-cli.yaml
.ddev/commands/web/claude
.ddev/commands/web/claude-swap
.ddev/commands/web/claude-refs
.ddev/codex-cli/install.sh
.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/`, `.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.
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` (and the codex installer runs the same script too), so the
`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-codex-claude-cli >>>` / `<<< ddev-codex-claude-cli <<<` markers is
left untouched; only the block between them is rewritten. Personal patterns (`.DS_Store`, `.idea`,
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 each project-local CLI `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 codex` in a project can take some time because it downloads the Codex CLI binary; later runs reuse `.ddev/codex-cli/bin/codex`
- 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/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.
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 Codex CLI and Claude Code CLI in the DDEV container, so host OS differences are minimized as long as DDEV works normally.
This add-on runs Claude Code CLI in the DDEV container, so host OS differences are minimized as long as DDEV works normally.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#ddev-generated
# Keeps Claude Code's MCP config in step with the opt-in headless browser.
#
# Enabled (.ddev/docker-compose.claude-browser.yaml exists): registers the
# "playwright" MCP server at user scope, and refreshes an add-on-managed compose
# copy from the template after an add-on upgrade. Disabled: removes the
# registration, but only if it is still the one this script created.
#
# Called by install.sh with HOME already pointing at the persisted profile. Never
# fatal: a browser problem must not fail "ddev start" or "ddev claude".
set -uo pipefail
DDEV_DIR="${DDEV_APPROOT}/.ddev"
TEMPLATE="${DDEV_DIR}/claude-code/browser/docker-compose.claude-browser.yaml"
ENABLED="${DDEV_DIR}/docker-compose.claude-browser.yaml"
NAME="playwright"
URL="http://claude-browser:8931/mcp"
CLAUDE_JSON="${HOME}/.claude.json"
if ! command -v jq >/dev/null 2>&1; then
[ -f "${ENABLED}" ] && echo "claude-browser: jq missing, skipping MCP registration" >&2
exit 0
fi
# Read ~/.claude.json directly: "claude mcp get" would try to connect.
registered=0
jq -e --arg n "${NAME}" '.mcpServers[$n]' "${CLAUDE_JSON}" >/dev/null 2>&1 && registered=1
current_url="$(jq -r --arg n "${NAME}" '.mcpServers[$n].url // empty' "${CLAUDE_JSON}" 2>/dev/null)"
if [ -f "${ENABLED}" ]; then
if [ -f "${TEMPLATE}" ] && grep -q '#ddev-generated' "${ENABLED}" && ! cmp -s "${TEMPLATE}" "${ENABLED}"; then
cat "${TEMPLATE}" >"${ENABLED}"
echo "claude-browser: refreshed .ddev/docker-compose.claude-browser.yaml from the add-on; run 'ddev restart' to apply" >&2
fi
if [ "${registered}" -eq 0 ]; then
claude mcp add --transport http --scope user "${NAME}" "${URL}" >/dev/null \
&& echo "claude-browser: registered MCP server '${NAME}' (${URL})"
elif [ "${current_url}" != "${URL}" ]; then
echo "claude-browser: an MCP server named '${NAME}' already exists and is not the add-on's; leaving it alone" >&2
fi
elif [ "${registered}" -eq 1 ] && [ "${current_url}" = "${URL}" ]; then
claude mcp remove "${NAME}" -s user >/dev/null \
&& echo "claude-browser: unregistered MCP server '${NAME}'"
fi
exit 0
@@ -0,0 +1,76 @@
#ddev-generated
# Opt-in headless browser for Claude Code: Playwright MCP with headless Chromium.
#
# This is the template. It does nothing where it is: "ddev claude-browser enable"
# copies it to .ddev/docker-compose.claude-browser.yaml and restarts the project, and
# install.sh registers it with Claude Code as the "playwright" MCP server
# (http://claude-browser:8931/mcp). While the copy keeps the #ddev-generated line,
# install.sh refreshes it from this template after add-on upgrades; remove that line
# from the copy to customise it.
#
# Pick another release with CLAUDE_BROWSER_IMAGE in .ddev/.env, e.g.
# CLAUDE_BROWSER_IMAGE=mcr.microsoft.com/playwright/mcp:v0.0.84
services:
claude-browser:
image: ${CLAUDE_BROWSER_IMAGE:-mcr.microsoft.com/playwright/mcp:v0.0.83}-${DDEV_SITENAME}-built
build:
# The upstream image runs as "node" (uid 1000). Turn that user into the host user
# (same name, uid and gid) so screenshots and other files the browser writes into
# the mounted project belong to you. Same approach as ddev-selenium-standalone-chrome.
dockerfile_inline: |
ARG CLAUDE_BROWSER_IMAGE="scratch"
FROM $${CLAUDE_BROWSER_IMAGE}
USER root
ARG username
ARG uid
ARG gid
RUN set -eu; \
old_uid="$$(id -u node)"; old_gid="$$(id -g node)"; \
for u in "$$(getent passwd "$${uid}" | cut -d: -f1)" "$$(getent passwd "$${username}" | cut -d: -f1)"; do \
if [ -n "$${u}" ] && [ "$${u}" != "node" ]; then userdel "$${u}"; fi; \
done; \
for g in "$$(getent group "$${gid}" | cut -d: -f1)" "$$(getent group "$${username}" | cut -d: -f1)"; do \
if [ -n "$${g}" ] && [ "$${g}" != "node" ]; then groupdel "$${g}" || true; fi; \
done; \
groupmod -o -g "$${gid}" node; \
if ! getent group "$${username}" >/dev/null; then groupmod -n "$${username}" node; fi; \
usermod -o -u "$${uid}" -g "$${gid}" node; \
if [ "$${username}" != "node" ]; then usermod -l "$${username}" node; fi; \
find / -xdev -uid "$${old_uid}" -exec chown -h "$${uid}" {} +; \
find / -xdev -gid "$${old_gid}" -exec chgrp -h "$${gid}" {} +
USER $${uid}:$${gid}
args:
CLAUDE_BROWSER_IMAGE: ${CLAUDE_BROWSER_IMAGE:-mcr.microsoft.com/playwright/mcp:v0.0.83}
username: ${DDEV_USER}
uid: ${DDEV_UID}
gid: ${DDEV_GID}
container_name: ddev-${DDEV_SITENAME}-claude-browser
user: "${DDEV_UID}:${DDEV_GID}"
restart: "no"
# Chromium needs more than Docker's 64 MB default /dev/shm for heavier pages.
shm_size: 1gb
# Appended to the image's entrypoint (node cli.js --headless --browser chromium --no-sandbox).
command:
- --port=8931
- --host=0.0.0.0
# The port is only exposed on the project's Docker network, never published to
# the host, so the Host-header check adds nothing here.
- --allowed-hosts=*
# DDEV's mkcert CA is not in this container's trust store.
- --ignore-https-errors
# Fresh in-memory profile per session, so parallel "ddev claude" sessions never
# fight over one profile directory.
- --isolated
# Same path as in the web container, so paths in tool results work for Claude as-is.
- --output-dir=/var/www/html/.ddev/claude-code/browser-output
- --file-paths=absolute
expose:
- 8931
volumes:
# The project at the web container's path: file uploads and saved screenshots
# use paths Claude can read directly.
- "../:/var/www/html"
x-ddev:
describe-info: |
Playwright MCP: http://claude-browser:8931/mcp
Toggle with: ddev claude-browser enable|disable
+22 -10
View File
@@ -12,20 +12,24 @@
# they belong in ~/.gitignore_global, not to this add-on.
set -uo pipefail
BEGIN='# >>> ddev-codex-claude-cli >>>'
END='# <<< ddev-codex-claude-cli <<<'
BEGIN='# >>> ddev-claude-cli >>>'
END='# <<< ddev-claude-cli <<<'
# Block written by this add-on before it was renamed from ddev-codex-claude-cli; removed on sight.
LEGACY_BEGIN='# >>> ddev-codex-claude-cli >>>'
LEGACY_END='# <<< ddev-codex-claude-cli <<<'
PATTERNS=(
'.ddev/addon-metadata/ddev-codex-claude-cli/'
'.ddev/config.codex-claude.yaml'
'.ddev/addon-metadata/ddev-claude-cli/'
'.ddev/config.claude-cli.yaml'
'.ddev/commands/web/claude'
'.ddev/commands/web/claude-swap'
'.ddev/commands/web/claude-refs'
'.ddev/commands/web/codex'
'.ddev/commands/host/claude-browser'
'.ddev/docker-compose.claude-browser.yaml'
'.ddev/claude-code/'
'.ddev/codex-cli/'
'.ddev/claude-refs.txt'
'.ddev/plans/'
'.ddev/codex-cli/' # legacy: left behind by add-on versions that shipped Codex CLI
)
approot="${DDEV_APPROOT:-$(pwd)}"
@@ -50,15 +54,23 @@ block="$(
printf '%s\n' "${END}"
)"
# Existing block replaced in place, otherwise appended after a blank line.
if grep -qF "${BEGIN}" "${exclude}" && grep -qF "${END}" "${exclude}"; then
# Drop the pre-rename block first, then replace the current block in place or
# append it after a blank line.
current="$(awk -v begin="${LEGACY_BEGIN}" -v end="${LEGACY_END}" '
index($0, begin) == 1 { skip = 1; next }
skip && index($0, end) == 1 { skip = 0; eat = 1; next }
eat && $0 == "" { eat = 0; next }
{ eat = 0 }
!skip { print }
' "${exclude}")"
if grep -qF "${BEGIN}" <<<"${current}" && grep -qF "${END}" <<<"${current}"; then
updated="$(awk -v begin="${BEGIN}" -v end="${END}" -v block="${block}" '
index($0, begin) == 1 { print block; skip = 1; next }
skip && index($0, end) == 1 { skip = 0; next }
!skip { print }
' "${exclude}")"
' <<<"${current}")"
else
updated="$(cat "${exclude}"; [ -s "${exclude}" ] && printf '\n'; printf '%s\n' "${block}")"
updated="$(printf '%s' "${current}"; [ -n "${current}" ] && printf '\n\n'; printf '%s\n' "${block}")"
fi
# Only write on a real change, so "ddev start" does not touch the mtime every time.
+6
View File
@@ -80,6 +80,12 @@ if [ -x "${PERSIST_BASE}/git-exclude.sh" ]; then
"${PERSIST_BASE}/git-exclude.sh" || true
fi
# Headless browser, opt-in via "ddev claude-browser enable". Registers or removes the
# Playwright MCP server to match; local and quick, never fatal.
if [ -x "${PERSIST_BASE}/browser-setup.sh" ]; then
"${PERSIST_BASE}/browser-setup.sh" || true
fi
# Sibling-repo mirrors, opt-in via .ddev/claude-refs.txt. Setup is local and quick;
# the sync runs detached so neither ddev start nor ddev claude waits on the network,
# and refs-sync.sh throttles itself.
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
#ddev-generated
set -euo pipefail
# All Codex CLI state is stored under .ddev/codex-cli so it survives container
# rebuilds without being committed to the project repository.
PERSIST_BASE="${DDEV_APPROOT}/.ddev/codex-cli"
PERSIST_HOME="${PERSIST_BASE}/home" # replaces $HOME inside the container
PERSIST_CONFIG_HOME="${PERSIST_BASE}/config" # XDG_CONFIG_HOME (e.g. ~/.config)
PERSIST_DATA_HOME="${PERSIST_BASE}/data" # XDG_DATA_HOME (e.g. ~/.local/share)
PERSIST_STATE_HOME="${PERSIST_BASE}/state" # XDG_STATE_HOME (e.g. ~/.local/state)
PERSIST_BIN_DIR="${PERSIST_BASE}/bin" # directory that holds the codex binary
PERSIST_BIN="${PERSIST_BIN_DIR}/codex" # full path to the codex executable
PERSIST_GITIGNORE_GLOBAL="${PERSIST_HOME}/.gitignore_global" # mirrors $HOME/.gitignore_global
# Capture the container's original HOME before we override it below, so we can
# seed the persisted gitignore from the container image on first run.
CONTAINER_HOME="${HOME:?HOME must be set}"
CONTAINER_GITIGNORE_GLOBAL="${CONTAINER_HOME}/.gitignore_global"
# Ensure all persistence directories exist.
mkdir -p \
"${PERSIST_HOME}" \
"${PERSIST_CONFIG_HOME}" \
"${PERSIST_DATA_HOME}" \
"${PERSIST_STATE_HOME}" \
"${PERSIST_BIN_DIR}"
# Seed the persisted gitignore from the container image on first run only.
if [ -f "${CONTAINER_GITIGNORE_GLOBAL}" ] && [ ! -f "${PERSIST_GITIGNORE_GLOBAL}" ]; then
cp -f "${CONTAINER_GITIGNORE_GLOBAL}" "${PERSIST_GITIGNORE_GLOBAL}"
fi
# Codex stores its standalone package tree under $HOME/.codex and links
# $CODEX_INSTALL_DIR/codex into that tree. Point HOME there before any install
# or health check so package data survives container restarts.
export HOME="${PERSIST_HOME}"
export CODEX_HOME="${PERSIST_HOME}/.codex"
export XDG_CONFIG_HOME="${PERSIST_CONFIG_HOME}"
export XDG_DATA_HOME="${PERSIST_DATA_HOME}"
export XDG_STATE_HOME="${PERSIST_STATE_HOME}"
export PATH="${PERSIST_BIN_DIR}:${HOME}/.local/bin:${PATH}"
# If a persisted binary exists but fails to run (e.g. wrong architecture after
# a host OS change), remove it so the install step below fetches a fresh copy.
if [ -x "${PERSIST_BIN}" ] && ! "${PERSIST_BIN}" --version >/dev/null 2>&1; then
echo "Persisted Codex CLI binary is not runnable, reinstalling ..."
rm -f "${PERSIST_BIN}"
fi
# Download and install the Codex CLI into the persisted bin directory.
if [ ! -x "${PERSIST_BIN}" ]; then
echo "Installing Codex CLI ..."
export CODEX_INSTALL_DIR="${PERSIST_BIN_DIR}"
export CODEX_NON_INTERACTIVE=1
curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh
fi
# Verify the binary is present after installation.
if [ ! -x "${PERSIST_BIN}" ]; then
echo "Codex CLI installation completed but no executable was found at ${PERSIST_BIN}." >&2
exit 1
fi
# Point git's global excludes file at the persisted copy when available,
# so git inside the container respects user-defined ignore patterns.
if command -v git >/dev/null 2>&1 && [ -f "${PERSIST_GITIGNORE_GLOBAL}" ]; then
CURRENT_EXCLUDESFILE="$(git config --global --get core.excludesfile || true)"
if [ "${CURRENT_EXCLUDESFILE}" != "${PERSIST_GITIGNORE_GLOBAL}" ]; then
git config --global core.excludesfile "${PERSIST_GITIGNORE_GLOBAL}"
fi
fi
# Shared with the Claude side: keeps the add-on's artifacts out of the project's git
# even when only the codex installer runs.
GIT_EXCLUDE="${DDEV_APPROOT}/.ddev/claude-code/git-exclude.sh"
if [ -x "${GIT_EXCLUDE}" ]; then
"${GIT_EXCLUDE}" || true
fi
echo "Codex CLI version: $("${PERSIST_BIN}" --version)"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
#ddev-generated
## Description: Turn the headless browser (Playwright MCP) for Claude Code on or off
## Usage: claude-browser [enable|disable|status]
## Example: "ddev claude-browser enable"
set -euo pipefail
TEMPLATE="${DDEV_APPROOT}/.ddev/claude-code/browser/docker-compose.claude-browser.yaml"
TARGET="${DDEV_APPROOT}/.ddev/docker-compose.claude-browser.yaml"
CONTAINER="ddev-${DDEV_SITENAME}-claude-browser"
case "${1:-status}" in
enable|on)
if [ ! -f "${TEMPLATE}" ]; then
echo "Missing ${TEMPLATE}; reinstall the add-on with 'ddev add-on get'." >&2
exit 1
fi
if [ -f "${TARGET}" ]; then
echo "The headless browser is already enabled (.ddev/docker-compose.claude-browser.yaml)."
exit 0
fi
cp "${TEMPLATE}" "${TARGET}"
echo "Enabled the headless browser; restarting so the claude-browser service starts ..."
ddev restart
;;
disable|off)
if [ ! -f "${TARGET}" ]; then
echo "The headless browser is not enabled."
exit 0
fi
if ! grep -q '#ddev-generated' "${TARGET}"; then
echo "${TARGET} has been customised (no #ddev-generated line); delete it yourself and run 'ddev restart'." >&2
exit 1
fi
rm -f "${TARGET}"
echo "Disabled the headless browser; restarting so the claude-browser service stops ..."
ddev restart
;;
status)
if [ -f "${TARGET}" ]; then
state="enabled"
else
state="disabled"
fi
running="unknown"
if command -v docker >/dev/null 2>&1; then
running="$(docker inspect -f '{{.State.Status}}' "${CONTAINER}" 2>/dev/null || echo "not running")"
fi
echo "Headless browser: ${state} (container ${CONTAINER}: ${running})"
;;
*)
echo "Usage: ddev claude-browser [enable|disable|status]" >&2
exit 2
;;
esac
+2 -1
View File
@@ -2,7 +2,8 @@
#ddev-generated
## Description: Manage multiple Claude accounts (claude-swap / cswap)
## Usage: claude-swap [command]
## Example: "ddev claude-swap list"
## Example: "ddev claude-swap list" or "ddev cswap list"
## Aliases: cswap
set -euo pipefail
INSTALL_SCRIPT="${DDEV_APPROOT}/.ddev/claude-code/install-swap.sh"
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
#ddev-generated
## Description: Run Codex CLI inside the web container
## Usage: codex
## Example: "ddev codex --version"
set -euo pipefail
INSTALL_SCRIPT="${DDEV_APPROOT}/.ddev/codex-cli/install.sh"
PERSIST_BASE="${DDEV_APPROOT}/.ddev/codex-cli"
PERSIST_BIN="${PERSIST_BASE}/bin/codex"
"${INSTALL_SCRIPT}"
export HOME="${PERSIST_BASE}/home"
export CODEX_HOME="${PERSIST_BASE}/home/.codex"
export XDG_CONFIG_HOME="${PERSIST_BASE}/config"
export XDG_DATA_HOME="${PERSIST_BASE}/data"
export XDG_STATE_HOME="${PERSIST_BASE}/state"
export PATH="${PERSIST_BASE}/bin:${HOME}/.local/bin:${PATH}"
unset SSH_AUTH_SOCK
cd "${DDEV_APPROOT}"
exec "${PERSIST_BIN}" --sandbox danger-full-access --ask-for-approval on-request "$@"
@@ -6,4 +6,3 @@ webimage_extra_packages:
hooks:
post-start:
- exec: .ddev/claude-code/install.sh
- exec: .ddev/codex-cli/install.sh
+19 -9
View File
@@ -1,28 +1,38 @@
name: ddev-codex-claude-cli
name: ddev-claude-cli
project_files:
- config.codex-claude.yaml
- commands/web/codex
- config.claude-cli.yaml
- commands/web/claude
- commands/web/claude-swap
- commands/web/claude-refs
- codex-cli/install.sh
- commands/host/claude-browser
- claude-code/install.sh
- claude-code/install-swap.sh
- claude-code/refs-sync.sh
- claude-code/git-exclude.sh
- claude-code/browser-setup.sh
- claude-code/browser/docker-compose.claude-browser.yaml
post_install_actions:
- |
#ddev-description: Make AI CLI scripts executable
#ddev-description: Make Claude CLI scripts executable
set -eu
chmod +x commands/web/codex commands/web/claude commands/web/claude-swap commands/web/claude-refs codex-cli/install.sh claude-code/install.sh claude-code/install-swap.sh claude-code/refs-sync.sh claude-code/git-exclude.sh
chmod +x commands/web/claude commands/web/claude-swap commands/web/claude-refs commands/host/claude-browser claude-code/install.sh claude-code/install-swap.sh claude-code/refs-sync.sh claude-code/git-exclude.sh claude-code/browser-setup.sh
- |
#ddev-description: Clean up after ddev-codex-claude-cli, this add-on's former name
# Files are removed only while still marked #ddev-generated. .ddev/claude-code/ is shared
# with the old add-on and kept as is; .ddev/codex-cli/ (Codex auth and config) is kept too.
for f in config.codex-claude.yaml commands/web/codex codex-cli/install.sh; do
if [ -f "$f" ] && grep -q '#ddev-generated' "$f"; then rm -f "$f"; fi
done
# Forget the old add-on, so it is not listed twice and nobody runs its removal
# actions, which would delete .ddev/claude-code/.
rm -rf addon-metadata/ddev-codex-claude-cli
removal_actions:
- |
#ddev-description: Remove AI CLI add-on artifacts
rm -f config.codex-claude.yaml commands/web/codex commands/web/claude commands/web/claude-swap commands/web/claude-refs 2>/dev/null || true
rm -rf codex-cli 2>/dev/null || true
#ddev-description: Remove Claude CLI add-on artifacts
rm -f config.claude-cli.yaml commands/web/claude commands/web/claude-swap commands/web/claude-refs commands/host/claude-browser docker-compose.claude-browser.yaml 2>/dev/null || true
rm -rf claude-code 2>/dev/null || true
ddev_version_constraint: ">= v1.24.3"