#!/usr/bin/env bash set -euo pipefail SERVICE="opencode" APP_BIN="opencode" # ------------------------------------------------------------ # Détection docker compose (plugin vs legacy) # ------------------------------------------------------------ # Prefer the Compose v2 plugin (`docker compose`): the modern docker-compose.yml # uses Compose-Spec features (e.g. the top-level `name:` key) that the legacy # Python `docker-compose` v1 rejects ("'name' does not match any of the regexes"). # Only fall back to the legacy binary when the v2 plugin is genuinely unavailable. if docker compose version >/dev/null 2>&1; then DC=(docker compose) elif command -v docker-compose >/dev/null 2>&1; then DC=(docker-compose) else echo "❌ Neither 'docker compose' (v2 plugin) nor 'docker-compose' (legacy) is available." >&2 echo " Install the Docker Compose v2 plugin: https://docs.docker.com/compose/install/" >&2 exit 1 fi # ------------------------------------------------------------ # TTY detection (pipe-safe) # ------------------------------------------------------------ if [[ -t 0 ]]; then TTY_FLAGS=(-it) else TTY_FLAGS=(-T) fi # ------------------------------------------------------------ # --log mode # ------------------------------------------------------------ if [[ "${1:-}" == "--log" ]]; then if [[ $# -lt 2 ]]; then echo "Usage: $0 --log " exit 1 fi SESSION_ID="$2" exec "${DC[@]}" exec "${TTY_FLAGS[@]}" "$SERVICE" \ bash -lc 'exec "$1" "$2"' bash "darkmoon-cli" "$SESSION_ID" fi # ------------------------------------------------------------ # Preflight: fail fast with a clear message if the configured # on-prem LLM endpoint is unreachable from INSIDE the container. # Without this, `opencode run` hangs silently — no output, no # error, no logs — when ANTHROPIC_BASE_URL / OPENCODE_LOCAL_BASE_URL # cannot be reached (see issue #28). # ------------------------------------------------------------ preflight_provider_check() { local res res="$("${DC[@]}" exec -T "$SERVICE" bash -c ' url="${ANTHROPIC_BASE_URL:-}"; mode="Anthropic (install.sh option 3)" if [ -z "$url" ]; then url="${OPENCODE_LOCAL_BASE_URL:-}"; mode="Local (install.sh option 2)"; fi [ -z "$url" ] && exit 0 # cloud provider or none -> nothing to probe proto="${url%%://*}"; rest="${url#*://}"; hostport="${rest%%/*}" host="${hostport%%:*}"; port="${hostport##*:}" [ "$host" = "$port" ] && port="" # no explicit port in URL [ -z "$port" ] && { [ "$proto" = https ] && port=443 || port=80; } # Pure-bash TCP connect (no curl in the image); detects the silent-hang case. if timeout 5 bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null; then exit 0; fi printf "UNREACHABLE|%s|%s" "$mode" "$url" exit 3 ' 2>/dev/null)" || true case "${res:-}" in UNREACHABLE\|*) local rest="${res#UNREACHABLE|}" local url="${rest#*|}" local mode="${rest%%|*}" { echo "" echo "❌ LLM endpoint not reachable from inside the Darkmoon container:" echo " ${url}" echo " (provider: ${mode})" echo "" echo " The agent cannot run and would otherwise produce no output. Check:" echo " • Network: the URL must be reachable FROM THE CONTAINER, not just your host." echo " 'localhost' / '127.0.0.1' point to the container itself — use the host LAN IP" echo " or 'host.docker.internal', and confirm the server is actually running." echo " • Protocol: option [3] expects an Anthropic Messages API endpoint (/v1/messages)." echo " If your endpoint is OpenAI-compatible (e.g. blackbox, LiteLLM, Ollama), re-run" echo " install.sh and choose [2] Local instead." echo "" } >&2 exit 3 ;; esac } preflight_provider_check # ------------------------------------------------------------ # Default behaviour # ------------------------------------------------------------ if [[ $# -eq 0 ]]; then # Mode interactif → TUI exec "${DC[@]}" exec "${TTY_FLAGS[@]}" "$SERVICE" \ bash -lc 'exec "$1"' bash "$APP_BIN" elif [[ "${1:0:1}" == "-" ]]; then # Le 1er argument est un flag (court ou long : -s, -c, -m, # --help, --version, --session, …). On passe la main directement # à `opencode` SANS la sous-commande `run`. C'est indispensable # pour -s/--session (continuer une session) et -c/--continue : # `opencode run` exigerait en plus un message positionnel et # échouerait avec "You must provide a message or a command". # Pour relancer une session en mode one-shot, mettre le message # en 1er : ./darkmoon.sh "mon prompt" -s exec "${DC[@]}" exec "${TTY_FLAGS[@]}" "$SERVICE" \ bash -lc 'app="$1"; shift; exec "$app" "$@"' bash "$APP_BIN" "$@" else # Arguments positionnels = prompt one-shot. opencode exige la # sous-commande `run`, sinon il interprète la chaîne comme un cwd # et échoue avec "Failed to change directory to /". # # Guard (ASCIT31/Dark-Moon-Front-API#39): a head-less one-shot run cannot use # opencode's free tier (opencode/big-pickle), the no-provider fallback written # by apply-settings.sh. opencode refuses it with "OpenCode's free tier can only # be used from within OpenCode", which reads as a cryptic failure. Detect that # model up front and say what to configure instead. _dm_model="$("${DC[@]}" exec -T "$SERVICE" python3 -c 'import json,os p="/root/.config/opencode/opencode.json" if not os.path.exists(p): p=os.path.expanduser("~/.config/opencode/opencode.json") try: print(json.load(open(p)).get("model","")) except Exception: print("")' 2>/dev/null | tr -d "[:space:]")" case "$_dm_model" in opencode/*) { echo "" echo "❌ No LLM provider is configured, so Darkmoon fell back to opencode's free" echo " tier (model: ${_dm_model}). That tier CANNOT run head-less: opencode refuses" echo " it with \"OpenCode's free tier can only be used from within OpenCode\"." echo "" echo " Configure a real model, then re-run your campaign:" echo " • Interactive: ./install.sh --init" echo " • Cloud (e.g.): sudo ./install.sh --provider anthropic --model claude-opus-4-6 --api-key sk-ant-..." echo " • Local (e.g.): sudo ./install.sh --local --local-engine ollama --local-url http://HOST:11434/v1 --local-model " echo "" } >&2 exit 3 ;; esac exec "${DC[@]}" exec "${TTY_FLAGS[@]}" "$SERVICE" \ bash -lc 'app="$1"; shift; exec "$app" run "$@"' bash "$APP_BIN" "$@" fi