#!/usr/bin/env bash
# =============================================================================
# aic — host-side CLI for aicontainer
# =============================================================================
# Thin shim around `@devcontainers/cli`. Resolves the aicontainer files
# relative to this script so both npm installs (npm install -g aicontainer)
# and git checkouts (~/.aicontainer + install.sh) work without configuration.
# Override with $AIC_HOME if you keep them somewhere unusual.
# =============================================================================
set -euo pipefail

# Resolve the real script directory, following symlinks (npm install -g
# creates one in the global bin dir). Works on macOS (BSD readlink, no -f)
# and Linux.
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT_PATH" ]; do
  link_dir="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
  SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
  [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$link_dir/$SCRIPT_PATH"
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"

AIC_HOME="${AIC_HOME:-$SCRIPT_DIR}"
TEMPLATE_DIR="$AIC_HOME/template"

err()  { echo "aic: $*" >&2; }
die()  { err "$*"; exit 1; }

# Prefer a devcontainer CLI bundled into our node_modules (when installed via
# `npm install -g aicontainer`), fall back to one on PATH (manual install).
resolve_devcontainer () {
  if [ -x "$AIC_HOME/node_modules/.bin/devcontainer" ]; then
    echo "$AIC_HOME/node_modules/.bin/devcontainer"
  elif command -v devcontainer >/dev/null 2>&1; then
    command -v devcontainer
  else
    die "devcontainer CLI not found. Install with: npm install -g @devcontainers/cli"
  fi
}

# Compose project name for this project's stack, matching what `devcontainer up`
# (and VS Code's "Reopen in Container") actually create: the lowercased workspace
# folder basename with characters outside [a-z0-9_-] *removed*, plus a
# `_devcontainer` suffix. Used by down/destroy/preflight to target the right
# stack and its `<project>_aic-sessions` volume.
#
# Two historical bugs this avoids: piping basename's trailing newline through
# `tr -c 'a-z0-9_-' '_'` turned the newline into a spurious trailing `_`, and the
# `_devcontainer` suffix was missing entirely — so `docker compose -p` and the
# volume name never matched the real stack, silently no-opping `aic down`/`destroy`.
project_name () {
  local base
  base="$(basename -- "$PWD")"
  # toLowerCase().replace(/[^a-z0-9_-]/g, '') — strip, don't substitute.
  base="$(printf '%s' "$base" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C tr -dc 'a-z0-9_-')"
  printf '%s_devcontainer\n' "$base"
}

require_in_project () {
  [ -f .devcontainer/devcontainer.json ] || die "no .devcontainer/ here — run 'aic init' first"
}

# ---------------------------------------------------------------------------
# Per-project tool selection (claude-code, codex).
#
# Stored in .devcontainer/devcontainer.json as containerEnv.AIC_TOOLS, e.g.
# "claude-code,codex". post-create.py reads it to gate setup_claude /
# setup_codex; the VSCode extensions array is filtered to match.
#
# Default for old projects without AIC_TOOLS (and for non-TTY init without
# --with) is both tools, preserving pre-flag behavior.
# ---------------------------------------------------------------------------
KNOWN_TOOLS="claude-code codex"

# ---------------------------------------------------------------------------
# Per-project shell selection (zsh, bash, fish).
#
# Stored in .devcontainer/devcontainer.json as containerEnv.AIC_SHELL. The
# image bakes minimal configs for all three (history persistence + fnm).
# zsh keeps oh-my-zsh + powerlevel10k; bash and fish are barebones. We also
# patch terminal.integrated.defaultProfile.linux + fontFamily so the VS Code
# terminal opens in the chosen shell with a font that fits (MesloLGS NF for
# zsh's p10k icons, plain monospace otherwise).
# ---------------------------------------------------------------------------
KNOWN_SHELLS="zsh bash fish"
DEFAULT_SHELL="zsh"

validate_shell () {
  local raw="${1:-}"
  raw="$(printf '%s' "$raw" | tr -d '[:space:]')"
  [ -n "$raw" ] || die "no shell specified (valid: $KNOWN_SHELLS)"
  case "$raw" in
    zsh|bash|fish) echo "$raw" ;;
    *) die "unknown shell '$1' (valid: $KNOWN_SHELLS)" ;;
  esac
}

prompt_shell () {
  # Single-select radio menu on /dev/tty. Falls back to the default shell
  # when stdin isn't a TTY (CI, piped install).
  if ! (exec 3</dev/tty 4>/dev/tty) 2>/dev/null; then
    echo "$DEFAULT_SHELL"
    return
  fi

  local opts=("zsh" "bash" "fish")
  local descs=(
    "zsh + oh-my-zsh + powerlevel10k (default)"
    "bash (minimal, history + fnm)"
    "fish (minimal, history + fnm)"
  )
  local n=3 cur=0 i mark prefix

  exec 3</dev/tty 4>/dev/tty

  trap 'printf "\033[?25h" >&4 2>/dev/null; exec 3<&- 4>&- 2>/dev/null; trap - INT TERM EXIT; exit 130' INT TERM

  printf "Which shell should this project use?\n" >&4
  printf "(↑/↓ move · enter confirms · q cancels)\n\n" >&4
  printf "\033[?25l" >&4

  draw_shell_menu () {
    for i in 0 1 2; do
      if [ "$i" = "$cur" ]; then prefix="❯ "; mark="●"; else prefix="  "; mark="○"; fi
      printf "\033[K%s%s %-5s — %s\n" "$prefix" "$mark" "${opts[$i]}" "${descs[$i]}" >&4
    done
  }
  draw_shell_menu

  local k c1 c2
  while true; do
    IFS= read -rsn1 -u 3 k || k=""
    if [ "$k" = $'\033' ]; then
      IFS= read -rsn1 -u 3 c1 || c1=""
      IFS= read -rsn1 -u 3 c2 || c2=""
      if [ "$c1" = "[" ]; then
        [ "$c2" = "A" ] && cur=$(( (cur - 1 + n) % n ))
        [ "$c2" = "B" ] && cur=$(( (cur + 1) % n ))
      fi
    elif [ -z "$k" ]; then
      break
    elif [ "$k" = "q" ] || [ "$k" = "Q" ]; then
      printf "\n\033[?25h" >&4
      exec 3<&- 4>&-
      trap - INT TERM
      die "cancelled by user"
    fi
    printf "\033[%dA" "$n" >&4
    draw_shell_menu
  done

  printf "\033[?25h" >&4
  exec 3<&- 4>&-
  trap - INT TERM

  echo "${opts[$cur]}"
}

read_aic_shell () {
  # Read containerEnv.AIC_SHELL from .devcontainer/devcontainer.json.
  # Empty output if the field isn't set (older projects).
  local file=".devcontainer/devcontainer.json"
  [ -f "$file" ] || return 0
  sed -n 's/.*"AIC_SHELL"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -n1
}

# Read this aic's own version from package.json. Used to pin the GHCR image
# tag in generated pull-mode compose files (so `aic@X.Y.Z` always pulls
# `ghcr.io/stefanoginella/aicontainer:vX.Y.Z`, never a floating :latest that
# could drift away from the CLI's filesystem expectations).
read_aic_version () {
  local pkg="$AIC_HOME/package.json"
  [ -f "$pkg" ] || die "package.json not found at $pkg (set AIC_HOME or reinstall aic)"
  local v
  v="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$pkg" | head -n1)"
  [ -n "$v" ] || die "could not read version from $pkg"
  echo "$v"
}

validate_tools () {
  # Normalize a user-supplied list ("codex,claude-code", "claude-code ,codex")
  # to canonical "claude-code,codex" order. Dies on unknown tools or empty input.
  local raw="$1"
  raw="$(printf '%s' "$raw" | tr -d '[:space:]')"
  [ -n "$raw" ] || die "no tools selected (valid: $KNOWN_TOOLS)"
  local has_claude=0 has_codex=0
  case ",$raw," in *,claude-code,*) has_claude=1 ;; esac
  case ",$raw," in *,codex,*)       has_codex=1 ;; esac
  # Validate by elimination: strip known tokens and separators; anything left
  # is an unknown tool (avoids touching IFS or spawning a subshell).
  local rest="$raw"
  rest="${rest//claude-code/}"
  rest="${rest//codex/}"
  rest="${rest//,/}"
  [ -z "$rest" ] || die "unknown tool(s) in '$1' (valid: $KNOWN_TOOLS)"
  local result=""
  [ "$has_claude" = "1" ] && result="claude-code"
  [ "$has_codex"  = "1" ] && result="${result:+$result,}codex"
  [ -n "$result" ] || die "no tools selected (valid: $KNOWN_TOOLS)"
  echo "$result"
}

prompt_tools () {
  # Renders an interactive checkbox menu on /dev/tty and writes the
  # comma-separated selection to stdout. Falls back to both tools when
  # no controlling terminal is available (e.g. CI, piped install).
  # The probe-open is the only reliable check: /dev/tty exists as a device
  # node even in non-interactive contexts, but opening it fails. We probe
  # in a subshell so the test never leaves stale fds in the parent.
  if ! (exec 3</dev/tty 4>/dev/tty) 2>/dev/null; then
    echo "claude-code,codex"
    return
  fi

  local opts=("claude-code" "codex")
  local sel=("1" "1")
  local n=2 cur=0 i mark prefix

  exec 3</dev/tty 4>/dev/tty

  # Restore cursor + close fds on any exit path.
  trap 'printf "\033[?25h" >&4 2>/dev/null; exec 3<&- 4>&- 2>/dev/null; trap - INT TERM EXIT; exit 130' INT TERM

  printf "Which AI tools should this project use?\n" >&4
  printf "(↑/↓ move · space toggles · enter confirms · q cancels)\n\n" >&4
  printf "\033[?25l" >&4

  draw_tools_menu () {
    for i in 0 1; do
      [ "${sel[$i]}" = "1" ] && mark="[✓]" || mark="[ ]"
      [ "$i" = "$cur" ] && prefix="❯ " || prefix="  "
      printf "\033[K%s%s %s\n" "$prefix" "$mark" "${opts[$i]}" >&4
    done
  }
  draw_tools_menu

  local k c1 c2 any
  while true; do
    IFS= read -rsn1 -u 3 k || k=""
    if [ "$k" = $'\033' ]; then
      IFS= read -rsn1 -u 3 c1 || c1=""
      IFS= read -rsn1 -u 3 c2 || c2=""
      if [ "$c1" = "[" ]; then
        [ "$c2" = "A" ] && cur=$(( (cur - 1 + n) % n ))
        [ "$c2" = "B" ] && cur=$(( (cur + 1) % n ))
      fi
    elif [ "$k" = " " ]; then
      if [ "${sel[$cur]}" = "1" ]; then sel[$cur]="0"; else sel[$cur]="1"; fi
    elif [ -z "$k" ]; then
      any=0
      for i in 0 1; do [ "${sel[$i]}" = "1" ] && any=1; done
      if [ "$any" = "1" ]; then break; fi
    elif [ "$k" = "q" ] || [ "$k" = "Q" ]; then
      printf "\n\033[?25h" >&4
      exec 3<&- 4>&-
      trap - INT TERM
      die "cancelled by user"
    fi
    printf "\033[%dA" "$n" >&4
    draw_tools_menu
  done

  printf "\033[?25h" >&4
  exec 3<&- 4>&-
  trap - INT TERM

  local result=""
  [ "${sel[0]}" = "1" ] && result="claude-code"
  [ "${sel[1]}" = "1" ] && result="${result:+$result,}codex"
  echo "$result"
}

read_aic_tools () {
  # Read containerEnv.AIC_TOOLS from .devcontainer/devcontainer.json.
  # Empty output if the field isn't set (older projects).
  local file=".devcontainer/devcontainer.json"
  [ -f "$file" ] || return 0
  sed -n 's/.*"AIC_TOOLS"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -n1
}

# Build the injection block for project-owned VS Code extensions. Reads the
# given file (if present): skips blank/# lines, trims surrounding whitespace,
# validates each remaining line against the publisher.extension id grammar and
# warns+skips anything else (so a stray line can't smuggle arbitrary JSON into
# devcontainer.json), dedups, and emits one leading-comma line per id. Leading
# commas keep the result valid JSON no matter how the template array ends.
# Echoes the (possibly empty) block; warnings go to stderr.
build_vscode_ext_block () {
  local file="$1" line id block="" seen=","
  [ -f "$file" ] || return 0
  while IFS= read -r line || [ -n "$line" ]; do
    id="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
    case "$id" in ''|\#*) continue ;; esac
    if ! printf '%s' "$id" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*\.[A-Za-z0-9._-]+$'; then
      printf 'aic: ignoring invalid vscode-extensions entry: %s\n' "$id" >&2
      continue
    fi
    case "$seen" in *",$id,"*) continue ;; esac
    seen="$seen$id,"
    block="$block        ,\"$id\"
"
  done < "$file"
  printf '%s' "$block"
}

# Build the injection block for project-owned VS Code settings. Reads the given
# file (if present): requires a JSON object (warns+skips otherwise), takes the
# content strictly between the first { and the last } and emits it verbatim
# (preserving nested objects, arrays, and JSONC comments) prefixed with a single
# leading comma. A trailing comma in the object is tolerated by JSONC before the
# closing brace. Echoes the (possibly empty) block; warnings go to stderr.
build_vscode_settings_block () {
  local file="$1" raw inner
  [ -f "$file" ] || return 0
  raw="$(cat "$file")"
  case "$raw" in
    *"{"*"}"*) ;;
    *) printf 'aic: ignoring vscode-settings.json (not a JSON object: must be { ... })\n' >&2; return 0 ;;
  esac
  inner="${raw#*\{}"     # drop through the first {
  inner="${inner%\}*}"   # drop from the last } onward
  case "$inner" in
    *[![:space:]]*) printf '        ,%s' "$inner" ;;  # non-empty object only
  esac
}

patch_devcontainer_json () {
  # Patch the freshly-copied devcontainer.json:
  #   1. Inject "AIC_TOOLS" and "AIC_SHELL" inside containerEnv.
  #   2. Drop "anthropic.claude-code" extension when claude-code not selected.
  #   3. Drop "openai.chatgpt" extension when codex not selected.
  #   4. Patch terminal.integrated.defaultProfile.linux to the chosen shell.
  #   5. Patch terminal.integrated.fontFamily — MesloLGS NF for zsh (p10k icons),
  #      plain monospace for bash/fish (no nerd font required on the host).
  #   6. Wire a project-owned docker-compose.override.yml into dockerComposeFile
  #      when one exists, so per-project compose tweaks (containerEnv-equivalent
  #      `environment:`, extra_hosts, build.dockerfile) survive `aic sync`. The
  #      override file itself is never copied or overwritten by apply_template;
  #      this only re-applies the reference, which the template ships without.
  #   7. Rewrite "name" to "aicontainer-<project folder>" so each project shows a
  #      distinct label in VS Code's remote indicator. Re-derived from $PWD every
  #      run (like AIC_TOOLS/AIC_SHELL), so it's recomputed on each init/sync and
  #      the template can keep the bare "aicontainer" placeholder.
  #   8. Merge project-owned .devcontainer/vscode-extensions (one extension id
  #      per line) into customizations.vscode.extensions at the
  #      "// aic:vscode-extensions" marker.
  #   9. Merge project-owned .devcontainer/vscode-settings.json (a JSON object)
  #      into customizations.vscode.settings at the "// aic:vscode-settings"
  #      marker.
  #      Both (8) and (9) are opt-in by file presence, never copied/overwritten
  #      by apply_template, and RO inside the container — same contract as
  #      firewall-allowlist / chown-paths. Injected blocks are leading-comma
  #      so they stay valid JSON regardless of how the template array/object
  #      ends; the source files are read fresh each run from the pristine
  #      template, so re-injection is idempotent across syncs.
  # Operates line-wise so JSONC comments don't trip us up.
  local file="$1" tools="$2" shell="$3"
  local hc=0 hx=0 ov=0 tmp font dir pname cname
  local ext_block="" settings_block=""
  case ",$tools," in *,claude-code,*) hc=1 ;; esac
  case ",$tools," in *,codex,*)       hx=1 ;; esac

  dir="$(dirname "$file")"
  [ -f "$dir/docker-compose.override.yml" ] && ov=1
  ext_block="$(build_vscode_ext_block "$dir/vscode-extensions")"
  settings_block="$(build_vscode_settings_block "$dir/vscode-settings.json")"

  # Project label from the cwd (project root for both init and sync). JSON-escape
  # backslashes and double-quotes so an odd folder name still yields valid JSON.
  pname="$(basename "$PWD")"
  pname="${pname//\\/\\\\}"
  pname="${pname//\"/\\\"}"
  cname="aicontainer-$pname"

  case "$shell" in
    zsh) font="'MesloLGS NF', monospace" ;;
    *)   font="monospace" ;;
  esac

  tmp="$(mktemp "${file}.XXXXXX")"
  AIC_DEVCONTAINER_NAME="$cname" \
  AIC_VSCODE_EXT_BLOCK="$ext_block" \
  AIC_VSCODE_SETTINGS_BLOCK="$settings_block" \
  awk -v tools="$tools" -v shell="$shell" -v font="$font" -v hc="$hc" -v hx="$hx" -v ov="$ov" '
    {
      # Rewrite the top-level "name" to the per-project label. Read from ENVIRON
      # (not -v) so awk does not re-interpret backslashes in the folder name.
      if ($0 ~ /^[[:space:]]*"name"[[:space:]]*:/) {
        print "  \"name\": \"" ENVIRON["AIC_DEVCONTAINER_NAME"] "\","
        next
      }

      # Merge project-owned extensions / settings just before their markers.
      # The blocks are built leading-comma (ENVIRON, so backslashes/quotes in
      # settings values survive) and empty when the source file is absent.
      if ($0 ~ /aic:vscode-extensions/) {
        if (ENVIRON["AIC_VSCODE_EXT_BLOCK"] != "") printf "%s\n", ENVIRON["AIC_VSCODE_EXT_BLOCK"]
        print
        next
      }
      if ($0 ~ /aic:vscode-settings/) {
        if (ENVIRON["AIC_VSCODE_SETTINGS_BLOCK"] != "") printf "%s\n", ENVIRON["AIC_VSCODE_SETTINGS_BLOCK"]
        print
        next
      }

      if ($0 ~ /^[[:space:]]*"anthropic\.claude-code",[[:space:]]*$/ && hc == 0) next
      if ($0 ~ /^[[:space:]]*"openai\.chatgpt",[[:space:]]*$/        && hx == 0) next

      # Per-shell terminal defaults (only act on the relevant lines so
      # generic "zsh" tokens elsewhere in the file are untouched).
      if ($0 ~ /"terminal\.integrated\.defaultProfile\.linux"/) {
        sub(/"zsh"/, "\"" shell "\"")
      }
      if ($0 ~ /"terminal\.integrated\.fontFamily"/) {
        sub(/:[[:space:]]*"[^"]*"/, ": \"" font "\"")
      }

      # Append the override to the dockerComposeFile array. Docker Compose
      # merges files left-to-right, so the override wins. Guarded so a file
      # that already lists it (or a hand-edited multi-entry array) is a no-op.
      if (ov == 1 && $0 ~ /"dockerComposeFile"/ && $0 !~ /docker-compose\.override\.yml/) {
        sub(/\]/, ", \"docker-compose.override.yml\"]")
      }

      print
      if ($0 ~ /^[[:space:]]*"containerEnv":[[:space:]]*\{[[:space:]]*$/) {
        print "    \"AIC_TOOLS\": \"" tools "\","
        print "    \"AIC_SHELL\": \"" shell "\","
      }
    }
  ' "$file" > "$tmp" && mv "$tmp" "$file"
}

# Copy template files into .devcontainer/, picking the right compose variant
# for $mode (pull or build) and only the files that mode needs. Idempotent:
# overwrites any existing target files; safe to call from init or sync.
apply_template () {
  local mode="$1" src="$2" dst="$3"
  [ -d "$src" ] || die "template not found at $src (set AIC_HOME or reinstall aic)"
  mkdir -p "$dst"

  cp "$src/devcontainer.json" "$dst/devcontainer.json"
  # Copied in both modes: an in-repo guide to which .devcontainer/ files are
  # aic-managed (overwritten on sync) vs project-owned (survive). Steers humans
  # and AI agents away from hand-editing devcontainer.json. Itself aic-managed.
  cp "$src/README.md" "$dst/README.md"
  case "$mode" in
    pull)
      cp "$src/docker-compose.pull.yml" "$dst/docker-compose.yml"
      # Pin the image tag to this aic's version so the CLI and the container
      # FS layout (hooks, sudoers, helper scripts) can't drift apart. Template
      # ships `:latest` as the canonical placeholder; we rewrite at copy time.
      local ver tmp
      ver="$(read_aic_version)"
      tmp="$(mktemp "$dst/docker-compose.yml.XXXXXX")"
      sed "s|ghcr.io/stefanoginella/aicontainer:latest|ghcr.io/stefanoginella/aicontainer:v${ver}|g" \
        "$dst/docker-compose.yml" > "$tmp" && mv "$tmp" "$dst/docker-compose.yml"
      ;;
    build)
      cp "$src/docker-compose.build.yml" "$dst/docker-compose.yml"
      cp "$src/Dockerfile"        "$dst/Dockerfile"
      cp "$src/post-create.py"    "$dst/post-create.py"
      cp "$src/aic-firewall"      "$dst/aic-firewall"
      cp "$src/aic-chown-volumes" "$dst/aic-chown-volumes"
      cp "$src/aic-lock-gitconfig" "$dst/aic-lock-gitconfig"
      cp "$src/.zshrc"            "$dst/.zshrc"
      rm -rf "$dst/hooks"
      cp -R "$src/hooks"          "$dst/hooks"
      ;;
    *) die "internal error: unknown mode '$mode'" ;;
  esac
}

cmd_init () {
  local mode="pull" force="" tools="" shell=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --build) mode="build" ;;
      --force) force="1" ;;
      --with)  shift; tools="${1:-}" ;;
      --with=*) tools="${1#--with=}" ;;
      --shell) shift; shell="${1:-}" ;;
      --shell=*) shell="${1#--shell=}" ;;
      *) die "unknown init flag: $1 (try --build, --force, --with claude-code,codex, or --shell zsh|bash|fish)" ;;
    esac
    shift
  done

  if [ -e .devcontainer ] && [ -z "$force" ]; then
    die ".devcontainer/ already exists. Re-run with --force to overwrite."
  fi

  if [ -z "$tools" ]; then
    tools="$(prompt_tools)"
  fi
  tools="$(validate_tools "$tools")"

  if [ -z "$shell" ]; then
    shell="$(prompt_shell)"
  fi
  shell="$(validate_shell "$shell")"

  apply_template "$mode" "$TEMPLATE_DIR" ".devcontainer"
  patch_devcontainer_json ".devcontainer/devcontainer.json" "$tools" "$shell"
  echo "aic: copied $mode-mode template into .devcontainer/ (tools: $tools, shell: $shell)"
  [ -f .devcontainer/docker-compose.override.yml ] && \
    echo "aic: wired existing docker-compose.override.yml into dockerComposeFile"
  if [ "$mode" = "pull" ]; then
    local ver; ver="$(read_aic_version)"
    echo "aic: 'aic up' will pull ghcr.io/stefanoginella/aicontainer:v${ver} (pinned to this aic's version)"
  else
    echo "aic: 'aic up' will build the image locally from .devcontainer/Dockerfile"
  fi
  echo "aic: next: 'aic up' then 'aic shell'"
}

# A project's Dockerfile.project is project-owned, so `aic sync` never rewrites
# it — but if it pins the aicontainer base to an explicit
# `FROM ghcr.io/stefanoginella/aicontainer:vX.Y.Z`, that tag silently lags the
# version this aic pins into docker-compose.yml after `npm update -g
# aicontainer`. And when an override's build: block points at Dockerfile.project,
# that stale FROM is what actually runs (the compose `image:` pin is bypassed),
# so the container can come up on an old base with none of the new behavior.
# Warn on that drift; rewrite it in place only when --bump-base is given, and
# only for a literal version pin — never :latest floats, ARG-templated bases,
# or other repos (those are deliberate, so we leave them alone).
check_dockerfile_project_base () {
  local ver="$1" do_bump="$2"
  local df=".devcontainer/Dockerfile.project"
  [ -f "$df" ] || return 0

  local repo="ghcr.io/stefanoginella/aicontainer"
  local cur_tag
  cur_tag="$(sed -n "s|^[[:space:]]*FROM[[:space:]]\{1,\}${repo}:\([A-Za-z0-9._-]\{1,\}\).*|\1|p" "$df" | head -n1)"
  [ -n "$cur_tag" ] || return 0          # no literal FROM <repo>:tag — templated, other repo, or absent

  local want="v${ver}"
  [ "$cur_tag" = "$want" ] && return 0    # already matches the pinned version

  if [ "$cur_tag" = "latest" ]; then
    echo "aic: note: Dockerfile.project tracks ${repo}:latest (floating base); not pinning it to :${want}" >&2
    return 0
  fi

  # Drift detected. With --bump-base we rewrite in place unconditionally.
  # Otherwise warn (yellow on a TTY) and, when stdin is interactive, offer to
  # bump now (default Yes). Non-interactive callers (CI, piped) are never
  # prompted: they get the command hint and the file is left untouched —
  # preserving the "plain sync only warns" contract the CLI tests guard.
  if [ -z "$do_bump" ]; then
    local y rst
    if [ -t 2 ]; then y=$'\033[33m'; rst=$'\033[0m'; else y=""; rst=""; fi
    printf "%saic: warning: Dockerfile.project pins %s:%s, but this aic pins :%s.%s\n" \
      "$y" "$repo" "$cur_tag" "$want" "$rst" >&2

    if [ -t 0 ]; then
      local reply
      printf "%saic: bump its FROM to :%s now? [Y/n]%s " "$y" "$want" "$rst" >&2
      IFS= read -r reply || reply=""
      case "$reply" in
        n|N|no|NO)
          echo "aic:          left as-is — bump later with 'aic sync --bump-base'." >&2
          return 0
          ;;
      esac
      do_bump=1
    else
      echo "aic:          Bump its FROM to match, or re-run 'aic sync --bump-base' to do it for you." >&2
      return 0
    fi
  fi

  # Escape dots for the sed LHS; anchor on the full repo path so the swap
  # can't hit an unrelated token. Replaces every occurrence (multi-stage
  # FROMs / an ARG default all reference the same base).
  local re_repo="${repo//./\\.}" re_tag="${cur_tag//./\\.}" tmp
  tmp="$(mktemp "${df}.XXXXXX")"
  sed "s|${re_repo}:${re_tag}|${repo}:${want}|g" "$df" > "$tmp" && mv "$tmp" "$df"
  echo "aic: bumped Dockerfile.project base → ${repo}:${want} (was :${cur_tag})"
}

cmd_sync () {
  require_in_project
  local mode="" tools="" shell="" bump_base=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --pull)  mode="pull" ;;
      --build) mode="build" ;;
      --with)  shift; tools="${1:-}" ;;
      --with=*) tools="${1#--with=}" ;;
      --shell) shift; shell="${1:-}" ;;
      --shell=*) shell="${1#--shell=}" ;;
      --bump-base) bump_base="1" ;;
      *) die "unknown sync flag: $1 (try --pull, --build, --bump-base, --with claude-code,codex, or --shell zsh|bash|fish)" ;;
    esac
    shift
  done

  # Auto-detect: presence of .devcontainer/Dockerfile means the project was
  # initialized with --build; otherwise it's the pull-mode 2-file layout.
  if [ -z "$mode" ]; then
    if [ -f .devcontainer/Dockerfile ]; then
      mode="build"
    else
      mode="pull"
    fi
  fi

  # Preserve the existing tool selection across syncs unless overridden with
  # --with. Projects created before AIC_TOOLS existed fall back to both tools.
  if [ -z "$tools" ]; then
    tools="$(read_aic_tools)"
    [ -n "$tools" ] || tools="claude-code,codex"
  fi
  tools="$(validate_tools "$tools")"

  # Same pattern for shell. Projects created before AIC_SHELL existed get
  # the default (zsh), matching their pre-flag behavior.
  if [ -z "$shell" ]; then
    shell="$(read_aic_shell)"
    [ -n "$shell" ] || shell="$DEFAULT_SHELL"
  fi
  shell="$(validate_shell "$shell")"

  apply_template "$mode" "$TEMPLATE_DIR" ".devcontainer"
  patch_devcontainer_json ".devcontainer/devcontainer.json" "$tools" "$shell"
  echo "aic: synced $mode-mode template → .devcontainer/ (tools: $tools, shell: $shell)"
  echo "aic: review with 'git diff -- .devcontainer/', then 'aic rebuild'"
  echo "aic: project-owned files (Dockerfile.project, firewall-allowlist, chown-paths, post-create.project.sh, docker-compose.override.yml, vscode-extensions, vscode-settings.json) are untouched"
  if [ -f .devcontainer/docker-compose.override.yml ]; then
    echo "aic: wired docker-compose.override.yml into dockerComposeFile"
  fi
  if [ -f .devcontainer/vscode-extensions ]; then
    local extcount; extcount="$(grep -cvE '^[[:space:]]*(#|$)' .devcontainer/vscode-extensions 2>/dev/null)" || extcount=0
    echo "aic: merged vscode-extensions ($extcount listed) into customizations.vscode.extensions"
  fi
  if [ -f .devcontainer/vscode-settings.json ]; then
    echo "aic: merged vscode-settings.json into customizations.vscode.settings"
  fi
  check_dockerfile_project_base "$(read_aic_version)" "$bump_base"
}

# Best-effort detection of whether the opt-in outbound firewall is currently
# active inside the running container. Echoes on|off|unknown and never fails.
# The firewall is enable-only, never auto-applied, and does not survive
# container recreation, so "off"/"unknown" is the safe (network-open) default.
# `sudo aic-firewall status` is one of the three scoped-sudo scripts, so this
# read is allowed; when enabled the OUTPUT chain shows `policy DROP`.
firewall_live_state () {
  local project cid devcontainer out
  project="$(project_name)"
  cid="$(docker compose -p "$project" -f .devcontainer/docker-compose.yml ps -q devcontainer 2>/dev/null)" || { echo unknown; return; }
  [ -n "$cid" ] || { echo unknown; return; }
  [ "$(docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null)" = "true" ] || { echo unknown; return; }
  devcontainer="$(resolve_devcontainer 2>/dev/null)" || { echo unknown; return; }
  out="$("$devcontainer" exec --workspace-folder . sudo aic-firewall status 2>/dev/null)" || { echo unknown; return; }
  if grep -q 'policy DROP' <<<"$out"; then echo on; else echo off; fi
}

# Print the trust boundary for the current project: what the agent can read,
# write, and reach. Folded into `aic up` (mode=up always reports network OPEN,
# since a freshly (re)created container never has the opt-in firewall applied)
# and exposed standalone as `aic preflight` (live-detects the firewall state).
# The protections are real but otherwise invisible — this makes them auditable.
# See the README "What crosses the boundary" table for the canonical version.
print_boundary_summary () {
  local mode="${1:-preflight}" project net
  project="$(project_name)"
  if [ "$mode" = "up" ]; then net="open"; else net="$(firewall_live_state)"; fi

  local b r y dim rst
  if [ -t 1 ]; then
    b=$'\033[1m'; r=$'\033[31m'; y=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m'
  else
    b=""; r=""; y=""; dim=""; rst=""
  fi

  printf "\n%saic: trust boundary for project '%s'%s\n\n" "$b" "$project" "$rst"

  printf "  %sFilesystem%s\n" "$b" "$rst"
  printf "    rw   /workspace                               your project (the only writable host path)\n"
  printf "    ro   ~/.gitconfig ~/.p10k.zsh ~/.zshrc.local    shell look-and-feel\n"
  printf "    ro   ~/.claude/settings.json ~/.codex/config.toml   AI-config seeds (allowlisted fields only)\n"
  printf "    ro   .devcontainer/ .git/config .git/hooks     AI cannot rewrite its own sandbox\n"
  printf "    %s--   host home, ~/.ssh, host credentials: NOT mounted%s\n" "$dim" "$rst"

  printf "\n  %sSecrets%s\n" "$b" "$rst"
  printf "    .env* reads/writes blocked by the PreToolUse hook (fires even in bypass mode)\n"
  printf "    %syour project's own .env still lives in /workspace: the hook gates the agent, not the file%s\n" "$dim" "$rst"
  printf "    git credentials: none forwarded (you auth fresh inside); ~/.gitconfig.local is root-locked\n"

  # Commit signing: the host signing key is never forwarded, so signing only
  # works here via a sandbox key set up with `aic signing`. Surface the state
  # so a host that signs commits doesn't hit a cryptic failure (or silently
  # push unsigned commits) without warning.
  local sg_st sg_mode sg_key sg_host
  sg_st="$(signing_state 2>/dev/null)" || sg_st="mode= key=0"
  sg_mode="${sg_st#mode=}"; sg_mode="${sg_mode%% key=*}"
  sg_key="${sg_st##*key=}"
  sg_host="$(git config --global --get commit.gpgsign 2>/dev/null || true)"
  if [ "$sg_mode" = "disabled" ]; then
    printf "    commit signing: disabled in sandbox (via 'aic signing')\n"
  elif [ "$sg_key" = "1" ]; then
    printf "    commit signing: sandbox ssh key (register its pubkey on GitHub to verify)\n"
  elif [ "$sg_host" = "true" ]; then
    printf "    %s! commit signing: host signs, but its key isn't here%s — commits unsigned; run 'aic signing'\n" "$y" "$rst"
  fi

  printf "\n  %sSessions%s\n" "$b" "$rst"
  if docker volume inspect "${project}_aic-sessions" >/dev/null 2>&1; then
    printf "    transcripts -> volume %s_aic-sessions  (survives rebuild; cleared only by 'aic destroy')\n" "$project"
  else
    printf "    transcripts -> volume %s_aic-sessions  (created on first 'aic up'; survives rebuild)\n" "$project"
  fi

  printf "\n  %sNetwork%s\n" "$b" "$rst"
  case "$net" in
    on)
      printf "    %s* firewall ENABLED%s  (default DROP + curated allowlist active)\n" "$b" "$rst"
      ;;
    unknown)
      printf "    %s! full outbound by default%s: internet, your LAN, cloud metadata (169.254.169.254)\n" "$y" "$rst"
      printf "    %s(container not running: live firewall state unknown)%s\n" "$dim" "$rst"
      printf "    lock down:  aic shell  ->  sudo aic-firewall enable\n"
      ;;
    *)
      printf "    %s! FULL OUTBOUND%s: reaches the internet, your LAN, and cloud metadata (169.254.169.254)\n" "$r" "$rst"
      printf "    lock down per-project:  aic shell  ->  sudo aic-firewall enable\n"
      ;;
  esac

  if [ -f .devcontainer/docker-compose.override.yml ] || [ -f .devcontainer/firewall-allowlist ]; then
    printf "\n  %sProject overrides%s\n" "$b" "$rst"
    if [ -f .devcontainer/docker-compose.override.yml ]; then
      printf "    %s! docker-compose.override.yml present%s: may add mounts/env/hosts; review it.\n" "$y" "$rst"
    fi
    if [ -f .devcontainer/firewall-allowlist ]; then
      local fwcount; fwcount="$(grep -cvE '^[[:space:]]*(#|$)' .devcontainer/firewall-allowlist 2>/dev/null)" || fwcount=0
      printf "    firewall-allowlist present (%s domains): applied when you enable the firewall.\n" "$fwcount"
    fi
  fi

  printf "\n  %sFull model:%s README \"Threat model\" + \"What crosses the boundary\".\n\n" "$dim" "$rst"
}

# ---------------------------------------------------------------------------
# Commit signing inside the sandbox.
#
# The host's signing key never enters the container — ~/.ssh and the SSH agent
# are deliberately NOT forwarded (see the threat model). So a host configured
# for SSH/GPG commit signing can't sign here: every `git commit` would fail
# with "Couldn't find key in agent". `aic signing` fixes that without weakening
# the boundary, by provisioning a *sandbox-only* ed25519 signing key inside the
# aic-auth-global volume (register its pubkey on GitHub once, as a Signing Key).
# post-create wires it into the container-local gitconfig on the next (re)create
# — nothing is forwarded and the host gitconfig stays read-only.
#
# Mode is persisted as ~/.config/aic-auth/signing/mode in the volume:
#   auto     generate a sandbox signing key (recommended)
#   byok     install a separate signing key you provide
#   disable  turn commit signing off inside the sandbox
# `status` reports the current state. Mutating actions run as `vscode` inside
# the running container (so the key lands with correct owner + 0600 perms), so
# they need the container up; `status` reads the volume directly and works
# anytime. Because the wiring lives in the root-locked ~/.gitconfig.local (only
# regenerated at create time), a mode change takes effect on the next rebuild —
# no live edit, hence no privileged unlock primitive to abuse.
# ---------------------------------------------------------------------------

# Read the persisted signing state from the aic-auth-global volume without
# requiring a running container (or auto-creating the volume). Echoes
# "mode=<m> key=<0|1>"; always succeeds.
signing_state () {
  docker volume inspect aic-auth-global >/dev/null 2>&1 || { printf "mode= key=0"; return; }
  docker run --rm -v aic-auth-global:/v busybox sh -c \
    'printf "mode=%s key=%s" "$(cat /v/signing/mode 2>/dev/null)" "$(test -f /v/signing/id_ed25519 && echo 1 || echo 0)"' \
    2>/dev/null || printf "mode= key=0"
}

# True when this project's devcontainer is up (mutating signing actions exec
# into it). Mirrors firewall_live_state's running-container probe.
signing_container_running () {
  local project cid
  project="$(project_name)"
  cid="$(docker compose -p "$project" -f .devcontainer/docker-compose.yml ps -q devcontainer 2>/dev/null)" || return 1
  [ -n "$cid" ] || return 1
  [ "$(docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null)" = "true" ]
}

signing_print_status () {
  local st mode key host_sign
  st="$(signing_state)"
  mode="${st#mode=}"; mode="${mode%% key=*}"
  key="${st##*key=}"
  host_sign="$(git config --global --get commit.gpgsign 2>/dev/null || true)"
  printf "aic: commit signing (sandbox-only, shared across projects)\n"
  if [ "$mode" = "disabled" ]; then
    printf "  mode: disabled — commits are unsigned inside the sandbox\n"
  elif [ "$key" = "1" ]; then
    printf "  mode: %s — sandbox ssh signing key present\n" "${mode:-auto}"
    printf "  note: register its pubkey on GitHub as a Signing Key if you haven't (else commits show Unverified)\n"
  elif [ "$host_sign" = "true" ]; then
    printf "  mode: unconfigured — your host signs commits, but that key isn't in the sandbox\n"
    printf "  note: commits are UNSIGNED here; run 'aic signing auto' to provision a sandbox key\n"
  else
    printf "  mode: unconfigured — host doesn't sign commits, nothing to set up\n"
  fi
}

# Simple numbered chooser (host TTY). Echoes the chosen action; returns 1 on
# cancel. Kept deliberately plain — no raw-mode arrow UI needed for 3 options.
signing_menu () {
  local reply
  printf "\nSet up sandbox commit signing:\n" >&2
  printf "  1) auto     generate a sandbox signing key (recommended)\n" >&2
  printf "  2) byok     install a signing key you provide\n" >&2
  printf "  3) disable  turn commit signing off in the sandbox\n" >&2
  printf "  q) cancel\n" >&2
  printf "choice [1]: " >&2
  IFS= read -r reply || reply=""
  case "$reply" in
    ""|1) echo auto ;;
    2)    echo byok ;;
    3)    echo disable ;;
    q|Q)  return 1 ;;
    *)    die "invalid choice: $reply" ;;
  esac
}

signing_do_auto () {
  local devcontainer="$1" register="$2" pub
  # Generate (or reuse) the key as vscode inside the container; emit only the
  # public key on stdout so we can capture it, diagnostics on stderr.
  pub="$("$devcontainer" exec --workspace-folder . bash -lc '
    set -euo pipefail
    dir="$HOME/.config/aic-auth/signing"; key="$dir/id_ed25519"
    mkdir -p "$dir"
    if [ ! -f "$key" ]; then
      ssh-keygen -t ed25519 -N "" -C "aicontainer sandbox signing key" -f "$key" >/dev/null
      echo "aic: generated sandbox signing key" >&2
    else
      echo "aic: reusing existing sandbox signing key" >&2
    fi
    chmod 600 "$key"
    printf auto > "$dir/mode"
    cat "$key.pub"
  ')" || die "failed to set up the signing key inside the container"

  printf "\naic: sandbox signing key ready:\n\n  %s\n\n" "$pub"
  if [ "$register" = "1" ]; then
    signing_register "$devcontainer"
  else
    printf "aic: register it on GitHub as a *Signing Key* (not Authentication):\n"
    printf "       https://github.com/settings/ssh/new\n"
    printf "aic: until then, commits sign locally but show \"Unverified\" on GitHub.\n"
    printf "aic: (or re-run 'aic signing auto --register' to add it via gh.)\n"
  fi
  printf "aic: then 'aic rebuild' (or VS Code: Rebuild Container) to activate signing.\n"
}

signing_register () {
  local devcontainer="$1"
  printf "aic: registering the signing key on GitHub via gh...\n"
  if "$devcontainer" exec --workspace-folder . bash -lc '
    set -euo pipefail
    gh api --method POST /user/ssh_signing_keys \
      -f "title=aicontainer ($(hostname))" \
      -f "key=$(cat "$HOME/.config/aic-auth/signing/id_ed25519.pub")" >/dev/null
  '; then
    printf "aic: registered as a signing key on your GitHub account.\n"
  else
    printf "aic: gh registration failed — most likely the gh token lacks the\n"
    printf "     'write:ssh_signing_key' scope. Grant it and retry:\n"
    printf "       aic shell  ->  gh auth refresh -h github.com -s write:ssh_signing_key\n"
    printf "     then re-run 'aic signing auto --register', or add the key by hand:\n"
    printf "       https://github.com/settings/ssh/new\n"
  fi
}

signing_do_byok () {
  local devcontainer="$1"
  if [ ! -t 0 ]; then
    # A private key is piped on stdin — install it (and derive the pubkey).
    "$devcontainer" exec --workspace-folder . bash -lc '
      set -euo pipefail
      dir="$HOME/.config/aic-auth/signing"; key="$dir/id_ed25519"
      mkdir -p "$dir"
      cat > "$key"
      chmod 600 "$key"
      ssh-keygen -y -f "$key" > "$key.pub"
      printf byok > "$dir/mode"
      echo "aic: installed your signing key:" >&2
      ssh-keygen -lf "$key.pub" >&2
    ' || die "failed to install the provided key (must be an unencrypted private key)"
    printf "aic: register the pubkey on GitHub as a Signing Key: https://github.com/settings/ssh/new\n"
    printf "aic: then 'aic rebuild' to activate signing.\n"
    return
  fi
  # No piped key — record the mode and explain how to drop one in.
  "$devcontainer" exec --workspace-folder . bash -lc '
    set -euo pipefail
    dir="$HOME/.config/aic-auth/signing"; mkdir -p "$dir"; printf byok > "$dir/mode"
  ' || die "failed to set signing mode in the container"
  cat <<'BYOK_EOF'
aic: bring-your-own-key mode set. Provide a *separate* signing key (not your
     host key) one of two ways:
       1) pipe it:   aic signing byok < /path/to/your_signing_key
       2) or place it in the container at
            ~/.config/aic-auth/signing/id_ed25519   (chmod 600)
          e.g. via 'aic shell'; aic derives the .pub if it's absent.
aic: register the pubkey on GitHub as a Signing Key, then 'aic rebuild'.
BYOK_EOF
}

signing_do_disable () {
  local devcontainer="$1"
  "$devcontainer" exec --workspace-folder . bash -lc '
    set -euo pipefail
    dir="$HOME/.config/aic-auth/signing"; mkdir -p "$dir"; printf disabled > "$dir/mode"
  ' || die "failed to set signing mode in the container"
  printf "aic: commit signing will be DISABLED inside the sandbox.\n"
  printf "aic: run 'aic rebuild' (or VS Code: Rebuild Container) to apply.\n"
}

cmd_signing () {
  require_in_project
  local action="" register=0
  while [ $# -gt 0 ]; do
    case "$1" in
      auto|byok|disable|status) action="$1" ;;
      --register) register=1 ;;
      *) die "unknown signing argument: $1 (try: auto, byok, disable, status [--register])" ;;
    esac
    shift
  done

  # No explicit action: print status, then offer the chooser on a TTY.
  if [ -z "$action" ]; then
    signing_print_status
    if [ -t 0 ] && [ -t 1 ]; then
      action="$(signing_menu)" || { echo "aic: cancelled."; return 0; }
    else
      return 0
    fi
  fi

  if [ "$action" = "status" ]; then
    signing_print_status
    return 0
  fi

  # Mutating actions run inside the container (as vscode) — needs it up.
  if ! signing_container_running; then
    die "container not running — run 'aic up' first, then 'aic signing $action'"
  fi
  local devcontainer; devcontainer="$(resolve_devcontainer)"
  case "$action" in
    auto)    signing_do_auto "$devcontainer" "$register" ;;
    byok)    signing_do_byok "$devcontainer" ;;
    disable) signing_do_disable "$devcontainer" ;;
  esac
}

cmd_up () {
  require_in_project
  local devcontainer; devcontainer="$(resolve_devcontainer)"
  "$devcontainer" up --workspace-folder . "$@"
  print_boundary_summary up
}

cmd_preflight () {
  require_in_project
  print_boundary_summary preflight
}

cmd_rebuild () {
  require_in_project
  # Pull-mode compose pins `pull_policy: missing`, so `devcontainer up` reuses
  # the cached image even with --remove-existing-container — meaning newer
  # GHCR releases would be silently ignored. Force a pull so `aic rebuild`
  # actually refreshes the image. In --build mode the devcontainer service
  # has a `build:` block (no top-level `image:` on it), so we skip the pull
  # branch entirely; --build-no-cache below does the refresh for that path.
  # Detect mode via presence of Dockerfile, which apply_template only copies
  # in --build mode.
  if [ ! -f .devcontainer/Dockerfile ]; then
    local image
    image="$(awk '/^[[:space:]]*image:/ {sub(/^[[:space:]]*image:[[:space:]]*/, ""); sub(/[[:space:]]*#.*$/, ""); sub(/[[:space:]]+$/, ""); print; exit}' .devcontainer/docker-compose.yml 2>/dev/null || true)"
    if [ -n "$image" ]; then
      echo "aic: pulling $image"
      docker pull "$image" || echo "aic: docker pull failed; continuing with cached image" >&2
    fi
  fi
  local devcontainer; devcontainer="$(resolve_devcontainer)"
  "$devcontainer" up --workspace-folder . --remove-existing-container --build-no-cache "$@"
}

cmd_shell () {
  require_in_project
  local devcontainer shell
  devcontainer="$(resolve_devcontainer)"
  # Honor the per-project shell choice (AIC_SHELL in devcontainer.json).
  # Older projects without the field fall back to zsh — that's what they had
  # baked in pre-flag.
  shell="$(read_aic_shell)"
  [ -n "$shell" ] || shell="$DEFAULT_SHELL"
  "$devcontainer" exec --workspace-folder . "$shell" -l "$@"
}

cmd_run () {
  require_in_project
  local devcontainer; devcontainer="$(resolve_devcontainer)"
  "$devcontainer" exec --workspace-folder . "$@"
}

cmd_down () {
  require_in_project
  local project; project="$(project_name)"
  docker compose -p "$project" -f .devcontainer/docker-compose.yml down
}

# Best-effort on-disk size of a named volume, e.g. "123.4MB". Echoes "" if the
# volume doesn't exist or docker can't tell us. Uses `docker system df -v`
# (docker's own accounting — no helper container, works on a stopped container),
# matching the volume by exact name. Never blocks the caller.
volume_size () {
  local vol="$1"
  command -v docker >/dev/null 2>&1 || { echo ""; return; }
  docker volume inspect "$vol" >/dev/null 2>&1 || { echo ""; return; }
  docker system df -v 2>/dev/null | awk -v v="$vol" '$1==v {print $NF; found=1} END{if(!found) print ""}'
}

cmd_destroy () {
  require_in_project
  local project assume_yes=0
  while [ $# -gt 0 ]; do
    case "$1" in
      -y|--yes) assume_yes=1 ;;
      *) die "unknown destroy flag: $1 (try --yes)" ;;
    esac
    shift
  done
  project="$(project_name)"
  local vol="${project}_aic-sessions" size
  size="$(volume_size "$vol")"

  echo "aic: 'aic destroy' will remove the container and these PER-PROJECT volumes for '$project':"
  if [ -n "$size" ]; then
    echo "       $vol  ($size of Claude/Codex session transcripts)"
  else
    echo "       $vol  (Claude/Codex session transcripts)"
  fi
  echo "aic: global volumes aic-auth-global and aic-shell-history are PRESERVED."
  echo "aic: this is irreversible. To stop the container but KEEP history, use 'aic down' instead."

  # Confirm interactively (stdin is a TTY). Non-interactive callers (CI, piped)
  # proceed unprompted, preserving prior behavior; pass --yes to skip the prompt
  # explicitly. Default answer is No.
  if [ "$assume_yes" -ne 1 ] && [ -t 0 ]; then
    local reply
    printf "aic: destroy '%s' and delete the above? [y/N] " "$project"
    IFS= read -r reply || reply=""
    case "$reply" in
      y|Y|yes|YES) ;;
      *) echo "aic: aborted — nothing removed."; return 0 ;;
    esac
  fi

  docker compose -p "$project" -f .devcontainer/docker-compose.yml down --volumes --remove-orphans
  docker volume rm "$vol" 2>/dev/null || true
  echo "aic: destroyed '$project' (container + per-project volumes removed)."
}

cmd_upgrade () {
  if [ -d "$AIC_HOME/.git" ]; then
    echo "aic: pulling latest aicontainer from $AIC_HOME"
    git -C "$AIC_HOME" pull --ff-only
  else
    echo "aic: installed via npm. Upgrade with:"
    echo "    npm update -g aicontainer"
    echo "aic: then in each project: 'aic sync' (re-pin compose to new version)"
    echo "aic: followed by 'aic rebuild' (pull the new image and recreate the container)"
  fi
}

cmd_version () {
  echo "aic v$(read_aic_version)"
}

cmd_help () {
  cat <<'EOF'
aic — sandboxed devcontainer for running Claude Code + Codex in bypass mode

Usage:
  aic init [--build] [--force] [--with TOOLS] [--shell SHELL]
                       Copy the aicontainer template into ./.devcontainer/.
                       Default: pull ghcr.io/stefanoginella/aicontainer:vX.Y.Z
                       where X.Y.Z is this aic's own version (pinned, not
                       :latest — `npm update -g aicontainer` + `aic sync`
                       is the upgrade path).
                       --build: full local build from template/Dockerfile (slow
                                first run, but you own the image).
                       --with:  comma-separated tools to enable for this project
                                (claude-code, codex, or both). Defaults to an
                                interactive checkbox prompt; selecting both is
                                the historical behavior. The choice is stored
                                as containerEnv.AIC_TOOLS in devcontainer.json.
                       --shell: interactive shell — zsh (default, with oh-my-zsh
                                + powerlevel10k), bash, or fish. Defaults to an
                                interactive radio prompt. Stored as
                                containerEnv.AIC_SHELL; 'aic shell' and the
                                VS Code default terminal profile honor it.
  aic sync [--pull|--build] [--bump-base] [--with TOOLS] [--shell SHELL]
                       Re-copy the aicontainer template files into an existing
                       ./.devcontainer/, overwriting them. Auto-detects the
                       mode from whether .devcontainer/Dockerfile exists; pass
                       --pull or --build to force one. Existing AIC_TOOLS /
                       AIC_SHELL selections are preserved unless overridden.
                       Project-owned files (Dockerfile.project, firewall-
                       allowlist, chown-paths, post-create.project.sh,
                       docker-compose.override.yml, vscode-extensions,
                       vscode-settings.json) are untouched; an existing
                       docker-compose.override.yml is auto-wired into
                       dockerComposeFile, and vscode-extensions /
                       vscode-settings.json are merged into the editor
                       customizations, so per-project tweaks survive the
                       sync. Useful after
                       editing template/ in a checkout — saves a manual cp -R.
                       Review with 'git diff' afterward.
                       --bump-base: if Dockerfile.project pins an explicit
                                FROM ghcr.io/stefanoginella/aicontainer:vX.Y.Z,
                                rewrite that tag to this aic's version without
                                prompting. (Without the flag, an interactive
                                sync warns about the drift and offers to bump;
                                non-interactive syncs just warn. :latest floats
                                and ARG-templated bases are left alone.)
  aic up               Pull/build and start the devcontainer for this project
                       (prints the trust boundary summary when it comes up)
  aic preflight        Print this project's trust boundary: what the agent can
                       read (mounts), what's blocked (.env/secrets), where
                       sessions persist, and whether outbound network is open
                       or firewalled. Read-only; also shown after 'aic up'.
  aic signing [ACTION] Set up commit signing inside the sandbox. The host's
                       signing key is never forwarded, so a host that signs
                       commits can't sign here without this. ACTION is one of:
                         status   (default) show the current signing state
                         auto     generate a sandbox-only ssh signing key
                                  (register its pubkey on GitHub as a Signing
                                  Key). --register adds it via gh for you
                                  (needs the write:ssh_signing_key scope).
                         byok     install a separate signing key you provide
                                  ('aic signing byok < your_key', or drop it in)
                         disable  turn commit signing off inside the sandbox
                       With no ACTION on a TTY, prints status then offers a
                       chooser. Mutating actions need the container up; the
                       choice persists in the aic-auth-global volume and takes
                       effect on the next 'aic rebuild'.
  aic shell            Open the configured interactive shell (AIC_SHELL) inside the devcontainer
  aic run CMD ...      Run CMD inside the devcontainer
  aic rebuild          Recreate the container from a fresh image
  aic down             Stop the container (volumes preserved)
  aic destroy [--yes]  Stop + remove container + per-project volumes
                       (global auth + shell history are preserved). Shows the
                       session-transcript volume size and confirms first (the
                       removal is irreversible); --yes / -y skips the prompt.
  aic upgrade          Update aic itself (git pull or 'npm update -g')
  aic completion SHELL Emit tab-completion script for bash, zsh, or fish.
                       Install (one-shot, recommended):
                         echo 'eval "$(aic completion zsh)"'  >> ~/.zshrc
                         echo 'eval "$(aic completion bash)"' >> ~/.bashrc
                         aic completion fish > ~/.config/fish/completions/aic.fish
  aic version          Print this aic's version (also: --version, -v)
  aic help             Show this help

Env:
  AIC_HOME             Where the aicontainer files live (default: this script's dir)
EOF
}

cmd_completion () {
  local shell="${1:-}"
  case "$shell" in
    bash) _completion_bash ;;
    zsh)  _completion_zsh  ;;
    fish) _completion_fish ;;
    "")   die "usage: aic completion <bash|zsh|fish>" ;;
    *)    die "unsupported shell: $shell (supported: bash, zsh, fish)" ;;
  esac
}

_completion_bash () {
  cat <<'BASH_EOF'
# aic bash completion. Install with:
#   echo 'eval "$(aic completion bash)"' >> ~/.bashrc
_aic_complete () {
  local cur prev cmd
  COMPREPLY=()
  cur="${COMP_WORDS[COMP_CWORD]}"
  prev="${COMP_WORDS[COMP_CWORD-1]}"

  local commands="init sync up preflight signing shell run rebuild down destroy upgrade completion version help"
  local tools="claude-code codex claude-code,codex"
  local shells="zsh bash fish"

  if [ "$COMP_CWORD" -eq 1 ]; then
    COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
    return
  fi

  cmd="${COMP_WORDS[1]}"
  case "$cmd" in
    init)
      case "$prev" in
        --with)  COMPREPLY=( $(compgen -W "$tools"  -- "$cur") ); return ;;
        --shell) COMPREPLY=( $(compgen -W "$shells" -- "$cur") ); return ;;
      esac
      COMPREPLY=( $(compgen -W "--build --force --with --shell" -- "$cur") )
      ;;
    sync)
      case "$prev" in
        --with)  COMPREPLY=( $(compgen -W "$tools"  -- "$cur") ); return ;;
        --shell) COMPREPLY=( $(compgen -W "$shells" -- "$cur") ); return ;;
      esac
      COMPREPLY=( $(compgen -W "--pull --build --bump-base --with --shell" -- "$cur") )
      ;;
    signing)
      COMPREPLY=( $(compgen -W "auto byok disable status --register" -- "$cur") )
      ;;
    destroy)
      COMPREPLY=( $(compgen -W "--yes" -- "$cur") )
      ;;
    completion)
      [ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
      ;;
  esac
}
complete -F _aic_complete aic
complete -F _aic_complete aicontainer
BASH_EOF
}

_completion_zsh () {
  cat <<'ZSH_EOF'
#compdef aic aicontainer
# aic zsh completion. Install with:
#   echo 'eval "$(aic completion zsh)"' >> ~/.zshrc
# Or save this output to a file named _aic in any directory on $fpath.
_aic () {
  local -a commands tools shells
  commands=(
    'init:Copy template into ./.devcontainer/'
    'sync:Re-copy template into existing ./.devcontainer/'
    'up:Pull/build and start the devcontainer'
    'preflight:Print this project'\''s trust boundary (mounts, secrets, network, sessions)'
    'signing:Set up sandbox commit signing (auto/byok/disable/status)'
    'shell:Open an interactive shell inside the devcontainer'
    'run:Run a command inside the devcontainer'
    'rebuild:Recreate the container from a fresh image'
    'down:Stop the container (volumes preserved)'
    'destroy:Stop + remove container + per-project volumes'
    'upgrade:Update aic itself'
    'completion:Emit shell completion script'
    'version:Print this aic'\''s version'
    'help:Show help'
  )
  tools=('claude-code' 'codex' 'claude-code,codex')
  shells=('zsh' 'bash' 'fish')

  _arguments -C \
    '1: :->command' \
    '*::arg:->args'

  case $state in
    command)
      _describe -t commands 'aic command' commands
      ;;
    args)
      case $words[1] in
        init)
          _arguments \
            '--build[full local build from template/Dockerfile]' \
            '--force[overwrite existing .devcontainer/]' \
            '--with[comma-separated tools]:tools:('"${tools[*]}"')' \
            '--shell[interactive shell]:shell:('"${shells[*]}"')'
          ;;
        sync)
          _arguments \
            '--pull[force pull-mode template]' \
            '--build[force build-mode template]' \
            '--bump-base[bump Dockerfile.project FROM tag to this aic version]' \
            '--with[comma-separated tools]:tools:('"${tools[*]}"')' \
            '--shell[interactive shell]:shell:('"${shells[*]}"')'
          ;;
        signing)
          _arguments \
            '--register[register the signing key on GitHub via gh]' \
            '1:action:(auto byok disable status)'
          ;;
        destroy)
          _arguments '(--yes -y)'{--yes,-y}'[skip the confirmation prompt]'
          ;;
        completion)
          _values 'shell' bash zsh fish
          ;;
      esac
      ;;
  esac
}

# Bind the function whether this script is eval'd or autoloaded from $fpath.
compdef _aic aic aicontainer 2>/dev/null
ZSH_EOF
}

_completion_fish () {
  cat <<'FISH_EOF'
# aic fish completion. Install with:
#   aic completion fish > ~/.config/fish/completions/aic.fish
set -l aic_cmds init sync up preflight signing shell run rebuild down destroy upgrade completion version help

for cmd in aic aicontainer
  complete -c $cmd -f
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a init     -d 'Copy template into ./.devcontainer/'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a sync     -d 'Re-copy template into existing ./.devcontainer/'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a up       -d 'Start the devcontainer'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a preflight -d 'Print this project\'s trust boundary (mounts, secrets, network)'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a signing  -d 'Set up sandbox commit signing'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a shell    -d 'Open the configured shell inside the devcontainer'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a run      -d 'Run a command inside the devcontainer'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a rebuild  -d 'Recreate the container from a fresh image'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a down     -d 'Stop the container'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a destroy  -d 'Stop + remove container + per-project volumes'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a upgrade  -d 'Update aic itself'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a completion -d 'Emit shell completion script'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a version  -d 'Print this aic\'s version'
  complete -c $cmd -n "not __fish_seen_subcommand_from $aic_cmds" -a help     -d 'Show help'

  complete -c $cmd -n "__fish_seen_subcommand_from init" -l build -d 'full local build from template/Dockerfile'
  complete -c $cmd -n "__fish_seen_subcommand_from init" -l force -d 'overwrite existing .devcontainer/'
  complete -c $cmd -n "__fish_seen_subcommand_from init" -l with  -d 'comma-separated tools' -xa 'claude-code codex claude-code,codex'
  complete -c $cmd -n "__fish_seen_subcommand_from init" -l shell -d 'interactive shell' -xa 'zsh bash fish'

  complete -c $cmd -n "__fish_seen_subcommand_from sync" -l pull  -d 'force pull-mode template'
  complete -c $cmd -n "__fish_seen_subcommand_from sync" -l build -d 'force build-mode template'
  complete -c $cmd -n "__fish_seen_subcommand_from sync" -l bump-base -d 'bump Dockerfile.project FROM tag to this aic version'
  complete -c $cmd -n "__fish_seen_subcommand_from sync" -l with  -d 'comma-separated tools' -xa 'claude-code codex claude-code,codex'
  complete -c $cmd -n "__fish_seen_subcommand_from sync" -l shell -d 'interactive shell' -xa 'zsh bash fish'

  complete -c $cmd -n "__fish_seen_subcommand_from signing" -a 'auto byok disable status'
  complete -c $cmd -n "__fish_seen_subcommand_from signing" -l register -d 'register the signing key on GitHub via gh'

  complete -c $cmd -n "__fish_seen_subcommand_from destroy" -l yes -s y -d 'skip the confirmation prompt'

  complete -c $cmd -n "__fish_seen_subcommand_from completion" -a 'bash zsh fish'
end
FISH_EOF
}

case "${1:-help}" in
  init)    shift; cmd_init "$@" ;;
  sync)    shift; cmd_sync "$@" ;;
  up)      shift; cmd_up "$@" ;;
  preflight) shift; cmd_preflight "$@" ;;
  signing) shift; cmd_signing "$@" ;;
  shell)   shift; cmd_shell "$@" ;;
  run)     shift; cmd_run "$@" ;;
  rebuild) shift; cmd_rebuild "$@" ;;
  down)    shift; cmd_down "$@" ;;
  destroy) shift; cmd_destroy "$@" ;;
  upgrade) shift; cmd_upgrade "$@" ;;
  completion) shift; cmd_completion "$@" ;;
  version|-v|--version) cmd_version ;;
  help|-h|--help) cmd_help ;;
  *) err "unknown command: $1"; cmd_help; exit 1 ;;
esac
