#!/usr/bin/env bash # claudio — run Claude Code isolated in a podman container. # # Install: keep this file next to the Containerfile, then # alias claudio=/path/to/claudio.sh (e.g. in ~/.bashrc) # # Usage: claudio [claude args...] run Claude Code in the current directory # claudio shell [bash args] open a bash shell in the container instead # claudio build build the image # claudio update rebuild from scratch (latest Debian + Claude Code) # claudio reset remove this project's container (PERSIST=1) # # The current directory is mounted at its own path. If it contains a .claudio # file, that file is sourced (bash) for extra mounts and options, and can set # GUI=1 / AUDIO=1 to share the host's Wayland / PulseAudio sockets, ROOT=1 # to allow passwordless sudo inside the container, PACKAGES=(...) to build a # per-project image with extra Debian packages, or PERSIST=1 to keep the # container (and any system changes) between runs. # The container's home (/home/claude: login, settings, history) lives in the # global podman volume "claudio-home". set -euo pipefail IMAGE=localhost/claudio:latest VOLUME=claudio-home DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" build() { podman build -t "$IMAGE" "$@" "$DIR"; } case "${1:-}" in build) build; exit ;; # PACKAGES images built on the old base are stale; they are rebuilt on demand. update) build --pull=always --no-cache \ && podman image prune -af --filter label=claudio.packages \ && podman image prune -f; exit ;; esac if [[ "$PWD" == "$HOME" || "$PWD" == / ]]; then echo "claudio: refusing to mount $PWD — run it from a project directory." >&2 exit 1 fi # PERSIST=1 container for this directory: readable name + hash of the full path. name="claudio-$(printf %s "${PWD##*/}" | tr -c 'a-zA-Z0-9_.-' -)-$(printf %s "$PWD" | sha256sum | cut -c1-8)" if [[ "${1:-}" == reset ]]; then podman rm -f --ignore "$name" >/dev/null exit fi podman image exists "$IMAGE" || build # Project settings (see the .claudio template). MOUNTS=() PODMAN_ARGS=() CLAUDE_ARGS=() PACKAGES=() GUI=0 AUDIO=0 ROOT=0 PERSIST=0 if [[ -f .claudio ]]; then source ./.claudio; fi # PACKAGES: a derived image, tagged by base image + package list, so changing # either one gives a new tag that gets built on first use. image="$IMAGE" if (( ${#PACKAGES[@]} )); then image="localhost/claudio:pkgs-$(printf '%s\n' "$(podman image inspect -f '{{.Id}}' "$IMAGE")" \ "${PACKAGES[@]}" | sha256sum | cut -c1-12)" podman image exists "$image" || podman build -t "$image" --label claudio.packages=1 -f - "$DIR" < same path inside the container args+=(-v "$m") done # GUI/AUDIO: share a socket from the host's runtime dir into the container's. host_run="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" share() { [[ -S "$host_run/$1" ]] || { echo "claudio: no socket at $host_run/$1" >&2; exit 1; } args+=(-v "$host_run/$1:/run/user/1000/$1") } if [[ "$GUI" == 1 ]]; then wl="${WAYLAND_DISPLAY:-wayland-0}" share "$wl" args+=(-e WAYLAND_DISPLAY="$wl" -e GDK_BACKEND=wayland -e QT_QPA_PLATFORM=wayland -e SDL_VIDEODRIVER=wayland -e ELECTRON_OZONE_PLATFORM_HINT=wayland) if [[ -e /dev/dri ]]; then args+=(--device /dev/dri); fi fi if [[ "$AUDIO" == 1 ]]; then share pulse/native args+=(-e PULSE_SERVER=unix:/run/user/1000/pulse/native) fi # ROOT: the image gives the "sudo" group passwordless sudo; join it only on request. if [[ "$ROOT" == 1 ]]; then args+=(--group-add sudo); fi # keep-id maps your host user to the container's uid 1000, so files written # in mounts stay owned by you. label=disable avoids SELinux relabeling of mounts. args+=(--userns=keep-id:uid=1000,gid=1000 --security-opt label=disable --hostname claudio "${PODMAN_ARGS[@]}") cmd=(claude "${CLAUDE_ARGS[@]}") if [[ "${1:-}" == shell ]]; then shift cmd=(bash) fi if [[ -t 0 && -t 1 ]]; then tty=(-it); else tty=(-i); fi if [[ "$PERSIST" != 1 ]]; then exec podman run --rm "${tty[@]}" "${args[@]}" --entrypoint "${cmd[0]}" \ "$image" "${cmd[@]:1}" "$@" fi # PERSIST: the container idles under `sleep` and every session is a `podman # exec` into it. It is recreated when the image or the options above change. config="$(printf '%s\n' "$(podman image inspect -f '{{.Id}}' "$image")" "${args[@]}" \ | sha256sum | cut -c1-12)" current="$(podman container inspect -f '{{index .Config.Labels "claudio.config"}}' "$name" 2>/dev/null)" \ || current=none if [[ "$current" != "$config" && "$current" != none ]]; then if [[ "$(podman container inspect -f '{{.State.Running}}' "$name")" == true ]]; then echo "claudio: settings or image changed, but $name is in use by another session." >&2 echo "claudio: close it (or run 'claudio reset') to recreate the container." >&2 exit 1 fi echo "claudio: settings or image changed, recreating $name (system changes are lost)." >&2 podman rm "$name" >/dev/null current=none fi if [[ "$current" == none ]]; then podman create --name "$name" --label claudio.config="$config" --init "${args[@]}" \ --entrypoint sleep "$image" infinity >/dev/null fi podman start "$name" >/dev/null status=0 podman exec "${tty[@]}" -w "$PWD" "$name" "${cmd[@]}" "$@" || status=$? # Stop once no session is left. Exec'd processes (and the init) have parent # pid 0, so with only the init and this ps left there are no sessions. left="$(podman exec "$name" ps -e -o ppid= | grep -cx ' *0')" || left=0 if (( left <= 2 )); then podman stop -t 2 --ignore "$name" >/dev/null; fi exit "$status"