Created
June 15, 2026 20:06
-
-
Save arcanemachine/c9ebdc0255941229a19f50db69a6e5a8 to your computer and use it in GitHub Desktop.
A simple, portable script to run multiple coding harnesses in a container (Claude Code, Pi, OpenCode)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # agcon — run a coding agent (or a shell) inside a minimal, disposable Docker | |
| # container with the current directory and the agent's config bind-mounted in. | |
| # | |
| # The invoked name selects the agent. Symlink this script to get per-agent | |
| # commands; the base name `agcon` (or any unknown name) uses DEFAULT_AGENT: | |
| # | |
| # ln -s agcon clcon # clcon -> claude | |
| # ln -s agcon picon # picon -> pi | |
| # ln -s agcon occon # occon -> opencode | |
| # | |
| # Agents (image and config are per-agent; host config is mounted in): | |
| # claude @anthropic-ai/claude-code ~/.claude + ~/.claude.json | |
| # pi @earendil-works/pi-coding-agent ~/.pi | |
| # opencode opencode-ai ~/.config/opencode + ~/.local/share/opencode | |
| # (env-key providers: pass keys via -e, e.g. | |
| # occon -e OPENROUTER_API_KEY="$OPENROUTER_API_KEY") | |
| # | |
| # Usage: | |
| # agcon [shell] [docker-run-args...] [-- command-args...] | |
| # | |
| # agcon # run DEFAULT_AGENT in ./ | |
| # picon # run pi (via symlink) | |
| # agcon shell # start a bash shell instead | |
| # agcon --network=host # forward flags straight to `docker run` | |
| # agcon -- --resume # pass args to the inner command | |
| # agcon shell -e FOO=bar # combine: shell + forwarded docker flags | |
| # | |
| # Mounts: | |
| # $PWD -> /workspace (your project, read-write) | |
| # <agent config> -> <container home> (per-agent; see profiles below) | |
| # agcon-history -> /commandhistory (named volume; bash history) | |
| # | |
| # Everything between the leading options and `--` is passed verbatim to | |
| # `docker run`; everything after `--` is passed to the in-container command. | |
| # The container is removed on exit (--rm); only the mounts persist. | |
| # | |
| # The image is rebuilt every run from the Dockerfile heredoc below, but | |
| # Docker's layer cache makes that near-instant unless something changed. | |
| # To extend it, add apt packages to the apt-get line; per-agent npm packages | |
| # come from the profiles below. | |
| set -euo pipefail | |
| # --- agent selection -------------------------------------------------------- | |
| DEFAULT_AGENT="claude" | |
| case "$(basename "$0")" in | |
| clcon) AGENT="claude" ;; | |
| picon) AGENT="pi" ;; | |
| occon) AGENT="opencode" ;; | |
| *) AGENT="$DEFAULT_AGENT" ;; | |
| esac | |
| # --- agent profiles --------------------------------------------------------- | |
| # Each profile sets the npm package to install, the default command, and the | |
| # host->container config mount(s). Add an agent by adding a case arm here (and | |
| # a name above if you want a dedicated symlinked command). | |
| AGENT_MOUNTS=() | |
| case "$AGENT" in | |
| claude) | |
| AGENT_PKG="@anthropic-ai/claude-code" | |
| AGENT_CMD="claude --dangerously-skip-permissions" | |
| AGENT_MOUNTS+=(-v "$HOME/.claude:/home/node/.claude") | |
| # Claude also keeps a stray dotfile beside the dir; mount it if present. | |
| [ -f "$HOME/.claude.json" ] && AGENT_MOUNTS+=(-v "$HOME/.claude.json:/home/node/.claude.json") | |
| ;; | |
| pi) | |
| AGENT_PKG="@earendil-works/pi-coding-agent" | |
| AGENT_CMD="pi" | |
| # pi keeps everything in a single ~/.pi dir. | |
| [ -d "$HOME/.pi" ] && AGENT_MOUNTS+=(-v "$HOME/.pi:/home/node/.pi") | |
| ;; | |
| opencode) | |
| AGENT_PKG="opencode-ai" | |
| AGENT_CMD="opencode" | |
| # opencode uses XDG paths: config in ~/.config/opencode, auth/state in | |
| # ~/.local/share/opencode. (For env-key auth, pass -e VARS via docker args.) | |
| [ -d "$HOME/.config/opencode" ] && AGENT_MOUNTS+=(-v "$HOME/.config/opencode:/home/node/.config/opencode") | |
| [ -d "$HOME/.local/share/opencode" ] && AGENT_MOUNTS+=(-v "$HOME/.local/share/opencode:/home/node/.local/share/opencode") | |
| ;; | |
| *) | |
| echo "agcon: unknown agent '$AGENT'" >&2; exit 1 ;; | |
| esac | |
| IMAGE="agcon-$AGENT:latest" | |
| HISTORY_VOLUME="agcon-history" | |
| # --- detect host timezone (best-effort) for the image build ----------------- | |
| HOST_TZ="${TZ:-}" | |
| if [ -z "$HOST_TZ" ]; then | |
| if [ -f /etc/timezone ]; then | |
| HOST_TZ="$(cat /etc/timezone)" | |
| elif [ -L /etc/localtime ]; then | |
| HOST_TZ="$(readlink /etc/localtime | sed 's#.*/zoneinfo/##')" | |
| fi | |
| fi | |
| HOST_TZ="${HOST_TZ:-UTC}" | |
| # --- parse leading script options (order-independent) ----------------------- | |
| CMD="$AGENT_CMD" | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| shell) CMD="bash"; shift ;; | |
| *) break ;; | |
| esac | |
| done | |
| # --- docker args (everything up to `--`) ------------------------------------ | |
| DOCKER_ARGS=() | |
| while [ $# -gt 0 ] && [ "$1" != "--" ]; do | |
| DOCKER_ARGS+=("$1"); shift | |
| done | |
| # --- inner command args (everything after `--`) ----------------------------- | |
| CMD_ARGS=() | |
| if [ "${1:-}" = "--" ]; then | |
| shift | |
| CMD_ARGS=("$@") | |
| fi | |
| # --- build the image (cached; quietly rebuilds only changed layers) --------- | |
| echo "(Re)building the container if necessary. This may take a moment..." >&2 | |
| docker build -q \ | |
| --build-arg "TZ=$HOST_TZ" \ | |
| --build-arg "AGENT_PKG=$AGENT_PKG" \ | |
| -t "$IMAGE" - >/dev/null <<'DOCKERFILE' | |
| FROM node:lts-slim | |
| ARG TZ | |
| ENV TZ="$TZ" | |
| # Minimal toolset. Add packages here as needed. | |
| RUN apt-get update && apt-get install -y --no-install-recommends \ | |
| curl \ | |
| git \ | |
| jq \ | |
| less \ | |
| locales \ | |
| nano \ | |
| ripgrep \ | |
| tzdata \ | |
| && rm -rf /var/lib/apt/lists/* | |
| # Generate a UTF-8 locale. | |
| RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen en_US.UTF-8 | |
| # Coding agent (selected per-agent by the calling script). | |
| ARG AGENT_PKG | |
| RUN npm install -g "$AGENT_PKG" | |
| # Bash history persists via a named volume mounted at /commandhistory. | |
| RUN mkdir /commandhistory && touch /commandhistory/.bash_history \ | |
| && chown -R node:node /commandhistory | |
| ENV SHELL=/bin/bash \ | |
| EDITOR=nano \ | |
| VISUAL=nano \ | |
| LANG=en_US.UTF-8 \ | |
| LANGUAGE=en_US:en \ | |
| LC_ALL=en_US.UTF-8 \ | |
| NODE_OPTIONS=--max-old-space-size=4096 \ | |
| HISTFILE=/commandhistory/.bash_history \ | |
| PROMPT_COMMAND="history -a" | |
| # node:slim ships a non-root `node` user (uid 1000). | |
| USER node | |
| WORKDIR /workspace | |
| DOCKERFILE | |
| # --- run -------------------------------------------------------------------- | |
| exec docker run --rm -it \ | |
| --name "agcon-$AGENT" \ | |
| -v "$PWD:/workspace" \ | |
| -v "$HISTORY_VOLUME:/commandhistory" \ | |
| ${AGENT_MOUNTS[@]+"${AGENT_MOUNTS[@]}"} \ | |
| -w /workspace \ | |
| ${DOCKER_ARGS[@]+"${DOCKER_ARGS[@]}"} \ | |
| "$IMAGE" \ | |
| $CMD ${CMD_ARGS[@]+"${CMD_ARGS[@]}"} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment