#!/usr/bin/env bash # claude.sh — Manage the isolated Claude Code Docker environment # Usage: ./claude.sh [args] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" PROJECT="claude-secure" # ─── Global flags ───────────────────────────────────────────────────────────── ALLOW_KUBE=0 # set by --kube before the subcommand # ─── Colours ────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' info() { echo -e "${GREEN}[+]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; } error() { echo -e "${RED}[-]${NC} $*" >&2; } # ─── Dependency check ───────────────────────────────────────────────────────── check_deps() { if ! command -v docker &>/dev/null; then error "Docker is not installed. https://docs.docker.com/get-docker/" exit 1 fi if ! docker compose version &>/dev/null 2>&1; then error "Docker Compose v2 plugin is required." exit 1 fi } # ─── Environment loading ────────────────────────────────────────────────────── load_env() { local env_file="$SCRIPT_DIR/.env" if [[ -f "$env_file" ]]; then # shellcheck disable=SC1090 set -a; source "$env_file"; set +a fi if [[ -z "${ANTHROPIC_API_KEY:-}" && -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then warn "No ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN found." warn "Claude Code will prompt you to authenticate on first run." warn " Option 1 (API key): set ANTHROPIC_API_KEY in .env" warn " Option 2 (token): run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN in .env" warn " Option 3 (browser): run './claude.sh run' and follow the login prompt;" warn " port 54545 must be reachable from your browser." fi } # ─── Workspace volume resolution ────────────────────────────────────────────── # Mounts the current working directory as /workspace inside the container. # Refuses to mount home directories, key material, or system directories. workspace_flag() { local cwd cwd="$(pwd)" # Exact-match blocklist — mounting these exposes too much of the host local -a exact_blocked=( / "$HOME" /root /home ) # Prefix blocklist — block these paths and all subdirectories. # Covers system internals and credential/key material. local -a prefix_blocked=( /bin /sbin /lib /lib64 /etc /usr /var /proc /sys /dev /boot /run # SSH keys "$HOME/.ssh" /root/.ssh # PGP/GPG keys "$HOME/.gnupg" /root/.gnupg ) for dir in "${exact_blocked[@]}"; do if [[ "$cwd" == "$dir" ]]; then error "Refusing to mount $cwd as workspace — too broad." error "cd into a project subdirectory first." exit 1 fi done # Block any user home directory directly under /home (e.g. /home/alice) if [[ "$cwd" =~ ^/home/[^/]+$ ]]; then error "Refusing to mount $cwd as workspace — user home directory." error "cd into a project subdirectory first." exit 1 fi for dir in "${prefix_blocked[@]}"; do if [[ "$cwd" == "$dir" || "$cwd" == "$dir/"* ]]; then error "Refusing to mount $cwd as workspace — contains sensitive data." error "cd into a project subdirectory first." exit 1 fi done echo "--volume ${cwd}:/workspace:z" } # ─── Optional kubeconfig mount ──────────────────────────────────────────────── # Enabled by passing --kube before the subcommand. # Mounts $HOME/.kube read-only at /home/node/.kube inside the container. kube_flag() { [[ "$ALLOW_KUBE" -eq 0 ]] && return local kube_dir="$HOME/.kube" if [[ ! -d "$kube_dir" ]]; then error "--kube specified but $kube_dir does not exist." exit 1 fi echo "--volume ${kube_dir}:/home/node/.kube:ro,z" } # ─── Compose wrapper ────────────────────────────────────────────────────────── dc() { docker compose -f "$COMPOSE_FILE" -p "$PROJECT" "$@"; } # ─── Commands ───────────────────────────────────────────────────────────────── cmd_start() { check_deps load_env info "Starting proxy sidecar..." dc up -d proxy info "Launching Claude Code..." # shellcheck disable=SC2046 dc run --rm --service-ports $(workspace_flag) $(kube_flag) claude "$@" } cmd_stop() { check_deps info "Stopping all containers..." dc down info "Done." } cmd_run() { check_deps load_env info "Ensuring proxy is running..." dc up -d proxy info "Launching Claude Code..." # shellcheck disable=SC2046 dc run --rm --service-ports $(workspace_flag) $(kube_flag) claude "$@" } cmd_update() { check_deps info "Pulling latest images from registry..." dc pull info "Update complete. Run './claude.sh start' to launch." } cmd_logs() { check_deps local svc="${1:-proxy}" dc logs -f "$svc" } cmd_status() { check_deps dc ps } cmd_shell() { check_deps load_env warn "Opening debug shell inside Claude container (non-Claude entrypoint)." # shellcheck disable=SC2046 dc run --rm --service-ports --entrypoint /bin/bash $(workspace_flag) $(kube_flag) claude } cmd_web() { check_deps load_env if [[ -z "${WEBUI_PASSWORD:-}" ]]; then error "WEBUI_PASSWORD is not set. Add it to .env before starting the web interface." exit 1 fi info "Starting proxy and web interface..." dc up -d webui local port=7681 info "Web interface is up → http://0.0.0.0:${port}" info "Credentials: ${WEBUI_USER:-claude} / [WEBUI_PASSWORD]" warn "To reach it from outside this host, publish the port:" warn " sbx ports --publish ${port}:${port}/tcp" } cmd_web_stop() { check_deps info "Stopping web interface..." dc stop webui dc rm -f webui } cmd_help() { cat < [args] Commands: start [args] Start proxy, launch Claude Code (CLI) run [args] Start proxy if needed, launch Claude Code (CLI) web Start proxy + web interface (browser terminal) web-stop Stop the web interface (keeps proxy running) stop Stop and remove all containers update Pull latest images from the registry logs [svc] Tail logs (default: proxy) status Show container status shell Open a bash shell in the Claude container (debug) help Show this message Environment variables (set in .env or shell): ANTHROPIC_API_KEY Required for all modes. WEBUI_USER Web interface username (default: claude). WEBUI_PASSWORD Required for web mode. Basic auth password. Flags (before the subcommand): --kube Mount \$HOME/.kube read-only at /home/node/.kube (kubectl access) Examples: cd ~/myproject && ./claude.sh start cd ~/myproject && ./claude.sh --kube start ./claude.sh web ./claude.sh logs proxy ./claude.sh logs webui ./claude.sh shell EOF } # ─── Dispatch ───────────────────────────────────────────────────────────────── # Parse global flags before the subcommand while [[ "${1:-}" == --* ]]; do case "$1" in --kube) ALLOW_KUBE=1; shift ;; *) break ;; esac done case "${1:-help}" in start) shift; cmd_start "$@" ;; stop) cmd_stop ;; run) shift; cmd_run "$@" ;; web) cmd_web ;; web-stop) cmd_web_stop ;; update) cmd_update ;; logs) shift; cmd_logs "${1:-}" ;; status) cmd_status ;; shell) cmd_shell ;; help|-h|--help) cmd_help ;; *) error "Unknown command: ${1}" cmd_help exit 1 ;; esac