diff --git a/README.md b/README.md
index 5d22bab..f7317cd 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,110 @@ A local proxy that lets **Claude Code** talk to **OpenCode Zen / Go / Free**
backends. It speaks the Anthropic Messages API on the client side and
translates to the backend's native format (`anthropic`, `oa-compat`/OpenAI
chat-completions, `openai`/Responses, `google`/Gemini) on the upstream side —
-with streaming, model discovery, and capability-aware request stripping.
+with streaming, model discovery, reasoning passthrough, and capability-aware
+request stripping.
+
+## Install
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/schiz0x00/claude-opencode-proxy/main/install.sh | bash
+```
+
+Then open a new shell and run `claude-oc`.
+
+That single command installs Docker if it is missing, pulls the published
+container, starts it on `127.0.0.1:8787`, and adds a `claude-oc` function to
+your shell. It is safe to re-run — it upgrades the image and rewrites its own
+managed block in your rc files, touching nothing else.
+
+> **Read before piping to a shell.** `curl | bash` executes code from the
+> internet as you. Review [`install.sh`](install.sh) first, or download it,
+> read it, and run it locally. Docker installation needs `sudo`; the script
+> asks before doing that.
+
+With an API key (paid lanes), or to change any default:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/schiz0x00/claude-opencode-proxy/main/install.sh -o install.sh
+bash install.sh --zen-key sk-... # Zen lane
+bash install.sh --go-key sk-... --port 9000 # Go lane on another port
+bash install.sh --dry-run # show what it would do
+bash install.sh --uninstall # remove container + shell wiring
+```
+
+
+Installer options
+
+| Flag | Env | Default | Purpose |
+| :-- | :-- | :-- | :-- |
+| `--port ` | `COP_PORT` | `8787` | Host port (bound to `127.0.0.1` only) |
+| `--model ` | `COP_MODEL` | `deepseek-v4-flash-free` | Default model for `claude-oc` |
+| `--backend ` | `OPENCODE_BACKEND` | auto | `zen` \| `go` \| `free` |
+| `--zen-key ` | `OPENCODE_ZEN_API_KEY` | — | Zen key (implies `--backend zen`) |
+| `--go-key ` | `OPENCODE_GO_API_KEY` | — | Go key (implies `--backend go`) |
+| `--image [` | `COP_IMAGE` | `ghcr.io/schiz0x00/claude-opencode-proxy:latest` | Image to run |
+| `--container ` | `COP_CONTAINER` | `claude-opencode-proxy` | Container name |
+| `--skip-docker` | — | — | Never install Docker; fail if absent |
+| `--dry-run` | — | — | Print actions, change nothing |
+| `--uninstall` | — | — | Remove container, env file, rc blocks |
+| `-y`, `--yes` | — | — | No prompts (needed for unattended install) |
+
+]
+
+### What the installer changes
+
+- **Container** `claude-opencode-proxy`, `--restart unless-stopped`, published
+ to `127.0.0.1:8787` only (never `0.0.0.0` — the proxy has no auth of its own).
+- **`~/.claude-opencode-proxy/env.sh`** (mode `600`) with the shell functions.
+- **`~/.bashrc`, `~/.bash_profile`, `~/.zshrc`** — whichever exist get a
+ three-line block between `# >>> claude-opencode-proxy >>>` markers that
+ sources the env file. Both bash and zsh are supported by the same file.
+
+### Shell commands
+
+| Command | Does |
+| :-- | :-- |
+| `claude-oc` | Claude Code through the proxy (takes all `claude` args, e.g. `claude-oc -p "hi"`) |
+| `claude-oc-models` | List models the proxy serves |
+| `claude-oc-logs` | Follow container logs |
+| `claude-oc-restart` / `-stop` / `-start` | Container control |
+| `claude-oc-update` | Pull the latest image and recreate the container |
+
+Override per run: `CLAUDE_OC_MODEL=glm-5-free claude-oc`, or
+`CLAUDE_OC_PORT=9000 claude-oc`.
+
+## Manual setup
+
+
+Docker without the installer
+
+```bash
+docker run -d --name claude-opencode-proxy --restart unless-stopped \
+ -p 127.0.0.1:8787:8787 \
+ -e OPENCODE_BACKEND=zen \
+ -e OPENCODE_ZEN_API_KEY=... \
+ ghcr.io/schiz0x00/claude-opencode-proxy:latest
+```
+
+Build locally instead:
+
+```bash
+docker build -t claude-opencode-proxy .
+docker run --rm -p 127.0.0.1:8787:8787 claude-opencode-proxy
+```
+
+
+
+
+From source (Node.js ≥ 20, LTS 24 recommended)
+
+```bash
+npm install
+npm run build
+npm start # or: npm run dev (tsx watch)
+```
+
+
## Features
@@ -20,27 +123,16 @@ with streaming, model discovery, and capability-aware request stripping.
`anthropic`, `oa-compat`, `openai`, `google`.
- **Streaming** with 30 s keep-alive pings, verbatim error passthrough, and
`message_stop` termination (Claude Code's retry logic works).
-- **Model registry** with live discovery (`GET /v1/models` on the backend),
- catalog metadata, and a local cache (`~/.claude-opencode-proxy/models.json`).
-- **Capability detection** — strips `thinking`, tools, `cache_control`,
- image blocks, etc. when the model doesn't support them.
+- **Thinking / reasoning passthrough** — upstream `reasoning_content` becomes
+ Anthropic `thinking` blocks and is echoed back on the next turn.
+- **Reasoning effort** mapped from Claude Code's thinking budget onto whatever
+ knob each model actually accepts, read from the upstream catalog.
+- **Model registry** with live discovery, catalog metadata, and a local cache
+ (`~/.claude-opencode-proxy/models.json`).
+- **Capability detection** — strips `thinking`, tools, `cache_control`, image
+ blocks, etc. when the model doesn't support them.
- **Retries** on transient network errors / 5xx / 429 (never 4xx).
- **Optional cost pings** (`OPENCODE_EMIT_COST_PINGS=1`).
-- **CORS** enabled for browser-based clients.
-
-## Requirements
-
-- Node.js ≥ 20 (LTS 24 recommended)
-
-## Quick start
-
-```bash
-npm install
-npm run build
-npm start
-```
-
-The proxy listens on `http://127.0.0.1:8787` by default.
## Configuration
@@ -52,7 +144,7 @@ All configuration is via environment variables (see `src/config.ts`).
| `OPENCODE_ZEN_API_KEY` | — | Zen API key (also sent as `x-api-key` upstream) |
| `OPENCODE_GO_API_KEY` | — | Go API key |
| `OPENCODE_BASE_URL` | `https://opencode.ai/zen/v1` | Upstream base URL override |
-| `OPENCODE_HOST` | `127.0.0.1` | Listen host |
+| `OPENCODE_HOST` | `127.0.0.1` | Listen host (the container sets `0.0.0.0`) |
| `OPENCODE_PORT` | `8787` | Listen port |
| `OPENCODE_LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` |
| `OPENCODE_REQUEST_TIMEOUT_MS` | `600000` | Timeout for the upstream response |
@@ -65,14 +157,30 @@ All configuration is via environment variables (see `src/config.ts`).
## Using with Claude Code
-Point Claude Code at the proxy and give it a credential:
+The installer's `claude-oc` sets all of this for you. To wire it up by hand:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
-export ANTHROPIC_AUTH_TOKEN= # or ANTHROPIC_API_KEY
+export ANTHROPIC_AUTH_TOKEN=opencode-free # any non-empty value on the free lane
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
+
+# Keep background calls (titles, summaries) on a model the proxy serves —
+# otherwise they go out as claude-sonnet-* / claude-3-5-haiku and 404.
+export ANTHROPIC_MODEL=deepseek-v4-flash-free
+export ANTHROPIC_SMALL_FAST_MODEL=deepseek-v4-flash-free
+export ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash-free
+export ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-flash-free
+export ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-flash-free
+export ANTHROPIC_DEFAULT_FABLE_MODEL=deepseek-v4-flash-free
+
+# Claude Code doesn't know these models' context windows and assumes 200k.
+export CLAUDE_CODE_MAX_CONTEXT_TOKENS=200000
```
+`ANTHROPIC_AUTH_TOKEN` is required **even on the free lane**. Without a
+credential Claude Code falls back to its own login and skips gateway model
+discovery entirely.
+
Optional hardening for strict backends:
```bash
@@ -83,49 +191,140 @@ export CLAUDE_CODE_ATTRIBUTION_HEADER=0
### Model picker
-With `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` and a credential set, the
-proxy's `GET /v1/models` populates Claude Code's `/model` picker with
-**"From gateway"** rows. Each backend model is exposed under an alias id
-(`claude-ocx---`) with `display_name` set to the real OpenCode
-name; the proxy maps the alias back to the real id on inference. Models with a
-1M context window get an extra `[1m]` variant row.
+With discovery enabled, `GET /v1/models` populates Claude Code's `/model`
+picker with a **"From gateway"** section. Each backend model is exposed under
+an alias id (`claude-ocx---`) with `display_name` set to the
+real OpenCode name; the proxy rewrites the alias back to the real id before
+forwarding. Models with a 1M context window get an extra `[1m]` row.
+
+Claude's own models still appear alongside the gateway ones — discovery adds a
+section, it does not replace the built-in list.
-Without discovery, you can pin models directly:
+Without discovery, pin models directly:
```bash
export ANTHROPIC_CUSTOM_MODEL_OPTION=deepseek-v4-flash-free
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME='DeepSeek V4 Flash (Free)'
-export ANTHROPIC_DEFAULT_SONNET_MODEL=mimo-v2.5-free
```
-## Docker
+### Thinking and reasoning effort
-Prebuilt image (published on every merge to `main`):
+Claude Code expresses reasoning effort only as `thinking.budget_tokens`
+(`think` ≈ 4k, `megathink` ≈ 10k, `ultrathink` ≈ 32k). Non-Anthropic backends
+want a different knob, and which knob varies per model. The proxy reads
+`reasoning_options` from the OpenCode catalog and maps the budget onto what
+each model actually accepts:
-```bash
-docker run --rm -p 8787:8787 \
- -e OPENCODE_BACKEND=zen \
- -e OPENCODE_ZEN_API_KEY=... \
- ghcr.io/schiz0x00/claude-opencode-proxy:latest
-```
+| Catalog advertises | Proxy sends |
+| :-- | :-- |
+| `budget_tokens` | `thinking.budget_tokens`, clamped to the documented min/max |
+| `effort` | `reasoning_effort`, bucketed onto that model's own ladder |
+| `toggle` only | `thinking: {type: "enabled"}` |
+| nothing | nothing — the field never reaches the backend |
-Or build locally:
+So `deepseek-v4-flash-free` (which advertises `low`/`high`/`max`) receives
+`low` / `high` / `max` for the three tiers, while `laguna-s-2.1-free` receives
+`low` / `medium` / `high`. `none` and `minimal` are never selected for an
+enabled thinking block.
-```bash
-docker build -t claude-opencode-proxy .
-docker run --rm -p 8787:8787 \
- -e OPENCODE_BACKEND=zen \
- -e OPENCODE_ZEN_API_KEY=... \
- claude-opencode-proxy
-```
+In the other direction, upstream `reasoning_content` is streamed to the client
+as Anthropic `thinking` blocks and translated back to `reasoning_content` when
+the client echoes it on the next turn — required by providers that reject a
+follow-up turn whose assistant message lost its reasoning trace.
-## Development
+## Troubleshooting
-```bash
-npm run typecheck # tsc --noEmit
-npm test # vitest run
-npm run dev # tsx watch src/index.ts
-```
+
+404 Unknown model: claude-sonnet-5 (or claude-3-5-haiku-*)
+
+Claude Code is making background calls with its built-in model ids, which the
+proxy does not serve. Set the `ANTHROPIC_DEFAULT_*_MODEL` and
+`ANTHROPIC_SMALL_FAST_MODEL` variables above (`claude-oc` does this already).
+Left unset, those calls 404 and get retried, adding latency to every turn.
+
+
+
+
+401 / Model claude-ocx-... is not supported
+
+The alias id reached the backend instead of the real model id. Fixed in
+current versions — update with `claude-oc-update`.
+
+
+
+
+Responses are slow (10–40 s), but fast in the OpenCode TUI
+
+Not the proxy. The **free lane is queued** and has a fat latency tail —
+measured directly against the upstream, bypassing the proxy entirely, the same
+5-token prompt returns in anywhere from 0.6 s to 23 s, with roughly 40% of
+requests landing in a 12–20 s queue. `curl` over HTTP/2 and Node over HTTP/1.1
+show the same spread, so it is not a client or transport issue.
+
+The reason it feels worse in Claude Code than in the OpenCode TUI is that one
+Claude Code turn is several sequential upstream calls (background work, the
+tool loop, then the answer), and each call draws again from that distribution.
+The TUI sends one. Three serial draws is where a 40 s turn comes from.
+
+What actually helps: set the `ANTHROPIC_DEFAULT_*`/`SMALL_FAST` vars so no call
+is wasted on a 404-and-retry, or use a paid lane with `--zen-key`.
+
+
+
+
+The TUI output is jumbled — one word per line
+
+Older versions emitted a separate content block per streamed token. Fixed —
+update with `claude-oc-update`.
+
+
+
+
+parse error near `()' after installing
+
+Your current shell still holds an alias with the same name as one of the
+installed functions; zsh expands it while parsing the function definition. The
+env file guards against this with `unalias`, but a shell that sourced an older
+rc first can still trip. Open a new shell, or run `unalias claude-oc` and
+re-source.
+
+
+
+
+claude.ai connectors are disabled because ANTHROPIC_API_KEY … is set
+
+Expected and harmless. Pointing Claude Code at any gateway means the auth
+token displaces your claude.ai login for connector purposes.
+
+
+
+
+"…" is not a model this version of Claude Code recognizes
+
+Claude Code doesn't know the context window of third-party models and assumes
+200k for auto-compact. Set `CLAUDE_CODE_MAX_CONTEXT_TOKENS` to the real window
+(`claude-oc` sets 200000, which is correct for `deepseek-v4-flash-free`), or
+append `[1m]` to the model name for 1M-context models.
+
+
+
+
+Docker permission denied after install
+
+The installer adds you to the `docker` group, which only takes effect on your
+next login. Log out and back in, or use `newgrp docker` in the current shell.
+
+
+
+## Privacy
+
+The **free lane retains prompts** and may use them to improve models — the
+proxy logs a warning about this at startup. Do not send confidential data
+through `OPENCODE_BACKEND=free`. Paid lanes (`zen`, `go`) follow OpenCode's
+own terms.
+
+The proxy binds to `127.0.0.1` and has no authentication of its own; anything
+that can reach the port can use your key. Do not expose it publicly.
## Architecture
@@ -142,7 +341,15 @@ Claude Code ── Anthropic Messages API ──▶ proxy ──▶ OpenCode bac
- `src/translate/` — per-format helpers and canonical-IR converters
- `src/stream.ts` — SSE pump (keep-alive, error passthrough, cost pings)
- `src/modelRegistry.ts` — static + discovered + catalog model metadata
-- `src/capability.ts` — capability-aware request stripping
+- `src/capability.ts` — capability stripping and reasoning-effort mapping
+
+## Development
+
+```bash
+npm run typecheck # tsc --noEmit
+npm test # vitest run
+npm run dev # tsx watch src/index.ts
+```
## Contributing
@@ -151,4 +358,4 @@ PRs against `dev`; merges to `main` publish the container image.
## License
-[MIT](LICENSE)
\ No newline at end of file
+[MIT](LICENSE)
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..b5a7633
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,383 @@
+#!/usr/bin/env bash
+# claude-opencode-proxy installer.
+#
+# curl -fsSL https://raw.githubusercontent.com/schiz0x00/claude-opencode-proxy/main/install.sh | bash
+#
+# Installs Docker (if missing), pulls the published container, runs it, and
+# wires up a `claude-oc` shell function for bash and zsh.
+#
+# Everything is idempotent: re-running upgrades the image, recreates the
+# container, and rewrites the managed block in your shell rc files.
+
+set -euo pipefail
+
+IMAGE="${COP_IMAGE:-ghcr.io/schiz0x00/claude-opencode-proxy:latest}"
+CONTAINER="${COP_CONTAINER:-claude-opencode-proxy}"
+PORT="${COP_PORT:-8787}"
+MODEL="${COP_MODEL:-deepseek-v4-flash-free}"
+CONTEXT="${COP_CONTEXT:-200000}"
+HOME_DIR="${HOME}/.claude-opencode-proxy"
+ENV_FILE="${HOME_DIR}/env.sh"
+BEGIN_MARK="# >>> claude-opencode-proxy >>>"
+END_MARK="# <<< claude-opencode-proxy <<<"
+
+# Backend selection: a key implies a paid lane, otherwise the free lane.
+BACKEND="${OPENCODE_BACKEND:-}"
+ZEN_KEY="${OPENCODE_ZEN_API_KEY:-}"
+GO_KEY="${OPENCODE_GO_API_KEY:-}"
+
+ASSUME_YES=0
+SKIP_DOCKER_INSTALL=0
+DRY_RUN=0
+UNINSTALL=0
+
+# --- output ------------------------------------------------------------------
+
+if [ -t 1 ]; then
+ B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; N=$'\033[0m'
+else
+ B=""; G=""; Y=""; R=""; N=""
+fi
+
+say() { printf '%s==>%s %s\n' "$G" "$N" "$*"; }
+warn() { printf '%s warn%s %s\n' "$Y" "$N" "$*" >&2; }
+die() { printf '%serror%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
+run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; }
+
+usage() {
+ cat < Host port for the proxy (default: ${PORT})
+ --model Default model for the shell function (default: ${MODEL})
+ --backend zen | go | free (default: auto — key present means paid)
+ --zen-key OpenCode Zen API key (implies --backend zen)
+ --go-key OpenCode Go API key (implies --backend go)
+ --image [ Container image (default: ${IMAGE})
+ --uninstall Remove the container, the env file, and the rc blocks
+ --skip-docker Never try to install Docker; fail if it is missing
+ --dry-run Print what would happen, change nothing
+ -y, --yes Do not prompt (required for unattended Docker install)
+ -h, --help This message
+
+Environment equivalents: COP_PORT, COP_MODEL, COP_IMAGE, COP_CONTAINER,
+COP_CONTEXT, OPENCODE_BACKEND, OPENCODE_ZEN_API_KEY, OPENCODE_GO_API_KEY.
+EOF
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --port) PORT="${2:?--port needs a value}"; shift 2 ;;
+ --model) MODEL="${2:?--model needs a value}"; shift 2 ;;
+ --backend) BACKEND="${2:?--backend needs a value}"; shift 2 ;;
+ --zen-key) ZEN_KEY="${2:?--zen-key needs a value}"; BACKEND="${BACKEND:-zen}"; shift 2 ;;
+ --go-key) GO_KEY="${2:?--go-key needs a value}"; BACKEND="${BACKEND:-go}"; shift 2 ;;
+ --image) IMAGE="${2:?--image needs a value}"; shift 2 ;;
+ --container) CONTAINER="${2:?--container needs a value}"; shift 2 ;;
+ --uninstall) UNINSTALL=1; shift ;;
+ --skip-docker) SKIP_DOCKER_INSTALL=1; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ -y|--yes) ASSUME_YES=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) die "unknown option: $1 (try --help)" ;;
+ esac
+done
+
+if [ -z "$BACKEND" ]; then
+ if [ -n "$GO_KEY" ]; then BACKEND="go"
+ elif [ -n "$ZEN_KEY" ]; then BACKEND="zen"
+ else BACKEND="free"; fi
+fi
+
+# Piped into bash (`curl ... | bash`) there is no stdin to prompt on, so read
+# answers from the terminal directly when one exists.
+confirm() {
+ [ "$ASSUME_YES" = 1 ] && return 0
+ local reply=""
+ if [ -r /dev/tty ]; then
+ printf '%s [y/N] ' "$1" > /dev/tty
+ read -r reply < /dev/tty || reply=""
+ else
+ return 1
+ fi
+ case "$reply" in [yY]*) return 0 ;; *) return 1 ;; esac
+}
+
+# --- docker ------------------------------------------------------------------
+
+SUDO=""
+need_sudo() {
+ if [ "$(id -u)" = 0 ]; then SUDO=""; return 0; fi
+ command -v sudo >/dev/null 2>&1 || die "need root or sudo for this step"
+ SUDO="sudo"
+}
+
+install_docker_linux() {
+ say "installing Docker via the official convenience script (get.docker.com)"
+ need_sudo
+ local tmp
+ tmp="$(mktemp)"
+ curl -fsSL https://get.docker.com -o "$tmp" || die "could not download the Docker install script"
+ run $SUDO sh "$tmp"
+ rm -f "$tmp"
+ # systemd hosts need the daemon enabled; containers/WSL without systemd do not.
+ if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
+ run $SUDO systemctl enable --now docker || warn "could not enable the docker service"
+ fi
+ if [ "$(id -u)" != 0 ]; then
+ run $SUDO usermod -aG docker "$USER" || warn "could not add $USER to the docker group"
+ warn "added $USER to the 'docker' group — log out and back in for it to apply"
+ warn "this run will keep using sudo for docker commands"
+ fi
+}
+
+ensure_docker() {
+ if command -v docker >/dev/null 2>&1; then
+ say "Docker found: $(docker --version 2>/dev/null || echo unknown)"
+ else
+ [ "$SKIP_DOCKER_INSTALL" = 1 ] && die "Docker is not installed (--skip-docker was given)"
+ case "$(uname -s)" in
+ Linux)
+ confirm "Docker is not installed. Install it now (runs get.docker.com with sudo)?" \
+ || die "Docker is required — install it and re-run"
+ install_docker_linux
+ ;;
+ Darwin)
+ die "Docker is not installed. On macOS install Docker Desktop first:
+ brew install --cask docker (then launch Docker.app)
+Re-run this installer afterwards."
+ ;;
+ *)
+ die "unsupported platform $(uname -s) — install Docker manually and re-run"
+ ;;
+ esac
+ fi
+
+ # The daemon must actually answer, and we may need sudo to reach its socket.
+ if ! docker info >/dev/null 2>&1; then
+ if command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then
+ SUDO="sudo"
+ warn "using sudo for docker (your user is not in the 'docker' group yet)"
+ elif [ "$DRY_RUN" = 1 ]; then
+ warn "docker daemon not reachable (ignored for --dry-run)"
+ else
+ die "the Docker daemon is not reachable. Start it (e.g. 'sudo systemctl start docker'),
+or if you were just added to the 'docker' group, log out and back in."
+ fi
+ fi
+}
+
+DOCKER() { run $SUDO docker "$@"; }
+
+# --- container ---------------------------------------------------------------
+
+start_container() {
+ say "pulling ${IMAGE}"
+ DOCKER pull "$IMAGE" || die "could not pull ${IMAGE}"
+
+ # Only ever touch a container with our own name.
+ if [ "$DRY_RUN" != 1 ] && $SUDO docker inspect "$CONTAINER" >/dev/null 2>&1; then
+ say "replacing the existing '${CONTAINER}' container"
+ DOCKER rm -f "$CONTAINER" >/dev/null
+ fi
+
+ local args=(
+ run -d
+ --name "$CONTAINER"
+ --restart unless-stopped
+ -p "127.0.0.1:${PORT}:8787"
+ -e "OPENCODE_BACKEND=${BACKEND}"
+ )
+ [ -n "$ZEN_KEY" ] && args+=(-e "OPENCODE_ZEN_API_KEY=${ZEN_KEY}")
+ [ -n "$GO_KEY" ] && args+=(-e "OPENCODE_GO_API_KEY=${GO_KEY}")
+ args+=("$IMAGE")
+
+ say "starting '${CONTAINER}' on 127.0.0.1:${PORT} (backend: ${BACKEND})"
+ DOCKER "${args[@]}" >/dev/null || die "could not start the container"
+
+ [ "$DRY_RUN" = 1 ] && return 0
+
+ # Wait for the model registry to come up rather than declaring success early.
+ local _attempt
+ for _attempt in $(seq 1 30); do
+ if curl -fsS -m 2 "http://127.0.0.1:${PORT}/v1/models" >/dev/null 2>&1; then
+ local count
+ count="$(curl -fsS -m 5 "http://127.0.0.1:${PORT}/v1/models" \
+ | tr ',' '\n' | grep -c '"object":"model"' || true)"
+ say "proxy is up — ${count:-?} models available"
+ return 0
+ fi
+ sleep 1
+ done
+ warn "the container started but did not answer on port ${PORT} within 30s"
+ warn "check it with: docker logs ${CONTAINER}"
+}
+
+# --- shell integration -------------------------------------------------------
+
+write_env_file() {
+ say "writing ${ENV_FILE}"
+ [ "$DRY_RUN" = 1 ] && return 0
+ mkdir -p "$HOME_DIR"
+ cat > "$ENV_FILE" </dev/null || true
+
+# Run Claude Code against the local proxy.
+# claude-oc interactive
+# claude-oc -p "hi" one-shot
+# CLAUDE_OC_MODEL=glm-5-free claude-oc
+claude-oc() {
+ local model="\${CLAUDE_OC_MODEL}"
+ # ANTHROPIC_AUTH_TOKEN must be set even on the free lane: without a
+ # credential Claude Code uses its own login and skips gateway discovery.
+ # The DEFAULT_*/SMALL_FAST vars keep background calls (titles, summaries)
+ # on a model the proxy serves — otherwise they go out as claude-sonnet-*
+ # or claude-3-5-haiku and 404.
+ ANTHROPIC_BASE_URL="http://127.0.0.1:\${CLAUDE_OC_PORT}" \\
+ ANTHROPIC_AUTH_TOKEN="\${OPENCODE_ZEN_API_KEY:-\${OPENCODE_GO_API_KEY:-opencode-free}}" \\
+ ANTHROPIC_MODEL="\$model" \\
+ ANTHROPIC_SMALL_FAST_MODEL="\$model" \\
+ ANTHROPIC_DEFAULT_HAIKU_MODEL="\$model" \\
+ ANTHROPIC_DEFAULT_SONNET_MODEL="\$model" \\
+ ANTHROPIC_DEFAULT_OPUS_MODEL="\$model" \\
+ ANTHROPIC_DEFAULT_FABLE_MODEL="\$model" \\
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS="\${CLAUDE_OC_CONTEXT}" \\
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \\
+ claude "\$@"
+}
+
+# List the models the proxy is serving.
+claude-oc-models() {
+ curl -fsS "http://127.0.0.1:\${CLAUDE_OC_PORT}/v1/models" \\
+ | tr '{' '\\n' | sed -n 's/.*"id":"\\([^"]*\\)".*"display_name":"\\([^"]*\\)".*/\\2\\t\\1/p'
+}
+
+# Container controls.
+claude-oc-logs() { docker logs -f ${CONTAINER}; }
+claude-oc-restart() { docker restart ${CONTAINER}; }
+claude-oc-stop() { docker stop ${CONTAINER}; }
+claude-oc-start() { docker start ${CONTAINER}; }
+claude-oc-update() {
+ docker pull ${IMAGE} && docker rm -f ${CONTAINER} >/dev/null 2>&1
+ docker run -d --name ${CONTAINER} --restart unless-stopped \\
+ -p 127.0.0.1:\${CLAUDE_OC_PORT}:8787 -e OPENCODE_BACKEND=${BACKEND} ${IMAGE}
+}
+EOF
+ chmod 600 "$ENV_FILE"
+}
+
+# Replace the managed block in an rc file, or append it if absent. Never
+# touches anything outside the markers.
+wire_rc() {
+ local rc="$1"
+ [ -e "$rc" ] || { [ "$DRY_RUN" = 1 ] && return 0; touch "$rc"; }
+
+ if [ "$DRY_RUN" = 1 ]; then
+ say "would wire ${rc}"
+ return 0
+ fi
+
+ if grep -qF "$BEGIN_MARK" "$rc" 2>/dev/null; then
+ local tmp
+ tmp="$(mktemp)"
+ # Drop the old managed block, keep everything else byte-for-byte.
+ awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
+ index($0, b) { skip = 1 }
+ !skip { print }
+ index($0, e) { skip = 0 }
+ ' "$rc" > "$tmp"
+ cat "$tmp" > "$rc"
+ rm -f "$tmp"
+ fi
+
+ {
+ printf '%s\n' "$BEGIN_MARK"
+ printf '[ -f "%s" ] && . "%s"\n' "$ENV_FILE" "$ENV_FILE"
+ printf '%s\n' "$END_MARK"
+ } >> "$rc"
+ say "wired ${rc}"
+}
+
+wire_shells() {
+ local wired=0
+ # bash reads .bash_profile (login) or .bashrc (interactive); on macOS the
+ # login file is the one that matters, so wire whichever already exist.
+ for rc in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc"; do
+ if [ -e "$rc" ]; then wire_rc "$rc"; wired=1; fi
+ done
+ if [ "$wired" = 0 ]; then
+ # No rc file at all: create the one matching the login shell.
+ case "${SHELL:-}" in
+ *zsh) wire_rc "$HOME/.zshrc" ;;
+ *) wire_rc "$HOME/.bashrc" ;;
+ esac
+ fi
+}
+
+# --- main --------------------------------------------------------------------
+
+printf '%s\n' "${B}claude-opencode-proxy installer${N}"
+[ "$DRY_RUN" = 1 ] && warn "dry run — nothing will be changed"
+
+if [ "$UNINSTALL" = 1 ]; then
+ if command -v docker >/dev/null 2>&1; then
+ docker info >/dev/null 2>&1 || SUDO="sudo"
+ if $SUDO docker inspect "$CONTAINER" >/dev/null 2>&1; then
+ say "removing container ${CONTAINER}"
+ DOCKER rm -f "$CONTAINER" >/dev/null
+ fi
+ fi
+ for rc in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc"; do
+ [ -e "$rc" ] || continue
+ grep -qF "$BEGIN_MARK" "$rc" 2>/dev/null || continue
+ if [ "$DRY_RUN" = 1 ]; then say "would unwire ${rc}"; continue; fi
+ tmp="$(mktemp)"
+ awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
+ index($0, b) { skip = 1 }
+ !skip { print }
+ index($0, e) { skip = 0 }
+ ' "$rc" > "$tmp"
+ cat "$tmp" > "$rc"
+ rm -f "$tmp"
+ say "unwired ${rc}"
+ done
+ run rm -f "$ENV_FILE"
+ say "done — the image and ~/.claude-opencode-proxy/models.json were left in place"
+ exit 0
+fi
+
+command -v curl >/dev/null 2>&1 || die "curl is required"
+ensure_docker
+start_container
+write_env_file
+wire_shells
+
+cat <]