From 5c07dd4885467ec519d57444f52ac3e31afbd747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 16 Oct 2026 15:10:26 +0200 Subject: [PATCH] chore: cleanup and gitea mcp integration --- README.md | 127 ++++++++++-- claude-code/git-exclude.sh | 68 ++++++ claude-code/install.sh | 16 ++ claude-code/refs-sync.sh | 195 ++++++++++++++++++ codex-cli/install.sh | 7 + commands/web/claude-refs | 7 + ...laude-cli.yaml => config.codex-claude.yaml | 0 install.yaml | 9 +- 8 files changed, 411 insertions(+), 18 deletions(-) create mode 100755 claude-code/git-exclude.sh create mode 100755 claude-code/refs-sync.sh create mode 100755 commands/web/claude-refs rename config.codex-claude-cli.yaml => config.codex-claude.yaml (100%) diff --git a/README.md b/README.md index d0206f1..fc1379b 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ - 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 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 - 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 @@ -74,6 +76,8 @@ Then restart: 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. @@ -151,34 +155,127 @@ 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. +## 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: +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). + ## Installed managed files - .ddev/config.codex-cli.yaml - .ddev/config.claude-code.yaml + .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 -## Notes +## 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 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` - 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` -- 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 -- if you do not want add-on or CLI runtime files in your repository, ignore them in git, for example: - /.ddev/addon-metadata/ddev-codex-claude-cli - /.ddev/config.codex-cli.yaml - /.ddev/config.claude-code.yaml - /.ddev/commands/web/codex - /.ddev/commands/web/claude - /.ddev/commands/web/claude-swap - /.ddev/codex-cli/ - /.ddev/claude-code/ - -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. +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 diff --git a/claude-code/git-exclude.sh b/claude-code/git-exclude.sh new file mode 100755 index 0000000..49b5a6c --- /dev/null +++ b/claude-code/git-exclude.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +#ddev-generated +# Keep the add-on's own artifacts out of git, in every project it is installed in. +# +# Rewrites a marker-delimited block in the project's .git/info/exclude. That file is +# per-clone and never committed, and every git process honours it -- unlike +# core.excludesfile, which is set on the container's git config only and so does +# nothing for git run from the host. Called by install.sh, which runs on "ddev start" +# and on every "ddev claude", so the list refreshes itself as the add-on changes. +# +# Personal patterns (.DS_Store, .idea, CLAUDE.md, ...) are deliberately NOT here: +# they belong in ~/.gitignore_global, not to this add-on. +set -uo pipefail + +BEGIN='# >>> ddev-codex-claude-cli >>>' +END='# <<< ddev-codex-claude-cli <<<' + +PATTERNS=( + '.ddev/addon-metadata/ddev-codex-claude-cli/' + '.ddev/config.codex-claude.yaml' + '.ddev/commands/web/claude' + '.ddev/commands/web/claude-swap' + '.ddev/commands/web/claude-refs' + '.ddev/commands/web/codex' + '.ddev/claude-code/' + '.ddev/codex-cli/' + '.ddev/claude-refs.txt' + '.ddev/plans/' +) + +approot="${DDEV_APPROOT:-$(pwd)}" + +# --git-common-dir resolves a worktree's .git file to the real directory. Anything +# that is not a git checkout is not an error: ddev start must never fail over this. +gitdir="$(git -C "${approot}" rev-parse --git-common-dir 2>/dev/null)" || exit 0 +[ -n "${gitdir}" ] || exit 0 +case "${gitdir}" in /*) ;; *) gitdir="${approot}/${gitdir}" ;; esac +[ -d "${gitdir}" ] || exit 0 + +info="${gitdir}/info" +exclude="${info}/exclude" +mkdir -p "${info}" 2>/dev/null || exit 0 +[ -e "${exclude}" ] || : >"${exclude}" 2>/dev/null || exit 0 +[ -w "${exclude}" ] || exit 0 + +block="$( + printf '%s\n' "${BEGIN}" + printf '%s\n' "# Managed by .ddev/claude-code/git-exclude.sh. Edits inside this block are lost." + printf '%s\n' "${PATTERNS[@]}" + 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 + 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}")" +else + updated="$(cat "${exclude}"; [ -s "${exclude}" ] && printf '\n'; printf '%s\n' "${block}")" +fi + +# Only write on a real change, so "ddev start" does not touch the mtime every time. +# cat > rather than mv: the file may be bind-mounted. +if [ "${updated}" != "$(cat "${exclude}")" ]; then + printf '%s\n' "${updated}" >"${exclude}" || exit 0 +fi diff --git a/claude-code/install.sh b/claude-code/install.sh index 60596f6..273a871 100755 --- a/claude-code/install.sh +++ b/claude-code/install.sh @@ -73,4 +73,20 @@ if command -v git >/dev/null 2>&1 && [ -f "${PERSIST_GITIGNORE_GLOBAL}" ]; then fi fi +# Keep the add-on's own artifacts out of this project's git, via a managed block in +# .git/info/exclude. Runs every time so the list follows the add-on, not a file that +# was copied once. Never fatal: a project need not be a git checkout. +if [ -x "${PERSIST_BASE}/git-exclude.sh" ]; then + "${PERSIST_BASE}/git-exclude.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. +REFS_SYNC="${PERSIST_BASE}/refs-sync.sh" +if [ -f "${DDEV_APPROOT}/.ddev/claude-refs.txt" ] && [ -x "${REFS_SYNC}" ]; then + "${REFS_SYNC}" --setup-only || echo "claude-refs: setup failed, continuing" >&2 + CLAUDE_REFS_DETACHED=1 setsid nohup "${REFS_SYNC}" >/dev/null 2>&1 &2; exit 2 ;; +esac + +export HOME="${PERSIST_HOME}" +export GIT_TERMINAL_PROMPT=0 + +# --- manifest --------------------------------------------------------------- +host=""; user=""; token="~/.gitea-ro-token"; depth=50; mcp_name=""; mcp_url="" +names=(); repos=(); descs=() +while IFS= read -r line || [ -n "${line}" ]; do + line="${line%$'\r'}" + case "${line}" in ''|'#'*) continue ;; esac + read -r key val rest <<<"${line}" + case "${key}" in + @host) host="${val}" ;; + @user) user="${val}" ;; + @token) token="${val}" ;; + @depth) depth="${val}" ;; + @mcp) mcp_name="${val}"; mcp_url="${rest}" ;; + @*) echo "claude-refs: unknown setting ${key}, ignored" >&2 ;; + *) names+=("${key}"); repos+=("${val}"); descs+=("${rest}") ;; + esac +done <"${MANIFEST}" +token="${token/#\~/${HOME}}" + +if [ -z "${host}" ]; then + echo "claude-refs: no @host in ${MANIFEST}" >&2 + exit 1 +fi + +# --- credentials (every run, so a renamed bot account heals itself) -------- +# Host-scoped on purpose: a global helper would hand the project's own HTTPS +# pushes the bot's pull-only credentials. +creds="${HOME}/.git-credentials-${host}" +if [ -n "${user}" ] && [ -r "${token}" ]; then + (umask 077; printf 'https://%s:%s@%s\n' "${user}" "$(tr -d '[:space:]' <"${token}")" "${host}" >"${creds}") + git config --global "credential.https://${host}.helper" "store --file=${creds}" + git config --global "credential.https://${host}.username" "${user}" +elif [ -n "${user}" ]; then + echo "claude-refs: token ${token} not readable; relying on existing git credentials" >&2 +fi + +# --- setup: permissions and MCP (no network) -------------------------------- +if [ "${MODE}" = setup ]; then + mkdir -p "${REFS}" "${HOME}/.claude" + if ! command -v jq >/dev/null 2>&1; then + echo "claude-refs: jq missing, skipping permission and MCP seeding" >&2 + exit 0 + fi + + settings="${HOME}/.claude/settings.json" + [ -s "${settings}" ] || echo '{}' >"${settings}" + want=("Bash(git -C ${REFS}/*)" "Bash(${SELF})" "Bash(${SELF} --force)") + [ -n "${mcp_name}" ] && want+=("mcp__${mcp_name}") + tmp="$(mktemp)" + # With --args every trailing word is an argument, so the file comes in on stdin. + if jq ' + .permissions.allow = reduce $ARGS.positional[] as $e + (.permissions.allow // []; if any(.[]; . == $e) then . else . + [$e] end) + ' --args "${want[@]}" <"${settings}" >"${tmp}"; then + cat "${tmp}" >"${settings}" + fi + rm -f "${tmp}" + + # Checked against ~/.claude.json rather than "claude mcp get", which connects. + if [ -n "${mcp_name}" ] && [ -n "${mcp_url}" ] && [ -r "${token}" ] \ + && ! jq -e --arg n "${mcp_name}" '.mcpServers[$n]' "${HOME}/.claude.json" >/dev/null 2>&1; then + claude mcp add --transport http --scope user "${mcp_name}" "${mcp_url}" \ + --header "Authorization: Bearer $(tr -d '[:space:]' <"${token}")" + fi + exit 0 +fi + +# --- sync ------------------------------------------------------------------- +mkdir -p "${REFS}" +exec 9>"${REFS}/.sync.lock" +if ! flock -n 9; then + echo "claude-refs: another sync is running" + exit 0 +fi + +if [ "${MODE}" = sync ] && [ -f "${STAMP}" ] \ + && [ $(( $(date +%s) - $(stat -c %Y "${STAMP}") )) -lt "${MAX_AGE}" ]; then + exit 0 +fi + +# Detached runs (ddev start, ddev claude) keep only the latest real sync's output. +[ -n "${CLAUDE_REFS_DETACHED:-}" ] && exec >"${REFS}/.sync.log" 2>&1 +date '+claude-refs sync %F %T' + +if ! curl -sS -o /dev/null --max-time 10 "https://${host}/" 2>/dev/null; then + echo "claude-refs: ${host} unreachable (off-LAN?), mirrors left as they are" + exit 0 +fi + +# git@h:o/r.git, https://u@h/o/r.git -> h/o/r (lowercase), to spot the project itself +normalize() { + local u="$1" + u="${u#*://}"; u="${u#*@}"; u="${u/://}"; u="${u%/}"; u="${u%.git}" + printf '%s' "${u,,}" +} +self="$(normalize "$(git -C "${DDEV_APPROOT}" remote get-url origin 2>/dev/null)")" + +declare -A listed=() +rows=() +failed=0 +for i in "${!names[@]}"; do + name="${names[$i]}"; repo="${repos[$i]}"; dir="${REFS}/${name}" + url="https://${host}/${repo}.git" + + if [ -n "${self}" ] && [ "$(normalize "${url}")" = "${self}" ]; then + printf '== %-16s skipped: this is the project itself, read it live\n' "${name}" + continue + fi + listed[${name}]=1 + rows+=("| \`${name}/\` | \`${repo}\` | ${descs[$i]//|/\\|} |") + + printf '== %-16s ' "${name}" + if [ -d "${dir}/.git" ]; then + if ! timeout "${NET_TIMEOUT}" git -C "${dir}" fetch --depth "${depth}" --quiet origin \ + || ! git -C "${dir}" reset --hard --quiet '@{u}'; then + echo "FAILED (fetch)"; failed=1; continue + fi + elif [ -e "${dir}" ]; then + echo "FAILED (exists but is not a git checkout)"; failed=1; continue + elif ! timeout "${NET_TIMEOUT}" git clone --depth "${depth}" --single-branch --quiet "${url}" "${dir}"; then + rm -rf "${dir}" # did not exist before this run + echo "FAILED (clone)"; failed=1; continue + fi + git -C "${dir}" log -1 --format='%ad %s' --date=short +done + +for dir in "${REFS}"/*/; do + [ -d "${dir}" ] || continue + name="$(basename "${dir}")" + [ -n "${listed[${name}]:-}" ] || echo "!! ${name}/ is not in the manifest; left in place, remove it by hand" +done + +{ + cat < + +Read-only shallow clones of sibling projects on \`${host}\`, kept here so +cross-project questions ("how did we solve X over there?") can be answered by +grepping real code instead of guessing or re-deriving it. + +**These are reference material, not working copies.** Never commit, never push, +never edit. The account behind them (\`${user:-unknown}\`) should have pull access only, +but treat them as read-only regardless. To change one of these projects, open +its real checkout. + +Clones are \`--depth ${depth} --single-branch\` off the default branch. They sync +themselves in the background on \`ddev start\` and \`ddev claude\`, at most every +$(( MAX_AGE / 3600 ))h. Run \`${SELF} --force\` before trusting anything time-sensitive. +Syncing hard-resets, so local changes are lost. The last run's output is in \`.sync.log\`. + +| Directory | Repo | What it is | +|---|---|---| +EOF + printf '%s\n' "${rows[@]}" +} >"${REFS}/README.md" + +if [ "${failed}" -eq 0 ]; then + touch "${STAMP}" +fi +exit "${failed}" diff --git a/codex-cli/install.sh b/codex-cli/install.sh index 3eccf5a..75799b9 100755 --- a/codex-cli/install.sh +++ b/codex-cli/install.sh @@ -71,4 +71,11 @@ if command -v git >/dev/null 2>&1 && [ -f "${PERSIST_GITIGNORE_GLOBAL}" ]; then 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)" diff --git a/commands/web/claude-refs b/commands/web/claude-refs new file mode 100755 index 0000000..06cfb93 --- /dev/null +++ b/commands/web/claude-refs @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#ddev-generated +## Description: Sync the read-only sibling-repo mirrors declared in .ddev/claude-refs.txt now +## Usage: claude-refs +## Example: "ddev claude-refs" +set -euo pipefail +exec "${DDEV_APPROOT}/.ddev/claude-code/refs-sync.sh" --force diff --git a/config.codex-claude-cli.yaml b/config.codex-claude.yaml similarity index 100% rename from config.codex-claude-cli.yaml rename to config.codex-claude.yaml diff --git a/install.yaml b/install.yaml index a663515..813be6f 100644 --- a/install.yaml +++ b/install.yaml @@ -1,24 +1,27 @@ name: ddev-codex-claude-cli project_files: - - config.codex-claude-cli.yaml + - config.codex-claude.yaml - 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 post_install_actions: - | #ddev-description: Make AI CLI scripts executable set -eu - chmod +x commands/web/codex commands/web/claude commands/web/claude-swap codex-cli/install.sh claude-code/install.sh claude-code/install-swap.sh + 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 removal_actions: - | #ddev-description: Remove AI CLI add-on artifacts - rm -f config.codex-claude-cli.yaml commands/web/codex commands/web/claude commands/web/claude-swap 2>/dev/null || true + 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 rm -rf claude-code 2>/dev/null || true