#!/usr/bin/env bash
# ===========================================================================
#  coo.ee/env — composable dev-environment bootstrapper
# ---------------------------------------------------------------------------
#  This is the HEADER fragment. The hosted service concatenates:
#       _header.sh  +  <module>.sh ...  +  _footer.sh
#  to produce the script served at  https://env.coo.ee/<modules>.
#  The checked-in file  java,android  is one such rendering.
#  Edit fragments here; do not hand-edit the rendered artifact.
#  See README.md.
# ===========================================================================
set -euo pipefail

COOEE_VERSION="0.1.0"

# ---- GitHub Actions awareness ---------------------------------------------
# GitHub is just another cloud target, but it has its own log + env protocol.
# Inside a runner we mirror our output into the workflow log: collapsible
# ::group:: sections per module (see _footer) and ::warning::/::error::
# annotations that surface on the run summary. No-op everywhere else.
COOEE_GHA=0
[[ "${GITHUB_ACTIONS:-}" == "true" ]] && COOEE_GHA=1
gha_group()    { [[ $COOEE_GHA == 1 ]] && printf '::group::%s\n' "$*" >&2 || true; }
gha_endgroup() { [[ $COOEE_GHA == 1 ]] && printf '::endgroup::\n'     >&2 || true; }

# ---- logging --------------------------------------------------------------
if [[ -t 2 ]]; then
  _c_red=$'\033[31m'; _c_yel=$'\033[33m'; _c_grn=$'\033[32m'
  _c_blu=$'\033[34m'; _c_rst=$'\033[0m'
else
  _c_red=; _c_yel=; _c_grn=; _c_blu=; _c_rst=
fi
log()  { printf '%s[coo.ee]%s %s\n'   "$_c_blu" "$_c_rst" "$*" >&2; }
ok()   { printf '%s[coo.ee] OK%s %s\n' "$_c_grn" "$_c_rst" "$*" >&2; }
warn() { printf '%s[coo.ee] !!%s %s\n' "$_c_yel" "$_c_rst" "$*" >&2
         [[ $COOEE_GHA == 1 ]] && printf '::warning::%s\n' "$*" >&2 || true; }
die()  { printf '%s[coo.ee] XX%s %s\n' "$_c_red" "$_c_rst" "$*" >&2
         [[ $COOEE_GHA == 1 ]] && printf '::error::%s\n' "$*" >&2 || true; exit 1; }

# ---- persisted environment ------------------------------------------------
# Everything we export is also written to a profile file so a *new* shell can
# pick it up, and forwarded to the host harness env files when present
# (Claude Code SessionStart: $CLAUDE_ENV_FILE, GitHub Actions: $GITHUB_ENV).
#
# Two files are kept: COOEE_PROFILE is `export KEY=value` (source-able in a
# shell), COOEE_HARNESS_ENV is raw `KEY=value` (the format harness env files
# want). They are truncated lazily — only when we actually (re)provision — so
# the already-provisioned short-circuit can replay the PREVIOUS run's values.
COOEE_PROFILE="${COOEE_PROFILE:-$HOME/.config/coo-ee/env.sh}"
COOEE_HARNESS_ENV="${COOEE_HARNESS_ENV:-$HOME/.config/coo-ee/env.harness}"
mkdir -p "$(dirname "$COOEE_PROFILE")"

cooee_init_profile() { : > "$COOEE_PROFILE"; : > "$COOEE_HARNESS_ENV"; }

# Upsert a raw KEY=value line into whichever harness env files this run has
# (Claude Code's $CLAUDE_ENV_FILE, GitHub Actions' $GITHUB_ENV). Claude Code
# re-runs this whole bootstrap on every SessionStart — including resume and
# compact — and inlines $CLAUDE_ENV_FILE into the preamble of *every* Bash
# call. A naive `>>` re-appends the same lines on each re-fire, so the file —
# and thus every spawned command string — grows without bound until it trips
# the ~128 KiB argv limit (E2BIG) even though the resulting environment is
# tiny (hundreds of duplicate lines collapse to the same few vars). Upsert
# instead: rewrite the target so each key appears exactly once, which both
# caps growth and heals a file already bloated by the old append behaviour.
cooee_forward_to_harness() {  # cooee_forward_to_harness KEY=value
  local kv=$1 key=${1%%=*}
  cooee_env_file_upsert "${CLAUDE_ENV_FILE:-}" "$key" "$kv"
  cooee_env_file_upsert "${GITHUB_ENV:-}"     "$key" "$kv"
  return 0
}

# The counterpart: drop every `KEY=…` line from the harness env files, for a
# variable a PREVIOUS run forwarded and this one has decided must not be set.
# Harness files are upserted rather than rebuilt (see above), so without this a
# retired value outlives the run that retired it — and since Claude Code replays
# the file as the preamble of every Bash call, it would keep poisoning every
# command in the session. No-op when the key isn't there.
cooee_unforward_from_harness() {  # cooee_unforward_from_harness KEY
  cooee_env_file_delete "${CLAUDE_ENV_FILE:-}" "$1"
  cooee_env_file_delete "${GITHUB_ENV:-}"      "$1"
  return 0
}

# Heal a harness env file another tool poisoned. yschimke/skills' installer
# (before it learned to quote) appended two kinds of line to it:
#   * `JAVA_TOOL_OPTIONS=-D… -D…` unquoted, so every Bash call Claude Code makes
#     ran the second flag as a command ("-Dhttps.proxyPort=…: command not
#     found"), one more copy per container start; and
#   * `PATH=~/.local/bin:<jdk>/bin:$PATH`, which put whichever JDK it found
#     ahead of the one JAVA_HOME names, so `java` and JAVA_HOME disagreed.
# This script writes neither (JAVA_TOOL_OPTIONS is shell-only, and its PATH lines
# are expanded, never a literal $PATH), so such lines are someone else's breakage.
cooee_heal_harness_env() {
  local file="${CLAUDE_ENV_FILE:-}" tmp
  local jto="^JAVA_TOOL_OPTIONS=[^'\"][^ ]* "
  local jdk="^PATH=${HOME}/\.local/bin:[^:]*(jvm|jdk|java)[^:]*/bin:.*\\\$PATH\$"
  [[ -n "$file" && -f "$file" ]] || return 0
  grep -qE -- "$jto|$jdk" "$file" 2>/dev/null || return 0
  tmp=$(mktemp "${file}.cooee.XXXXXX" 2>/dev/null) || return 0
  grep -vE -- "$jto|$jdk" "$file" > "$tmp" 2>/dev/null || true
  mv -f "$tmp" "$file"
  ok "Removed lines another installer left in the Claude Code env file (an unquoted JAVA_TOOL_OPTIONS ran as commands in every shell; a PATH entry put a different JDK ahead of JAVA_HOME's)."
}

# Remove every `KEY=…` line from FILE, atomically. No-op when FILE is empty
# (harness absent), missing, or carries no such key.
cooee_env_file_delete() {  # cooee_env_file_delete FILE KEY
  local file=$1 key=$2
  [[ -n "$file" && -e "$file" ]] || return 0
  grep -q -- "^${key}=" "$file" 2>/dev/null || return 0
  local tmp
  tmp=$(mktemp "${file}.cooee.XXXXXX" 2>/dev/null) || return 0
  grep -v -- "^${key}=" "$file" > "$tmp" 2>/dev/null || true
  mv -f "$tmp" "$file"
}

# Replace every existing `KEY=…` line in FILE with LINE (appending if none was
# present), leaving all other lines — including keys written by other harness
# hooks — untouched. No-op when FILE is empty (harness absent). Atomic via a
# sibling temp file + rename so a concurrent reader never sees a torn file.
cooee_env_file_upsert() {  # cooee_env_file_upsert FILE KEY LINE
  local file=$1 key=$2 line=$3
  [[ -n "$file" ]] || return 0
  if [[ ! -e "$file" ]]; then
    printf '%s\n' "$line" >> "$file"
    return 0
  fi
  local tmp
  tmp=$(mktemp "${file}.cooee.XXXXXX" 2>/dev/null) || {
    # Couldn't stage a rewrite — fall back to a plain append so the value is at
    # least present, rather than silently dropping it.
    printf '%s\n' "$line" >> "$file"
    return 0
  }
  grep -v -- "^${key}=" "$file" > "$tmp" 2>/dev/null || true
  printf '%s\n' "$line" >> "$tmp"
  mv -f "$tmp" "$file"
}

add_env() {  # add_env KEY VALUE — export now and persist for later shells
  local key=$1 val=$2
  export "${key}=${val}"
  printf 'export %s=%q\n' "$key" "$val" >> "$COOEE_PROFILE"
  cooee_env_is_shell_only "$key" && return 0
  printf '%s=%s\n'        "$key" "$val" >> "$COOEE_HARNESS_ENV"
  cooee_forward_to_harness "${key}=${val}"
}

# Variables that must NOT reach a harness env file, because harnesses replay
# those files as *unquoted shell*, one `KEY=value` line at a time.
#
# A value containing whitespace then stops being one assignment. Claude Code
# inlines the file as the preamble of every Bash call, so
#
#   JAVA_TOOL_OPTIONS=-Dhttp.proxyHost=p -Dhttp.proxyPort=8080 -Dhttp.nonProxyHosts=a|b
#
# assigns only the first token, runs `-Dhttp.proxyPort=8080` as a command, and
# then splits the pipe-separated nonProxyHosts list into a pipeline of more
# bogus commands — spraying dozens of "command not found" lines into the result
# of *every* command the agent runs, for the rest of the session. The variable
# is still set correctly in the shell; the damage is pure output pollution, and
# it drowns real output. (`cooee_trust_cas_in_jdk` already worked around one
# instance of this by keeping its own option whitespace-free; this generalises
# the rule instead of re-discovering it per option.)
#
# Skipped keys are still exported for this run and still written to
# $COOEE_PROFILE (`export KEY=%q`, properly quoted), so a shell that sources the
# profile gets them. What replaces the harness path for Gradle — the consumer
# that actually needs these flags — is $GRADLE_USER_HOME/gradle.properties; see
# cooee_gradle_props_jvmargs.
COOEE_ENV_SHELL_ONLY="${COOEE_ENV_SHELL_ONLY:-JAVA_TOOL_OPTIONS}"

cooee_env_is_shell_only() {  # <key>
  local k
  for k in $COOEE_ENV_SHELL_ONLY; do [[ "$1" == "$k" ]] && return 0; done
  return 1
}

# JAVA_TOOL_OPTIONS as it was when this script started, so the footer can put it
# back before exiting. Claude Code derives its replay file from the *difference*
# this hook makes to the environment, so a variable we leave exactly as we found
# it never enters the preamble at all — which is the only way to keep the
# container's own (equally space-laden) value out of it too.
COOEE_ORIG_JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS-}"
COOEE_ORIG_JAVA_TOOL_OPTIONS_SET=0
[[ -n "${JAVA_TOOL_OPTIONS+x}" ]] && COOEE_ORIG_JAVA_TOOL_OPTIONS_SET=1

# Restore JAVA_TOOL_OPTIONS to its entry value. Called from the footer once all
# provisioning (which legitimately needs the enriched value for its own JVM
# calls) is done.
cooee_restore_java_tool_options() {
  if (( COOEE_ORIG_JAVA_TOOL_OPTIONS_SET )); then
    export JAVA_TOOL_OPTIONS="$COOEE_ORIG_JAVA_TOOL_OPTIONS"
  else
    unset JAVA_TOOL_OPTIONS
  fi
}

# Every JVM flag the cloud fixes added this run, so the java module can also pin
# them through Gradle's own channel (org.gradle.jvmargs in the user-dir
# gradle.properties). That file is read by the Gradle client, its daemon, and
# every forked worker, needs no environment inheritance, and — unlike a harness
# env file — is not parsed as shell, so spaces in the value are simply fine.
COOEE_JVM_FLAGS=""

# Append <flags> to JAVA_TOOL_OPTIONS (for this run and the persisted profile)
# and record them for the Gradle-properties pin. Single entry point so a new
# cloud fix can't add a flag that reaches one channel but not the other.
cooee_add_jvm_flag() {  # <flag> [<flag> ...]
  local flags="$*"
  [[ -n "$flags" ]] || return 0
  add_env JAVA_TOOL_OPTIONS "${JAVA_TOOL_OPTIONS:+$JAVA_TOOL_OPTIONS }$flags"
  COOEE_JVM_FLAGS="${COOEE_JVM_FLAGS:+$COOEE_JVM_FLAGS }$flags"
}

# Short-circuit replay: re-export the last run's env into THIS session without
# reinstalling anything.
cooee_forward_persisted_env() {
  # shellcheck disable=SC1090
  [[ -f "$COOEE_PROFILE" ]] && . "$COOEE_PROFILE" || true
  if [[ -f "$COOEE_HARNESS_ENV" ]]; then
    local line
    while IFS= read -r line; do
      [[ -n "$line" ]] && cooee_forward_to_harness "$line"
    done < "$COOEE_HARNESS_ENV"
  fi
}

# ---- activation: make the env auto-apply without a manual `source` ----------
# Persisting the env to a file isn't enough on its own — a future shell or agent
# session still has to load it. So, as part of a normal run, we wire that up:
#   * append a guarded line to the common shell rc files (every future shell
#     sources the persisted env), and
#   * install/merge a Claude Code SessionStart hook into the *consuming* project
#     so future web sessions re-run this same bootstrap (idempotent, cheap).
# This is generic plumbing for whatever project pulls in coo.ee/env — never tied
# to one repo. GitHub Actions has its own activation ($GITHUB_ENV/$GITHUB_PATH),
# so we skip it there; COOEE_NO_ACTIVATE=1 opts out everywhere.
COOEE_BASE_URL="${COOEE_BASE_URL:-https://env.coo.ee}"

# Rebuild the canonical request segment (modules + their params) so a hook can
# re-run exactly what was asked for, e.g. "java[17,21],android[34]".
cooee_request_segment() {
  local m seg=() p
  for m in "${MODULES[@]}"; do
    [[ "$m" == base ]] && continue
    p="${_MODULE_PARAMS[$m]:-}"
    if [[ -n "$p" ]]; then seg+=("$m[$p]"); else seg+=("$m"); fi
  done
  local IFS=,; printf '%s' "${seg[*]}"
}

# ---- cumulative requests ---------------------------------------------------
# A box is usually provisioned more than once with *different* requests: the
# environment's setup asked for `java,android`, then a Ruby project asks for
# `ruby,postgres`. Each request used to behave as if it were the whole box — it
# truncated the persisted env (fresh shells lost JAVA_HOME), wrote a stamp naming
# only itself, and added a second SessionStart hook beside the first — after
# which the two hooks invalidated each other's stamp on every session, so
# neither ever took the fast path. So the box's request is cumulative: a new
# request is merged with the previous one (per module, the newer request's
# params win), the merged request is what the stamp and the one SessionStart
# hook carry, and the env the previous modules persisted is kept. COOEE_REPLACE=1
# makes a request replace the previous one instead (to drop modules).
COOEE_REQUEST_FILE="${COOEE_REQUEST_FILE:-$HOME/.config/coo-ee/request}"

# The previous request segment: the one recorded by the last full run, else the
# one an older run baked into the global SessionStart hook. Empty on a fresh box
# or with COOEE_REPLACE=1.
cooee_previous_segment() {
  [[ "${COOEE_REPLACE:-0}" == 1 ]] && return 0
  if [[ -s "$COOEE_REQUEST_FILE" ]]; then
    head -1 "$COOEE_REQUEST_FILE"; return 0
  fi
  local settings; settings="$(cooee_global_claude_dir)/settings.json"
  [[ -f "$settings" ]] || return 0
  local pfx="curl -fsSL ${COOEE_BASE_URL}/" line seg
  while IFS= read -r line || [[ -n "$line" ]]; do   # a last line may lack its newline
    [[ "$line" == *"$pfx"* ]] || continue
    seg=${line#*"$pfx"}; seg=${seg%%[ \\\"?|]*}
    [[ -n "$seg" ]] && { printf '%s' "$seg"; return 0; }
  done < "$settings"
  return 0
}

# Split a segment on its top-level commas: "java[17,21],node" -> java[17,21] / node.
cooee_segment_tokens() {  # cooee_segment_tokens <segment>  (one token per line)
  local seg=$1 tok="" depth=0 i c
  for (( i = 0; i < ${#seg}; i++ )); do
    c=${seg:i:1}
    case "$c" in
      "[") depth=$((depth + 1)); tok+=$c ;;
      "]") depth=$((depth - 1)); tok+=$c ;;
      ",") if (( depth == 0 )); then [[ -n "$tok" ]] && printf '%s\n' "$tok"; tok=""; else tok+=$c; fi ;;
      *)   tok+=$c ;;
    esac
  done
  [[ -n "$tok" ]] && printf '%s\n' "$tok"
  return 0
}

# Union two segments by module name, the newer one's token winning for a module
# both name; sorted by name, `base` dropped (it is always implied).
cooee_merge_segments() {  # cooee_merge_segments <older> <newer>
  declare -A tokens=()
  local tok name
  while IFS= read -r tok; do
    [[ -n "$tok" ]] || continue
    name=${tok%%\[*}; [[ "$name" == base ]] && continue
    tokens[$name]=$tok
  done < <(cooee_segment_tokens "$1"; cooee_segment_tokens "$2")
  local out="" n
  for n in $(printf '%s\n' "${!tokens[@]}" | LC_ALL=C sort); do out+="${out:+,}${tokens[$n]}"; done
  printf '%s' "$out"
}

# The segment the SessionStart hook re-runs: the merged request once main has
# worked it out (COOEE_HOOK_SEGMENT), else this run's own.
cooee_hook_segment() {
  if [[ -n "${COOEE_HOOK_SEGMENT:-}" ]]; then printf '%s' "$COOEE_HOOK_SEGMENT"
  else cooee_request_segment; fi
}

# Append a guarded activation block to the usual shell rc files. Idempotent via
# marker lines; touches ~/.zshrc only when zsh is actually in play.
cooee_install_shell_rc() {
  local begin='# >>> coo.ee/env >>>' end='# <<< coo.ee/env <<<'
  local block="${begin}
[ -f \"${COOEE_PROFILE}\" ] && . \"${COOEE_PROFILE}\"
${end}"
  local -a rcs=("$HOME/.bashrc" "$HOME/.profile")
  [[ -f "$HOME/.zshrc" || "${SHELL:-}" == *zsh || -n "${ZSH_VERSION:-}" ]] && rcs+=("$HOME/.zshrc")
  local rc updated=0
  for rc in "${rcs[@]}"; do
    if [[ -f "$rc" ]] && grep -qF "$begin" "$rc" 2>/dev/null; then
      updated=1; continue   # already activated here
    fi
    printf '\n%s\n' "$block" >> "$rc" && { ok "activation: ${rc/#$HOME/\~} now sources the persisted env."; updated=1; }
  done
  (( updated )) || warn "activation: could not update any shell rc file."
}

# The global Claude config dir — $HOME/.claude — is the *environment's* settings
# home, not a project's. Overridable with CLAUDE_CONFIG_DIR (Claude Code's own
# override) so a non-default config location is still targeted correctly.
cooee_global_claude_dir() {
  printf '%s' "${CLAUDE_CONFIG_DIR:-${HOME%/}/.claude}"
}

# ---- Agent homes ----------------------------------------------------------
# Skills and environment-wide rules only help an agent that reads them, and
# each agent reads its own home:
#
#   Claude Code   skills  ~/.claude/skills    rules  ~/.claude/CLAUDE.md
#   Codex         skills  ~/.agents/skills    rules  $CODEX_HOME/AGENTS.md
#   Gemini CLI /  skills  ~/.agents/skills    rules  ~/.gemini/GEMINI.md
#   Antigravity
#
# OpenCode reads ~/.claude/skills and ~/.claude/CLAUDE.md itself, so it needs
# nothing extra. It also scans ~/.agents/skills, so on a box that has OpenCode
# beside Codex or Gemini it sees each skill twice; COOEE_AGENTS=none avoids that
# at the cost of the other agents' copy. Claude Code is always targeted — it is what the SessionStart hook
# re-provisions. Codex and Gemini count when their CLI is on PATH, their config
# dir exists, or they are the provider this run detected; COOEE_AGENTS (e.g.
# `codex,gemini`, or `none`) replaces that detection.
cooee_agent_present() {  # cooee_agent_present <codex|gemini>
  if [[ -n "${COOEE_AGENTS:-}" ]]; then
    [[ ",${COOEE_AGENTS}," == *",$1,"* ]]; return
  fi
  case "$1" in
    codex)  [[ "$COOEE_PROVIDER" == codex ]] || command -v codex >/dev/null 2>&1 \
              || [[ -d "${CODEX_HOME:-$HOME/.codex}" ]] ;;
    gemini) [[ "$COOEE_PROVIDER" == gemini ]] || command -v gemini >/dev/null 2>&1 \
              || [[ -d "$HOME/.gemini" ]] ;;
    *) return 1 ;;
  esac
}

# Every skills dir to link into, one per line.
cooee_skill_dirs() {
  printf '%s\n' "$HOME/.claude/skills"
  if cooee_agent_present codex || cooee_agent_present gemini; then
    printf '%s\n' "$HOME/.agents/skills"
  fi
}

# Every global rules file to write an environment-wide guide into, one per line.
cooee_agent_rules_files() {
  printf '%s\n' "$(cooee_global_claude_dir)/CLAUDE.md"
  if cooee_agent_present codex; then
    # Codex reads only the first non-empty of AGENTS.override.md, AGENTS.md.
    local codex="${CODEX_HOME:-$HOME/.codex}"
    if [[ -s "$codex/AGENTS.override.md" ]]; then
      printf '%s\n' "$codex/AGENTS.override.md"
    else
      printf '%s\n' "$codex/AGENTS.md"
    fi
  fi
  if cooee_agent_present gemini; then
    local gemini_name
    while IFS= read -r gemini_name; do
      printf '%s\n' "$HOME/.gemini/$gemini_name"
    done < <(cooee_gemini_context_files)
  fi
}

# The global context file(s) to write for Gemini, one per line. Gemini CLI loads
# the names in `context.fileName` (a string or a list; older releases used a
# top-level `contextFileName`), defaulting to GEMINI.md, and the effective value
# comes from the highest-priority settings layer that sets it: a checkout's
# .gemini/settings.json or the system settings outrank ~/.gemini/settings.json.
# A project override cannot be known here (it differs per checkout), so GEMINI.md
# is always written as the fallback, and the system then user settings name a
# second file when they choose another one. With a list, GEMINI.md is kept when
# it is in it, else the first name is taken. A whole-string `$VAR`, `${VAR}` or
# `${VAR:-default}` is expanded the way Gemini expands it; anything that is not
# then a plain file name, or settings we cannot parse (no jq/python3/node, or
# JSON with comments), adds nothing beyond GEMINI.md.
cooee_gemini_context_files() {
  printf 'GEMINI.md\n'
  local settings name=""
  for settings in "${GEMINI_CLI_SYSTEM_SETTINGS_PATH:-/etc/gemini-cli/settings.json}" \
                  "$HOME/.gemini/settings.json"; do
    name=$(cooee_gemini_settings_file_name "$settings")
    [[ -n "$name" ]] && break
  done
  if [[ "$name" =~ ^\$\{?([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}?$ ]]; then
    local var="${BASH_REMATCH[1]}" def="${BASH_REMATCH[3]}"
    name="${!var:-$def}"
  fi
  case "$name" in
    ""|GEMINI.md|*/*|*'$'*|.|..) : ;;
    *) printf '%s\n' "$name" ;;
  esac
}

# The `context.fileName` one settings file sets, or nothing.
cooee_gemini_settings_file_name() {  # <settings.json>
  local settings="$1"
  [[ -f "$settings" ]] || return 0
  if command -v jq >/dev/null 2>&1; then
    jq -r '(.context.fileName // .contextFileName // empty)
           | if type == "array" then (if index("GEMINI.md") then "GEMINI.md" else .[0] end)
             else . end // empty' "$settings" 2>/dev/null
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
v = (d.get("context") or {}).get("fileName") or d.get("contextFileName")
if isinstance(v, list):
    v = "GEMINI.md" if "GEMINI.md" in v else (v[0] if v else None)
if isinstance(v, str):
    print(v)' "$settings" 2>/dev/null
  elif command -v node >/dev/null 2>&1; then
    node -e '
      const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
      let v = (d.context || {}).fileName || d.contextFileName;
      if (Array.isArray(v)) v = v.includes("GEMINI.md") ? "GEMINI.md" : v[0];
      if (typeof v === "string") console.log(v);' "$settings" 2>/dev/null
  fi
  return 0
}

# Symlink one skill directory into every agent's skills dir, so a re-run that
# re-pulls the repo updates them all. A real directory already there belongs to
# another installer (npx skills, the compose-preview installer) and is kept:
# `ln -sfn` would otherwise link *inside* it.
#
# Claude Code is skipped for a skill an enabled Claude plugin already ships
# (yschimke/skills' `compose-skills` bundle, say): a second copy under
# ~/.claude/skills would load every skill twice. A link an earlier run left there
# is removed; anything else is left alone.
cooee_link_skill() {  # cooee_link_skill <skill dir>
  local src="$1" name d; name=$(basename "$src")
  while IFS= read -r d; do
    if [[ "$d" == "$HOME/.claude/skills" ]] && cooee_claude_plugin_provides "$name"; then
      cooee_is_cooee_link "$d/$name" && rm -f "$d/$name"
      log "skill: $name comes from an enabled Claude plugin; not linking it into ~/.claude/skills."
      continue
    fi
    mkdir -p "$d" || { warn "skill: couldn't create $d"; continue; }
    if [[ -d "$d/$name" && ! -L "$d/$name" ]]; then
      warn "skill: kept ${d/#$HOME/\~}/$name, installed by something else."
      continue
    fi
    ln -sfn "$src" "$d/$name"
  done < <(cooee_skill_dirs)
}

# Every skill directory in a cloned repo, one per skill name. A skills repo can
# carry the same skill twice — yschimke/skills keeps `skills/<name>` and a
# generated copy under `plugins/<bundle>/skills/<name>` for its marketplace —
# so prefer a path outside a `plugins/` dir and drop later duplicates. With a
# <name>, print only that skill.
cooee_find_skills() {  # cooee_find_skills <repo dir> [name]
  local dir="$1" only="${2:-}"
  find "$dir" -name SKILL.md -not -path '*/.git/*' 2>/dev/null \
    | awk '{ print (index($0, "/plugins/") ? 1 : 0) "\t" $0 }' | sort \
    | while IFS=$'\t' read -r _ md; do
        local d; d=$(dirname "$md")
        printf '%s\t%s\n' "$(basename "$d")" "$d"
      done \
    | awk -F'\t' -v only="$only" '(only == "" || $1 == only) && !seen[$1]++ { print $2 }'
}

# True when <name> is linked (or otherwise installed) in every agent's skills dir.
# For Claude Code, an enabled plugin that ships the skill counts, unless a
# coo.ee link to the same skill is still there: then the box is not provisioned
# (the plugin was enabled after the link was made), so the next run takes the
# full path and cooee_link_skill removes the duplicate.
cooee_skill_linked() {  # cooee_skill_linked <name>
  local d
  while IFS= read -r d; do
    if [[ "$d" == "$HOME/.claude/skills" ]] && cooee_claude_plugin_provides "$1"; then
      cooee_is_cooee_link "$d/$1" && return 1
      continue
    fi
    [[ -f "$d/$1/SKILL.md" ]] || return 1
  done < <(cooee_skill_dirs)
}

# True when <path> is a symlink this script made (into its own skill-repo cache).
cooee_is_cooee_link() {  # cooee_is_cooee_link <path>
  [[ -L "$1" && "$(readlink "$1")" == "$HOME/.cache/coo-ee/"* ]]
}

# The Claude Code plugins enabled in the global settings (`enabledPlugins`),
# one `<plugin>@<marketplace>` key per line.
cooee_claude_enabled_plugins() {
  local settings; settings="$(cooee_global_claude_dir)/settings.json"
  [[ -f "$settings" ]] || return 0
  if command -v jq >/dev/null 2>&1; then
    jq -r '(.enabledPlugins // {}) | to_entries[] | select(.value == true) | .key' \
      "$settings" 2>/dev/null
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c '
import json, sys
for k, v in (json.load(open(sys.argv[1])).get("enabledPlugins") or {}).items():
    if v is True:
        print(k)' "$settings" 2>/dev/null
  elif command -v node >/dev/null 2>&1; then
    node -e '
      const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).enabledPlugins || {};
      for (const [k, v] of Object.entries(p)) if (v === true) console.log(k);' \
      "$settings" 2>/dev/null
  fi
  return 0
}

# True when an enabled Claude plugin ships skill <name>. A plugin's skills are
# read from Claude Code's plugin cache for that plugin's own marketplace
# (plugins/cache/<marketplace>/<plugin>/<version>/skills/). Until the first
# session has filled that cache, the `plugins/<plugin>/skills/` bundles of the
# skill repos this script cloned stand in (yschimke/skills generates its
# marketplace bundles there); once the cache exists, only the cache counts.
cooee_claude_plugin_provides() {  # cooee_claude_plugin_provides <name>
  local name="$1" key plugin market cache f
  while IFS= read -r key; do
    [[ "$key" == *@* ]] || continue
    plugin="${key%@*}" market="${key##*@}"
    [[ -n "$plugin" && -n "$market" && "$plugin$market" != */* ]] || continue
    cache="$(cooee_global_claude_dir)/plugins/cache/$market/$plugin"
    if [[ -d "$cache" ]]; then
      for f in "$cache"/*/skills/"$name"/SKILL.md; do
        [[ -f "$f" ]] && return 0
      done
    else
      for f in "$HOME"/.cache/coo-ee/skills/*/plugins/"$plugin"/skills/"$name"/SKILL.md; do
        [[ -f "$f" ]] && return 0
      done
    fi
  done < <(cooee_claude_enabled_plugins)
  return 1
}

# Install/merge the SessionStart hook AND the toolchain's permission allowlist
# into the user's GLOBAL Claude config ($HOME/.claude/settings.json) — the
# environment, never a project checkout.
#
# A cloud session checks out one or more repos side by side, each with its own
# *git-tracked* .claude/settings.json. Writing the hook + permissions into those
# dirties committed files: every provisioned repo shows spurious changes in
# `git status` and the agent is nagged to commit environment plumbing that
# doesn't belong in the repo. The global config, by contrast, is part of the
# environment (the container's home) and applies to every session regardless of
# which repo it opens — so a single merge here provisions the toolchain
# everywhere without touching a single working tree. It merges intelligently:
# existing keys and permission rules are preserved and unioned, never clobbered.
cooee_install_session_hook() {
  cooee_install_session_hook_one "$(cooee_global_claude_dir)"
}

# Install/merge the hook + permissions into <claude_dir>/settings.json (the arg
# is a .claude config dir, e.g. ~/.claude). Uses jq/python3/node to merge an
# existing file; writes a fresh one otherwise; warns (with the snippet) if a
# file exists but no JSON tool is available.
cooee_install_session_hook_one() {
  local claude_dir="$1"
  local seg cmd settings="$claude_dir/settings.json" perms_json
  seg="$(cooee_hook_segment)"
  perms_json="$(cooee_perms_json)"
  # Degrade gracefully: if a future session can't reach the service (offline, or
  # env.coo.ee not on that environment's allowlist), the hook logs and continues
  # rather than failing session startup. The trailing `|| echo` guarantees a 0
  # exit even under `set -o pipefail`, where `curl -f` failing would otherwise
  # propagate through the pipe and abort the session.
  cmd="curl -fsSL ${COOEE_BASE_URL}/${seg} | bash || echo 'coo.ee/env: setup skipped (offline or host not allowlisted)' >&2"

  # Nothing to do if the hook is already wired and every permission rule we'd
  # add is already listed. Checking the raw rule strings keeps this cheap and
  # tool-free (the merge itself dedupes, so a false "missing" only re-merges).
  # A box has exactly one coo.ee hook — the cumulative request — so the hook is
  # only "already wired" when it is the only one; an older request's hook beside
  # it is replaced below.
  local pfx="curl -fsSL ${COOEE_BASE_URL}/"
  if [[ -f "$settings" ]] && grep -qF "$cmd" "$settings" 2>/dev/null \
     && [[ "$(grep -oF "$pfx" "$settings" 2>/dev/null | wc -l)" -eq 1 ]]; then
    local rule missing=0
    while IFS= read -r rule; do
      [[ -z "$rule" ]] && continue
      grep -qF "$rule" "$settings" 2>/dev/null || { missing=1; break; }
    done < <(cooee_collect_perms)
    if (( ! missing )); then
      ok "activation: SessionStart hook + permissions already present in ${settings/#$HOME/\~}."
      return 0
    fi
  fi
  mkdir -p "$claude_dir"

  if [[ ! -f "$settings" ]]; then
    local perms_block=""
    [[ "$perms_json" != "[]" ]] && perms_block=",
  \"permissions\": { \"allow\": ${perms_json} }"
    cat > "$settings" <<JSON
{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "${cmd}" } ] }
    ]
  }${perms_block}
}
JSON
    ok "activation: wrote SessionStart hook + permissions to ${settings/#$HOME/\~}."
    return 0
  fi

  # Merge into the existing settings without clobbering other keys: drop any
  # coo.ee hook (an earlier, narrower request's) in favour of this one, leave
  # every other SessionStart hook alone, and union the permission rules (dedup).
  local add_hook=1
  local tmp; tmp="$(mktemp)"
  if command -v jq >/dev/null 2>&1; then
    if jq --arg c "$cmd" --arg pfx "$pfx" --argjson add_hook "$add_hook" --argjson perms "$perms_json" '
        (if $add_hook == 1 then
           .hooks //= {} | .hooks.SessionStart //= []
           | .hooks.SessionStart |= map(select([.hooks[]?.command // "" | startswith($pfx)] | any | not))
           | .hooks.SessionStart += [ { hooks: [ { type: "command", command: $c } ] } ]
         else . end)
        | (if ($perms | length) > 0 then
             .permissions //= {} | .permissions.allow //= []
             | .permissions.allow = (.permissions.allow + $perms | unique)
           else . end)' \
        "$settings" > "$tmp" 2>/dev/null && mv "$tmp" "$settings"; then
      ok "activation: merged SessionStart hook + permissions into ${settings/#$HOME/\~} (jq)."; return 0
    fi
  elif command -v python3 >/dev/null 2>&1; then
    if python3 - "$settings" "$cmd" "$add_hook" "$perms_json" "$pfx" <<'PY' 2>/dev/null
import json, sys
path, cmd, add_hook, perms, pfx = sys.argv[1], sys.argv[2], sys.argv[3], json.loads(sys.argv[4]), sys.argv[5]
with open(path) as f: data = json.load(f)
if add_hook == "1":
    hooks = data.setdefault("hooks", {}).setdefault("SessionStart", [])
    hooks[:] = [e for e in hooks
                if not any(str(h.get("command", "")).startswith(pfx) for h in e.get("hooks", []))]
    hooks.append({"hooks": [{"type": "command", "command": cmd}]})
if perms:
    allow = data.setdefault("permissions", {}).setdefault("allow", [])
    data["permissions"]["allow"] = sorted(set(allow) | set(perms))
with open(path, "w") as f:
    json.dump(data, f, indent=2); f.write("\n")
PY
    then ok "activation: merged SessionStart hook + permissions into ${settings/#$HOME/\~} (python3)."; return 0; fi
  elif command -v node >/dev/null 2>&1; then
    if node -e '
        const fs = require("fs"), [p, c, addHook, permsRaw, pfx] = process.argv.slice(1);
        const d = JSON.parse(fs.readFileSync(p, "utf8")), perms = JSON.parse(permsRaw);
        if (addHook === "1") {
          (d.hooks ||= {}).SessionStart ||= [];
          d.hooks.SessionStart = d.hooks.SessionStart.filter((e) =>
            !(e.hooks || []).some((h) => String(h.command || "").startsWith(pfx)));
          d.hooks.SessionStart.push({ hooks: [{ type: "command", command: c }] });
        }
        if (perms.length) {
          (d.permissions ||= {}).allow ||= [];
          d.permissions.allow = [...new Set([...d.permissions.allow, ...perms])].sort();
        }
        fs.writeFileSync(p, JSON.stringify(d, null, 2) + "\n");
      ' "$settings" "$cmd" "$add_hook" "$perms_json" "$pfx" 2>/dev/null; then
      ok "activation: merged SessionStart hook + permissions into ${settings/#$HOME/\~} (node)."; return 0; fi
  fi
  rm -f "$tmp"
  warn "activation: ${settings/#$HOME/\~} exists but no jq/python3/node to merge it safely."
  warn "activation: add this SessionStart hook command manually: ${cmd}"
}

# Run both activation steps unless opted out / on GitHub Actions.
cooee_install_activation() {
  [[ "${COOEE_NO_ACTIVATE:-0}" == 1 ]] && { log "activation: skipped (COOEE_NO_ACTIVATE=1)."; return 0; }
  [[ "$COOEE_GHA" == 1 ]] && { log "activation: GitHub Actions uses \$GITHUB_ENV/\$GITHUB_PATH; skipping shell-rc/hook install."; return 0; }
  cooee_install_shell_rc
  cooee_install_session_hook
}

# ---- privilege helper -----------------------------------------------------
# Run a command as root: directly when already root, via sudo when available,
# otherwise return 127 so the caller can degrade gracefully (warn, not die).
cooee_sudo() {
  if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then "$@"
  elif command -v sudo >/dev/null 2>&1; then sudo "$@"
  else return 127; fi
}

# ---- provisioning backend (resolved at render time) -----------------------
# The renderer splices in exactly one backend driver fragment right after this
# header — _backend-nix.sh by default, _backend-devenv.sh for a `?devenv`
# request. Each defines nix_ensure (install a nixpkgs package) plus the
# cooee_backend_* hooks the modules and base call, so there is never a runtime
# `if devenv` branch in the rendered script: only the selected backend's code
# is present. See modules/_backend-*.sh.

# ---- cloud-specific fixes -------------------------------------------------
# Sandboxes (Claude Code, etc.) route HTTPS through a TLS-terminating proxy
# whose CA is trusted in the *system* store. A Nix-provided JDK ships its own
# read-only cacerts and ignores the system store, so Java/Gradle downloads die
# with "PKIX path building failed" right after the JDK lands. Fix: make a
# writable copy of the JDK truststore, import any extra/local CAs, and point
# Java at the copy. No-op on a laptop with no extra CAs.
cooee_extra_ca_files() {
  local f
  for f in /usr/local/share/ca-certificates/*.crt \
           /etc/pki/ca-trust/source/anchors/*.crt \
           "${NODE_EXTRA_CA_CERTS:-}" "${COOEE_EXTRA_CA:-}"; do
    [[ -n "$f" && -f "$f" ]] && printf '%s\n' "$f"
  done
}

cooee_trust_cas_in_jdk() {  # cooee_trust_cas_in_jdk <java_home>
  local java_home=$1
  command -v keytool >/dev/null 2>&1 || { warn "keytool missing; skipping JDK TLS fix."; return 0; }
  local src="$java_home/lib/security/cacerts"
  [[ -f "$src" ]] || { warn "no cacerts under $java_home; skipping JDK TLS fix."; return 0; }

  local -a extra
  mapfile -t extra < <(cooee_extra_ca_files | sort -u)
  if (( ${#extra[@]} == 0 )); then
    log "No extra/proxy CAs detected; JDK default truststore is fine."
    return 0
  fi

  local store="$HOME/.config/coo-ee/cacerts"
  install -m 0644 "$src" "$store"          # writable copy (Nix store path is read-only)
  local n=0 crt
  for crt in "${extra[@]}"; do
    n=$((n+1))
    if keytool -importcert -noprompt -trustcacerts \
         -keystore "$store" -storepass changeit \
         -alias "cooee-extra-$n" -file "$crt" >/dev/null 2>&1; then
      ok "trusted extra CA: $crt"
    else
      warn "could not import CA: $crt"
    fi
  done

  # Append, don't clobber, any JAVA_TOOL_OPTIONS already in the environment.
  # Deliberately a single, space-free option: we point only at the truststore
  # and omit -Djavax.net.ssl.trustStorePassword. The password is needed solely
  # to verify the keystore's integrity hash, not to read its certificates, so a
  # CA truststore loads and is trusted fine without it. Keeping the value to one
  # whitespace-free token means the persisted env line (and any naive consumer
  # that word-splits it) can't break it into a bogus second command — the source
  # of the stray "...trustStorePassword=changeit: command not found" noise.
  cooee_add_jvm_flag "-Djavax.net.ssl.trustStore=$store"
  ok "JDK now trusts ${#extra[@]} extra CA(s) (JAVA_TOOL_OPTIONS + org.gradle.jvmargs)."
}

# Cloud fix, the browser half of the one above: Chromium on Linux (Playwright's,
# the image's /opt/pw-browsers, a Nix one) ignores SSL_CERT_FILE,
# NODE_EXTRA_CA_CERTS and the system store alike and trusts only its built-in
# roots plus the user NSS database at ~/.pki/nssdb. Nothing else puts the proxy
# CA there (the directory does not even exist until Chromium's first launch), so
# every HTTPS navigation through the sandbox proxy dies with
# net::ERR_CERT_AUTHORITY_INVALID while curl to the same URL works. Fix: import
# each extra CA into that database. A CA file may be a bundle (NODE_EXTRA_CA_CERTS
# in Claude Code is the whole 100+-cert system bundle) and certutil reads only the
# first certificate of a file, so split bundles and import every certificate.
# certutil comes from nixpkgs#nss.tools when it is not on PATH, built without a
# profile entry since only this function needs it. No-op with no extra CAs; opt
# out with COOEE_NO_NSS_CA=1.
cooee_trust_cas_in_nss() {
  [[ "${COOEE_NO_NSS_CA:-0}" == 1 ]] && { log "COOEE_NO_NSS_CA=1: leaving the browser NSS store alone."; return 0; }
  local -a extra
  mapfile -t extra < <(cooee_extra_ca_files | sort -u)
  (( ${#extra[@]} == 0 )) && return 0

  local certutil
  if ! certutil=$(command -v certutil 2>/dev/null); then
    local nss
    if command -v nix >/dev/null 2>&1 \
        && nss=$(nix build --print-out-paths --no-link "$(cooee_nixpkgs_ref)#nss.tools" --accept-flake-config 2>/dev/null) \
        && [[ -x "${nss%%$'\n'*}/bin/certutil" ]]; then
      certutil="${nss%%$'\n'*}/bin/certutil"
    else
      warn "certutil unavailable (no nixpkgs#nss.tools); Chromium will not trust the proxy CA."
      return 0
    fi
  fi

  local db="$HOME/.pki/nssdb"
  mkdir -p "$db" && chmod 700 "$HOME/.pki" "$db" \
    || { warn "could not create $db; skipping browser CA fix."; return 0; }
  if [[ ! -f "$db/cert9.db" ]]; then
    "$certutil" -d "sql:$db" -N --empty-password >/dev/null 2>&1 \
      || { warn "could not create the NSS database at $db; skipping browser CA fix."; return 0; }
  fi

  local split; split=$(mktemp -d "${TMPDIR:-/tmp}/cooee-nss.XXXXXX") || return 0
  local crt i=0
  for crt in "${extra[@]}"; do
    i=$((i+1))
    awk -v pre="$split/$i-" '
      /-----BEGIN CERTIFICATE-----/ { n++; f = pre n ".pem"; inside = 1 }
      inside { print > f }
      /-----END CERTIFICATE-----/ && inside { inside = 0; close(f) }' "$crt"
  done
  # Named by a hash of the base64 body, so a certificate that appears in two of
  # the inputs (a local anchor that is also in the bundle) is imported once, and
  # a re-run finds it already listed.
  local known; known=$("$certutil" -d "sql:$db" -L 2>/dev/null)
  local -A seen=()
  local f h nick n=0 total=0
  for f in "$split"/*.pem; do
    [[ -f "$f" ]] || continue
    h=$(sed '/-----/d' "$f" | tr -d '[:space:]' | sha256sum | cut -c1-16)
    [[ -n "${seen[$h]:-}" ]] && continue
    seen[$h]=1
    total=$((total+1))
    nick="cooee-$h"
    grep -qF "$nick " <<<"$known" && continue
    if "$certutil" -d "sql:$db" -A -t "C,," -n "$nick" -i "$f" >/dev/null 2>&1; then
      n=$((n+1))
    else
      warn "could not import CA $nick into $db"
    fi
  done
  rm -rf "$split"
  ok "browser NSS store ($db) trusts the extra CA(s): $n new, $total total."
}

# Cloud fix: the JVM ignores the http(s)_proxy env vars that curl honors, so in
# a sandbox where ALL egress is forced through a proxy (Claude Code on the web),
# the Gradle wrapper + daemon die with "UnknownHostException: services.gradle.org"
# even when the host is allowlisted. Translate the proxy env into JVM system
# properties (via JAVA_TOOL_OPTIONS, which every JVM — wrapper, daemon, plain
# java — reads) so Gradle routes through the proxy. No-op when no proxy is set
# (the laptop case), so it's always safe to call.
cooee_jvm_proxy_opts() {
  local proxy="${HTTPS_PROXY:-${https_proxy:-${HTTP_PROXY:-${http_proxy:-}}}}"
  [[ -n "$proxy" ]] || { log "No http(s) proxy in the environment; JVM proxy flags not needed."; return 0; }

  # Parse host:port out of a proxy URL: strip scheme, any user:pass@, any path.
  local hp="${proxy#*://}"; hp="${hp##*@}"; hp="${hp%%/*}"
  local host="${hp%%:*}" port=""
  [[ "$hp" == *:* ]] && port="${hp##*:}"
  [[ -n "$host" ]] || { warn "could not parse proxy host from '$proxy'; skipping JVM proxy flags."; return 0; }

  # Never proxy loopback; carry NO_PROXY through (comma list -> Java's pipe list,
  # leading-dot suffixes -> *. wildcards).
  local nph="localhost|127.0.0.1|[::1]" entry
  local raw="${NO_PROXY:-${no_proxy:-}}"
  if [[ -n "$raw" ]]; then
    local -a parts; IFS=',' read -ra parts <<< "$raw"
    for entry in "${parts[@]}"; do
      entry="${entry//[[:space:]]/}"; [[ -z "$entry" ]] && continue
      [[ "$entry" == .* ]] && entry="*$entry"
      nph="$nph|$entry"
    done
  fi

  local opts="-Dhttp.proxyHost=$host -Dhttps.proxyHost=$host"
  [[ -n "$port" ]] && opts="$opts -Dhttp.proxyPort=$port -Dhttps.proxyPort=$port"
  opts="$opts -Dhttp.nonProxyHosts=$nph"

  cooee_add_jvm_flag "$opts"
  ok "JVM routed through proxy $host${port:+:$port} (Gradle wrapper/daemon will reach the network)."
}

# Cloud fix: force a UTF-8 locale + JVM file encoding so the JVM and Gradle can
# read/write non-ASCII text. Many sandbox base images boot with a C/POSIX
# (ASCII) locale, which makes the JVM derive sun.jnu.encoding=ANSI_X3.4-1968.
# That's the encoding the JVM uses for *file names*, so the moment Gradle writes
# a report path containing a non-ASCII char (the —/·/× in test/report names) the
# build dies with "Malformed input or input contains unmappable characters" —
# a pure locale artifact, unrelated to the build itself. The two levers:
#   * LANG/LC_ALL -> a UTF-8 locale. This is what actually fixes sun.jnu.encoding
#     (it's read from the native locale at JVM startup; a -Dsun.jnu.encoding flag
#     is NOT honored by HotSpot, so the env var is the only lever that works).
#     C.UTF-8 is chosen because it's always available on glibc without generating
#     a locale, so it's safe in a bare sandbox.
#   * -Dfile.encoding=UTF-8 -> pins the JVM's default charset for stream/reader
#     content too. JDK 18+ already defaults this to UTF-8 (JEP 400), but JDK 17
#     (the Android/AGP toolchain) still follows the locale, so set it explicitly.
# Both are inherited by every JVM that reads JAVA_TOOL_OPTIONS / the process
# locale — the Gradle client, its daemon, and any forked test/worker JVM. A
# deliberate UTF-8 locale already in the environment is left untouched.
cooee_jvm_utf8_opts() {
  case "${LC_ALL:-${LANG:-}}" in
    *[Uu][Tt][Ff]-8 | *[Uu][Tt][Ff]8)
      log "UTF-8 locale already set (${LC_ALL:-$LANG}); leaving LANG/LC_ALL as-is." ;;
    *)
      add_env LANG   C.UTF-8
      add_env LC_ALL C.UTF-8
      ok "Locale set to C.UTF-8 (JVM sun.jnu.encoding + tool I/O now UTF-8)." ;;
  esac

  cooee_add_jvm_flag "-Dfile.encoding=UTF-8"
  ok "JVM file.encoding pinned to UTF-8 (Gradle client/daemon/workers)."
}

# ---- build dependency prefetch --------------------------------------------
# Once a language toolchain is ready, optionally warm its build cache by
# resolving the project's dependencies now — while the build/registry hosts are
# still reachable — so a later build (under possibly tighter egress, or none)
# can run from cache. Best-effort: a failure here warns but never fails
# provisioning. The work itself lives in each language module (cooee_prefetch_*),
# which calls these shared helpers; opt out everywhere with COOEE_NO_DEPS=1.

# The consuming project's root: the harness-provided project dir when set,
# otherwise the current directory (where the bootstrap was invoked).
cooee_project_dir() {
  if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then printf '%s' "$CLAUDE_PROJECT_DIR"
  else printf '%s' "$PWD"; fi
}

# The workspace root that holds the side-by-side project checkouts — the parent
# of the project dir. Overridable with COOEE_CHECKOUTS_DIR. Never roots a scan at
# a broad system directory: if the parent is one of those, fall back to the
# project dir itself so a single-repo layout still resolves to something sane.
# Used by the multi-checkout Gradle wrapper seeding (java.sh), which reads each
# checkout's gradle-wrapper.properties to warm the wrapper cache in ~/.gradle
# (it never writes into the checkouts themselves).
cooee_workspace_root() {
  local root="${COOEE_CHECKOUTS_DIR:-}"
  if [[ -z "$root" ]]; then
    local dir; dir="$(cooee_project_dir)"
    root="$(dirname "$dir")"
    case "$root" in ""|.|/|/home|/Users|/root|/usr|/var|/opt|/tmp) root="$dir" ;; esac
  fi
  printf '%s' "$root"
}

# False when dependency prefetch is opted out (COOEE_NO_DEPS=1). A module's
# prefetch step calls this first and skips quietly when it returns non-zero.
cooee_deps_enabled() { [[ "${COOEE_NO_DEPS:-0}" != 1 ]]; }

# ---- module + host registry ----------------------------------------------
# Modules register themselves (so _footer runs them in order) and declare the
# hosts they need, each with a human reason used in the remediation banner.
MODULES=()
declare -A _HOST_REASON=()   # required to INSTALL — probed, hard-fail if blocked
declare -A _WANT_REASON=()   # recommended for BUILDS — advisory only (may be wildcards)
declare -A _MODULE_PARAMS=() # request params per module (comma-joined), injected by the renderer
register_module() { MODULES+=("$1"); }
# True when <module> is part of this run's requested set (every fragment calls
# register_module at source time, before the footer runs the modules, so this is
# reliable inside a module_* function). Lets a module vary its defaults by what
# else is being installed — e.g. java defaulting differently when android is in.
cooee_module_requested() {  # cooee_module_requested <module>
  local m
  for m in "${MODULES[@]}"; do [[ "$m" == "$1" ]] && return 0; done
  return 1
}
need_host()       { _HOST_REASON["$1"]="$2"; }   # need_host <host> <reason>
want_host()       { _WANT_REASON["$1"]="$2"; }   # want_host <host|*.host> <reason>
set_params()      { _MODULE_PARAMS["$1"]="$2"; } # set_params <module> <comma-joined params>

# ---- provisioning state + cloud built-in awareness ------------------------
# Each module declares the command that proves its tool is ALREADY present (on
# a warm box, or shipped by the cloud provider's base image), and — when a
# provider exposes a first-class version selector — which env var to point the
# user at so they prefer the built-in over a redundant Nix install.
declare -A _PROVIDES_CMD=()     # module -> command that resolves when present
declare -A _BUILTIN_ENVVAR=()   # module -> provider env var that selects it
provides_tool() {               # provides_tool <module> <command> [provider_env_var]
  _PROVIDES_CMD["$1"]="$2"
  [[ -n "${3:-}" ]] && _BUILTIN_ENVVAR["$1"]="$3"
  return 0
}
module_present() {              # module_present <module> -> 0 if its tool is on PATH
  # A module may define cooee_present_<module> for a richer check than "is the
  # command on PATH" — e.g. android adopts an SDK an image shipped on disk but
  # never exported, where `command -v adb` fails yet the SDK is right there.
  if declare -F "cooee_present_$1" >/dev/null 2>&1; then
    "cooee_present_$1"; return
  fi
  local cmd=${_PROVIDES_CMD["$1"]:-}
  [[ -n "$cmd" ]] && command -v "$cmd" >/dev/null 2>&1
}

# ---- Claude Code permission hints -----------------------------------------
# Each module declares the tool-invocation permissions a Claude Code session
# will want pre-approved so the agent isn't prompted for the toolchain the
# environment just installed (e.g. `Bash(gradle:*)` for java). These are folded
# into the project's .claude/settings.json alongside the SessionStart hook (see
# cooee_install_session_hook). The strings are Claude Code permission rules:
# https://docs.claude.com/en/docs/claude-code/settings#permissions
declare -A _PROVIDES_PERMS=()   # module -> newline-separated permission rules
provides_perms() {              # provides_perms <module> <rule> [rule...]
  local m="$1"; shift
  local existing="${_PROVIDES_PERMS["$m"]:-}" rule
  for rule in "$@"; do
    existing+="${existing:+$'\n'}$rule"
  done
  _PROVIDES_PERMS["$m"]="$existing"
  return 0
}

# nixpkgs attribute -> the command name it actually puts on PATH, for the cases
# where they differ. Used to turn a `tools[...]` request into permission rules
# for the binaries it installs. Anything not listed is assumed to match its
# nixpkgs leaf name (jq -> jq, fd -> fd, gh -> gh, nodePackages.prettier ->
# prettier). Keep this to the well-known mismatches — a wrong guess just means
# one extra permission prompt, never a broken rule.
declare -A _TOOL_CMD_ALIAS=(
  [ripgrep]=rg
  [fd-find]=fd
  [the_silver_searcher]=ag
  [neovim]=nvim
)

# Emit the permission rules for the requested modules, one per line, deduped and
# sorted. Static per-module rules come from _PROVIDES_PERMS; the `tools` module
# additionally contributes a rule per binary it was asked to install.
cooee_collect_perms() {
  local m
  {
    for m in "${MODULES[@]}"; do
      [[ -n "${_PROVIDES_PERMS[$m]:-}" ]] && printf '%s\n' "${_PROVIDES_PERMS[$m]}"
      if [[ "$m" == tools && -n "${_MODULE_PARAMS[tools]:-}" ]]; then
        local -a want; local t leaf cmd
        IFS=',' read -r -a want <<< "${_MODULE_PARAMS[tools]}"
        for t in "${want[@]}"; do
          [[ "$t" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || continue
          leaf="${t##*.}"
          cmd="${_TOOL_CMD_ALIAS[$t]:-${_TOOL_CMD_ALIAS[$leaf]:-$leaf}}"
          printf 'Bash(%s:*)\n' "$cmd"
        done
      fi
    done
    # Union in the allowlists from the side-by-side project checkouts, so a
    # repo's own pre-approvals apply globally regardless of which one is opened.
    cooee_collect_checkout_perms
  } | LC_ALL=C sort -u   # empty input -> sort exits 0 (safe under pipefail)
}

# Extract the `.permissions.allow[]` strings from a Claude Code settings.json,
# one rule per line. Tries whatever JSON tool is on PATH (jq, then python3, then
# node — the same set the settings merge relies on) and prints nothing (exit 0)
# when the file is missing/unreadable/malformed or no tool is available, so an
# unparseable checkout simply contributes no rules rather than failing the run.
cooee_settings_allow() {   # cooee_settings_allow <settings.json>
  local f="$1"
  [[ -f "$f" ]] || return 0
  if command -v jq >/dev/null 2>&1; then
    jq -r '(.permissions.allow // [])[]' "$f" 2>/dev/null
  elif command -v python3 >/dev/null 2>&1; then
    python3 - "$f" <<'PY' 2>/dev/null
import json, sys
try:
    with open(sys.argv[1]) as fh: data = json.load(fh)
except Exception:
    sys.exit(0)
for rule in (data.get("permissions") or {}).get("allow") or []:
    print(rule)
PY
  elif command -v node >/dev/null 2>&1; then
    node -e 'try{const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));for(const r of ((d.permissions||{}).allow||[]))console.log(r)}catch(e){}' "$f" 2>/dev/null
  fi
  return 0
}

# Permission rules contributed by the side-by-side project checkouts. A cloud
# session checks out one or more repos under the workspace root, each carrying
# its own git-tracked .claude/settings.json. Claude Code only auto-loads the
# settings of the session's *own* project dir, so a repo opened as a sibling (or
# a subdirectory of the workspace root) never gets its allowlist applied. Folding
# every checkout's `permissions.allow` into the GLOBAL config makes those
# pre-approvals hold no matter which repo the session opens — and does it without
# writing into (and dirtying) any tracked working tree. Scans both the workspace
# root's immediate children and the project dir itself (the single-repo layout,
# where the root *is* the checkout). Opt out with COOEE_NO_CHECKOUT_PERMS=1.
cooee_collect_checkout_perms() {
  [[ "${COOEE_NO_CHECKOUT_PERMS:-0}" == 1 ]] && return 0
  local root proj d
  root="$(cooee_workspace_root)"
  proj="$(cooee_project_dir)"
  {
    [[ -d "$root" ]] && for d in "$root"/*/; do
      cooee_settings_allow "${d}.claude/settings.json"
    done
    # The project dir itself, in case it isn't a child of the scanned root
    # (COOEE_CHECKOUTS_DIR points elsewhere, or root fell back to proj).
    cooee_settings_allow "${proj%/}/.claude/settings.json"
  } 2>/dev/null
  return 0
}

# Render the collected permission rules as a compact JSON array string, e.g.
# ["Bash(gradle:*)","Bash(node:*)"]. Empty rule set renders as [].
cooee_perms_json() {
  local first=1 rule out='['
  while IFS= read -r rule; do
    [[ -z "$rule" ]] && continue
    out+="$( ((first)) || printf ',' )\"${rule//\"/\\\"}\""
    first=0
  done < <(cooee_collect_perms)
  printf '%s]' "$out"
}

# Stamp of the last successfully provisioned module set, used to short-circuit.
COOEE_STAMP="${COOEE_STAMP:-$HOME/.config/coo-ee/provisioned}"

# Detect the hosting agent so we can prefer its built-in toolchains. Codex ships
# a base image whose languages are version-selected via CODEX_ENV_*_VERSION;
# Claude Code / Gemini expose themselves through their own env markers.
COOEE_PROVIDER=unknown
COOEE_PROVIDER_LABEL="this environment"
cooee_detect_provider() {
  if compgen -v 2>/dev/null | grep -q '^CODEX_'; then
    COOEE_PROVIDER=codex;  COOEE_PROVIDER_LABEL="the Codex base image"
  elif [[ -n "${CLAUDECODE:-}" || -n "${CLAUDE_CODE_ENTRYPOINT:-}" || -n "${CLAUDE_ENV_FILE:-}" ]]; then
    COOEE_PROVIDER=claude; COOEE_PROVIDER_LABEL="the Claude Code environment"
  elif [[ -n "${GEMINI_CLI:-}" || -n "${GOOGLE_CLOUD_AGENT:-}" || -n "${ANTIGRAVITY:-}" ]]; then
    COOEE_PROVIDER=gemini; COOEE_PROVIDER_LABEL="the Gemini / Antigravity sandbox"
  fi
}

# ---- nixpkgs --------------------------------------------------------------

# The nixpkgs flake ref every module builds against.
#
# By default this is the registry's `nixpkgs`, which is a *rolling* ref — on a
# Determinate install it resolves to nixpkgs-weekly or nixpkgs-unstable. That is
# usually what you want, and occasionally the whole problem: when it lands on a
# revision the binary cache has not finished building, nothing substitutes and
# Nix compiles the closure from source. The visible symptom is bewildering —
# `bash-5.3p15.drv` failing to build during an *Android SDK* provision — and it
# has nothing to do with the SDK, dl.google.com, or the requested build-tools.
#
# COOEE_NIXPKGS_REF pins a way out without waiting for a code change, e.g.
#   COOEE_NIXPKGS_REF=github:NixOS/nixpkgs/nixos-25.05
cooee_nixpkgs_ref() { printf '%s' "${COOEE_NIXPKGS_REF:-nixpkgs}"; }

# Log which revision that ref actually resolved to, once per run.
#
# This is the first fact anyone needs when a Nix build fails and the last one
# they can get after the fact: a rolling ref means two sessions an hour apart
# build different package sets, and nothing in a failure log says which.
cooee_nixpkgs_note_revision() {
  [[ -n "${_COOEE_NIXPKGS_NOTED:-}" ]] && return 0
  _COOEE_NIXPKGS_NOTED=1
  local ref rev
  ref="$(cooee_nixpkgs_ref)"
  # `|| true` because this is diagnostics: under `set -euo pipefail` a failing
  # command substitution takes the whole provisioning run with it, and "we could
  # not name the revision" must never be the thing that stops a working build.
  rev="$(nix flake metadata "$ref" 2>/dev/null | sed -n 's/^ *Revision: *//p' | head -1)" || true
  if [[ -n "$rev" ]]; then
    log "nixpkgs: $ref -> $rev"
  else
    log "nixpkgs: $ref (revision could not be resolved)"
  fi
}

# One line for a failure message, naming the ref and the escape hatch.
cooee_nixpkgs_hint() {
  printf '%s' "Built against nixpkgs ref '$(cooee_nixpkgs_ref)'. If the failure is a *source \
build* of something unrelated (bash, glibc, ncurses), that ref resolved to a revision \
cache.nixos.org has not built yet — pin a cached one with \
COOEE_NIXPKGS_REF=github:NixOS/nixpkgs/nixos-25.05 (or any known-good rev) and re-run."
}

# ---- fetching -------------------------------------------------------------

# Download <url> to <dest>, retrying transient failures. Returns non-zero when
# every attempt failed, leaving the caller to decide between `die` and a warn.
#
# Every download in a provisioning run is one 503 away from taking the whole
# session with it: `curl -f` exits 22 on an HTTP error, the script runs under
# `set -e`, and the agent harness reports nothing but "Setup script failed with
# exit code 22". A CDN answering 503 for a few seconds — install.determinate.
# systems did exactly this — is not a broken environment, it is a retry.
#
# Two layers on purpose. curl's own `--retry` handles the sub-second case
# (connection resets, an immediate 429/5xx) without paying process startup
# again; the outer loop with growing backoff handles an endpoint that is having
# a bad half-minute. Bounded at four attempts (~14s of sleeping plus curl's own
# waits) so a genuinely blocked host still fails fast enough to be diagnosed by
# the allowlist help above rather than hanging the session.
cooee_fetch() {  # cooee_fetch URL DEST [ATTEMPTS]
  local url=$1 dest=$2 attempts=${3:-4} i delay=2
  for (( i = 1; i <= attempts; i++ )); do
    if curl -fsSL --retry 2 --retry-delay 1 --retry-connrefused -o "$dest" "$url"; then
      return 0
    fi
    (( i < attempts )) || break
    warn "fetch failed for $url (attempt $i/$attempts); retrying in ${delay}s..."
    sleep "$delay"
    delay=$(( delay * 2 ))
  done
  return 1
}

# ---- preconditions: tools, OS, and host reachability ----------------------
probe_host() {
  # Reachable == we got *any* HTTP response (even 403/404). "000" == the
  # connection itself was blocked (DNS/proxy/TLS), which is the allowlist case.
  local host=$1 code
  code=$(curl -sS -o /dev/null -w '%{http_code}' \
           --connect-timeout 5 --max-time 12 "https://${host}/" 2>/dev/null) || true
  [[ -n "$code" && "$code" != "000" ]]
}

print_allowlist_help() {
  local missing=("$@") h
  {
    echo
    echo "${_c_yel}+- Network not configured -------------------------------------${_c_rst}"
    echo "${_c_yel}|${_c_rst} The sandbox cannot reach the hosts this install needs."
    echo "${_c_yel}|${_c_rst} Add these to your environment's allowed hosts:"
    echo "${_c_yel}|${_c_rst}"
    for h in "${missing[@]}"; do
      printf '%s|%s     %s   %s(%s)%s\n' \
        "$_c_yel" "$_c_rst" "$h" "$_c_yel" "${_HOST_REASON[$h]}" "$_c_rst"
    done
    echo "${_c_yel}|${_c_rst}"
    echo "${_c_yel}|${_c_rst} Where to set it:"
    echo "${_c_yel}|${_c_rst}   - Claude Code on the web: environment -> Network access ->"
    echo "${_c_yel}|${_c_rst}       Custom -> Allowed domains (keep the default package list)."
    echo "${_c_yel}|${_c_rst}   - OpenAI Codex: environment -> Internet access -> On ->"
    echo "${_c_yel}|${_c_rst}       add to the domain allowlist (allow GET/HEAD)."
    echo "${_c_yel}|${_c_rst}   - Antigravity / Gemini Managed Agents: declare the hosts"
    echo "${_c_yel}|${_c_rst}       in the sandbox network allowlist."
    echo "${_c_yel}|${_c_rst}   - GitHub Actions: hosted runners have open egress, so this"
    echo "${_c_yel}|${_c_rst}       rarely fires; on self-hosted/firewalled runners, allow"
    echo "${_c_yel}|${_c_rst}       these in the runner network policy or corporate proxy."
    echo "${_c_yel}+-------------------------------------------------------------${_c_rst}"
    echo
  } >&2
}

check_preconditions() {
  command -v curl >/dev/null 2>&1 || die "curl is required but not installed."
  [[ "$(uname -s)" == "Linux" ]] || warn "Designed for Linux sandboxes; $(uname -s) is untested."

  log "Checking ${#_HOST_REASON[@]} required host(s)..."
  local missing=() h
  for h in "${!_HOST_REASON[@]}"; do
    if probe_host "$h"; then ok "reachable  $h"
    else warn "BLOCKED    $h  (${_HOST_REASON[$h]})"; missing+=("$h"); fi
  done

  if (( ${#missing[@]} )); then
    print_allowlist_help "${missing[@]}"
    if [[ "${COOEE_IGNORE_HOST_CHECK:-0}" == "1" ]]; then
      warn "COOEE_IGNORE_HOST_CHECK=1 set — continuing despite blocked hosts."
    else
      die "${#missing[@]} required host(s) blocked. Fix the allowlist above, or re-run with COOEE_IGNORE_HOST_CHECK=1 to try anyway."
    fi
  else
    ok "All required hosts reachable."
  fi

  print_recommended_hosts   # advisory: build-time hosts, never fatal
}

# Build-time hosts (Gradle/Maven/Android registries, possibly wildcards) are
# not needed to install, so we don't probe them — we just remind you to allow
# them before the project actually builds.
print_recommended_hosts() {
  (( ${#_WANT_REASON[@]} )) || return 0
  local h
  {
    echo
    echo "${_c_blu}i Recommended for builds${_c_rst} — add these too if you'll build the project:"
    for h in "${!_WANT_REASON[@]}"; do
      printf '    %s   %s(%s)%s\n' "$h" "$_c_blu" "${_WANT_REASON[$h]}" "$_c_rst"
    done
    echo
  } >&2
}

# ===========================================================================
#  BACKEND: nix profile (default)
# ---------------------------------------------------------------------------
#  Spliced in by the renderer when ?devenv is absent. Packages install
#  straight into the default Nix profile. Defines the backend contract the
#  modules rely on: nix_ensure + the cooee_backend_* hooks. The devenv backend
#  (_backend-devenv.sh) is the alternative implementation of the same contract;
#  exactly one is ever present in a rendered script.
# ===========================================================================

# Hook: extra provisioning after Nix is on PATH (called by module_base). The
# nix backend needs nothing beyond Nix itself.
cooee_backend_setup() { :; }

# Hook: which of the requested JDK majors to actually install. The nix profile
# gives each install its own --priority, so multiple JDKs coexist — install all.
cooee_backend_jdks() { printf '%s\n' "$@"; }

# Hook: build a complete Android SDK from the request the android module assembled
# in the _COOEE_ANDROID_* globals, and set COOEE_ANDROID_SDK_DIR to its SDK dir
# (…/libexec/android-sdk). The nix backend builds an androidenv expression
# directly with `nix build`. die()s on failure (call it directly, not in a
# command substitution, so the die exits the whole script).
cooee_backend_android_sdk() {
  local -a levels=("${_COOEE_ANDROID_LEVELS[@]}")
  local -a img_types=("${_COOEE_ANDROID_IMG_TYPES[@]}")
  local want_emu="${_COOEE_ANDROID_WANT_EMU:-0}"

  # Compose the Nix expression. Quote each level / image type as a list element.
  local platforms_nix="" img_types_nix="" l t
  for l in "${levels[@]}"; do platforms_nix+="\"$l\" "; done
  for t in "${img_types[@]}"; do img_types_nix+="\"$t\" "; done
  local emu_bool="false"; (( want_emu )) && emu_bool="true"

  # On x86_64 Linux, androidenv unconditionally drags in 32-bit (i686) glibc,
  # zlib and ncurses5 as legacy runtime libs for ancient 32-bit build-tool
  # binaries — the modern 64-bit build-tools we install need none of them.
  # glibc/zlib substitute from the cache, but the niche ncurses5 (an
  # abiVersion=5 override, "ncurses-abi5-compat") usually isn't cached, so Nix
  # *builds* it — and building anything i686 runs a 32-bit builder, which dies
  # with "Exec format error" on kernels without 32-bit x86 support (common in
  # minimal cloud containers). Swap a native, empty stub in its place so the SDK
  # build never needs a 32-bit builder. Off only where you genuinely need the
  # 32-bit legacy build-tools on a 32-bit-capable host: COOEE_ANDROID_NCURSES5_STUB=0.
  local overlays_nix=""
  if [[ "${COOEE_ANDROID_NCURSES5_STUB:-1}" != 0 && "$(uname -m)" == x86_64 ]]; then
    overlays_nix="overlays = [
        (final: prev: {
          pkgsi686Linux = prev.pkgsi686Linux.extend (i686final: i686prev: {
            ncurses5 = prev.runCommand \"ncurses5-stub\" { } \"mkdir -p \$out/lib \$out/include\";
          });
        })
      ];"
  fi

  cooee_nixpkgs_note_revision
  local nixpkgs_ref; nixpkgs_ref="$(cooee_nixpkgs_ref)"
  local expr="let
    pkgs = import (builtins.getFlake \"${nixpkgs_ref}\").outPath {
      system = builtins.currentSystem;
      config.allowUnfree = true;
      config.android_sdk.accept_license = true;
      ${overlays_nix}
    };
  in (pkgs.androidenv.composeAndroidPackages {
    platformVersions   = [ ${platforms_nix}];
    buildToolsVersions = [ \"${COOEE_ANDROID_BUILD_TOOLS}\" ];
    includeEmulator     = ${emu_bool};
    includeSystemImages = ${emu_bool};
    systemImageTypes    = [ ${img_types_nix}];
    abiVersions         = [ \"x86_64\" ];
  }).androidsdk"

  log "android: building the SDK via Nix androidenv (platforms: ${levels[*]}; build-tools ${COOEE_ANDROID_BUILD_TOOLS})..."
  export NIXPKGS_ALLOW_UNFREE=1

  # --out-link doubles as a GC root so the SDK survives `nix store gc`; the store
  # path is printed to stdout for us to anchor ANDROID_HOME at.
  local link="$HOME/.cache/coo-ee/android-sdk"
  mkdir -p "$(dirname "$link")"
  local out
  # stderr is captured so the failure can quote Nix's *first* error line. The
  # message used to offer three guesses (dl.google.com, wrong build-tools
  # versions, 32-bit ncurses) and a real failure turned out to be none of them —
  # a guess in place of the actual error is worse than no guess at all, because
  # it sends the reader off to check things that were never wrong.
  local errf; errf=$(mktemp "${TMPDIR:-/tmp}/cooee-android-sdk.XXXXXX" 2>/dev/null)
  if ! out=$(nix build --impure --print-out-paths --out-link "$link" --expr "$expr" 2>"$errf"); then
    local first_error=""
    [[ -n "$errf" ]] && {
      cat "$errf" >&2
      first_error=$(grep -m1 -E '^error:' "$errf" 2>/dev/null)
      rm -f "$errf"
    }
    die "android: SDK build failed.${first_error:+ First error: ${first_error}} \
$(cooee_nixpkgs_hint) Other common causes: dl.google.com unreachable; the requested \
platform/build-tools versions are absent from this nixpkgs (override COOEE_ANDROID_BUILD_TOOLS / \
COOEE_ANDROID_DEFAULT_PLATFORM); or a 32-bit (i686) ncurses build on a kernel without 32-bit x86 \
support ('Exec format error' — keep the default COOEE_ANDROID_NCURSES5_STUB=1 that stubs it out)."
  fi
  [[ -n "$errf" ]] && rm -f "$errf"
  COOEE_ANDROID_SDK_DIR="$out/libexec/android-sdk"
}

# ---- idempotent nix package install ---------------------------------------
# Safe to run repeatedly: installs only what's missing, treats an already
# present package as success, so the whole script is a no-op on a warm box
# and a repair on a cold/partial one.
nix_ensure() {  # nix_ensure <match> <flakeref> [extra nix flags...]
  local match=$1; shift
  if nix profile list 2>/dev/null | grep -qiF -- "$match"; then
    ok "already present: $match"; return 0
  fi
  local out
  if out=$(nix profile install "$@" 2>&1); then
    ok "installed: $match"
  elif grep -qiF "already installed" <<<"$out"; then
    ok "already present: $match"
  else
    printf '%s\n' "$out" >&2
    return 1
  fi
}

# ===========================================================================
#  module: base — install Nix (Determinate Systems installer)
#    Then run the backend's cooee_backend_setup hook: a no-op for the nix
#    backend, or installing devenv.sh on top of Nix for a `?devenv` request
#    (see modules/_backend-*.sh, spliced in by the renderer).
# ===========================================================================
register_module base
need_host install.determinate.systems   "Determinate Nix installer + binaries"
need_host cache.nixos.org                "Nix binary cache (substituter)"
need_host channels.nixos.org            "nixpkgs channel / flake source"
need_host github.com                     "nixpkgs + flake inputs"
need_host objects.githubusercontent.com  "GitHub release assets for flake inputs"
# Determinate Nix ships FlakeHub configured as an extra flake registry + binary
# cache. Advisory, not required: if blocked, flake resolution/substitution falls
# back to cache.nixos.org / github. The apex is concrete; the CDN + cache paths
# live on subdomains, hence the wildcard (which can't be probed, so it's a want).
want_host flakehub.com                   "Determinate FlakeHub — flake registry + binary cache"
want_host "*.flakehub.com"               "FlakeHub CDN / cache subdomains (flake tarballs + cached store paths)"
# The coo.ee/env service itself (or your COOEE_BASE_URL mirror). Not needed to
# install Nix, but the curl|bash bootstrap and the auto-activation / SessionStart
# hook re-fetch it on later runs — so it must be allowed once egress is locked
# down (the devcontainer firewall, Codex/Claude allowlist). Advisory, never
# probed, so a COOEE_BASE_URL override or a vendored script can't trip it.
want_host env.coo.ee                     "the coo.ee/env service — fetch + re-provision the script (auto-activation hook)"

# Environment-wide Claude Code permissions. Unlike the toolchain rules other
# modules declare, these aren't tied to anything Nix installs — they pre-approve
# the *harness's* own tools that every cloud session leans on: the scheduling /
# trigger MCP (send-later, cron-style wakeups) and the routine, mostly read-only
# GitHub collaboration operations (open/update PRs, reply to reviews, read job
# logs and CI check status, subscribe/unsubscribe to PR activity). Baking them
# into `base` — always included —
# means they land in the GLOBAL config on every provision, so a fresh box never
# prompts for them. Individual project checkouts can still add their own rules;
# those are merged up into the global config too (see cooee_collect_perms). Both
# the capitalized (`Claude_Code_Remote`) and hyphenated (`claude-code-remote`)
# server spellings are listed because the MCP server name varies by host build,
# and a permission rule must match the server name exactly.
provides_perms base \
  'mcp__Claude_Code_Remote__send_later' \
  'mcp__Claude_Code_Remote__create_trigger' \
  'mcp__Claude_Code_Remote__update_trigger' \
  'mcp__Claude_Code_Remote__delete_trigger' \
  'mcp__Claude_Code_Remote__list_triggers' \
  'mcp__Claude_Code_Remote__fire_trigger' \
  'mcp__claude-code-remote__send_later' \
  'mcp__claude-code-remote__create_trigger' \
  'mcp__claude-code-remote__update_trigger' \
  'mcp__claude-code-remote__delete_trigger' \
  'mcp__claude-code-remote__list_triggers' \
  'mcp__claude-code-remote__fire_trigger' \
  'mcp__github__pull_request_read' \
  'mcp__github__get_job_logs' \
  'mcp__github__create_pull_request' \
  'mcp__github__update_pull_request' \
  'mcp__github__resolve_review_thread' \
  'mcp__github__add_reply_to_pull_request_comment' \
  'mcp__github__add_issue_comment' \
  'mcp__github__issue_write' \
  'mcp__github__subscribe_pr_activity' \
  'mcp__github__unsubscribe_pr_activity' \
  'mcp__github__get_check_run' \
  'mcp__github__actions_get' \
  'mcp__github__actions_list' \
  'mcp__github__search_issues' \
  'mcp__github__list_pull_requests'

# Determinate's installer always lays down a root-owned (multi-user) store.
# - As root (typical agent sandbox) we read/write that store directly, so a
#   daemonless `--init none` install is enough.
# - As a non-root user (e.g. a CI runner) the store is only reachable through
#   the nix-daemon, which `--init none` never starts. So when we're not root we
#   either let the installer manage the daemon via systemd, or start it
#   ourselves afterwards.
cooee_nix_daemon_socket=/nix/var/nix/daemon-socket/socket

cooee_is_root()     { [[ "${EUID:-$(id -u)}" -eq 0 ]]; }
cooee_has_systemd() { [[ -d /run/systemd/system ]]; }

cooee_start_nix_daemon() {  # bring up nix-daemon when there is no init to do it
  [[ -S "$cooee_nix_daemon_socket" ]] && return 0
  command -v sudo >/dev/null 2>&1 || { warn "non-root Nix install but no sudo to start nix-daemon; installs may fail."; return 0; }
  local daemon=/nix/var/nix/profiles/default/bin/nix-daemon
  [[ -x "$daemon" ]] || daemon=$(command -v nix-daemon 2>/dev/null || true)
  [[ -n "$daemon" && -x "$daemon" ]] || { warn "nix-daemon binary not found; cannot start it."; return 0; }
  log "Starting nix-daemon (non-root install without an init system)..."
  sudo --background "$daemon" >/dev/null 2>&1 || true
  local i
  for i in $(seq 1 50); do [[ -S "$cooee_nix_daemon_socket" ]] && break; sleep 0.2; done
  [[ -S "$cooee_nix_daemon_socket" ]] && ok "nix-daemon is up." \
    || warn "nix-daemon socket still absent; non-root nix commands may fail."
}

module_base() {
  if command -v nix >/dev/null 2>&1; then
    ok "Nix already installed: $(nix --version)"
  else
    # Pick the daemon strategy from who we are and whether systemd is around.
    local -a init_flags=(--init none)
    local manage_daemon=0
    if cooee_is_root; then
      log "Installing Nix (Determinate Systems installer, daemonless — running as root)..."
    elif cooee_has_systemd; then
      init_flags=(--init systemd)     # let the installer start nix-daemon.service
      log "Installing Nix (Determinate, systemd daemon — non-root host)..."
    else
      manage_daemon=1                 # no init: we'll start the daemon ourselves
      log "Installing Nix (Determinate, daemon started manually — non-root, no systemd)..."
    fi
    # Fetched to a file rather than piped straight into `sh`, for two reasons
    # that are really one: a `curl … | sh` cannot be retried, and it cannot fail
    # cleanly. A 503 from the CDN (routine, and what killed a provisioning run
    # with the opaque "Setup script failed with exit code 22") aborts the whole
    # setup, and a transfer that dies mid-stream feeds `sh` a truncated
    # installer that has already started running. cooee_fetch retries; the
    # installer only runs once we hold all of it.
    local installer
    installer=$(mktemp "${TMPDIR:-/tmp}/cooee-nix-install.XXXXXX") \
      || die "could not create a temp file for the Nix installer."
    cooee_fetch https://install.determinate.systems/nix "$installer" \
      || die "could not download the Determinate Nix installer from install.determinate.systems (it answered with an error on every attempt). This is usually transient — re-run the setup script; if it persists, check that the host is in your environment's allowlist."
    # flakes: needed for nix#pkgs references used by the modules.
    sh "$installer" install linux \
      --no-confirm \
      "${init_flags[@]}" \
      --extra-conf "experimental-features = nix-command flakes"
    rm -f "$installer"
    ok "Nix installed."
    [[ "$manage_daemon" == 1 ]] && cooee_start_nix_daemon
  fi

  # Put nix on PATH for the rest of THIS script...
  if [[ -e /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]]; then
    # shellcheck disable=SC1091
    . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
  fi
  export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
  command -v nix >/dev/null 2>&1 || die "Nix not on PATH after install."

  # ...and for future shells (a PATH prepend, not a frozen snapshot). Recorded
  # in both persisted files so the short-circuit can replay it next session.
  echo 'export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"' \
    >> "$COOEE_PROFILE"
  printf 'PATH=%s\n' "$PATH" >> "$COOEE_HARNESS_ENV"
  cooee_forward_to_harness "PATH=$PATH"

  # Backend setup hook: a no-op for the nix backend, or installs devenv.sh on
  # top of Nix for a ?devenv render (defined in the spliced-in _backend-*.sh).
  cooee_backend_setup

  ok "base ready: $(nix --version)"
}

# ===========================================================================
#  module: android
#    software : full Android SDK (Nix androidenv; under ?devenv, devenv's native
#               android integration over the same androidenv) — platform-tools
#               (adb), cmdline-tools, the requested platforms + build-tools (and,
#               when android-emulator is also requested, the emulator + system
#               images). Because Nix builds it at a read-only, unguessable
#               /nix/store path with the command-line tools under a versioned
#               dir (no cmdline-tools/latest), a conventional symlink view is
#               published at /opt/android-sdk (else ~/Android/Sdk) with a
#               cmdline-tools/latest alias; ANDROID_HOME / ANDROID_SDK_ROOT point
#               at THAT, and (for a Gradle project rooted here) sdk.dir is pinned
#               in local.properties so AGP finds the SDK even when the env vars
#               don't reach the build — and so agents probing conventional paths
#               or running sdkmanager/avdmanager just work.
#    params   : android[36] selects the platform API level(s) to install
#               (e.g. android[30,36,wear-33]); bare `android` installs the
#               default (API 36). A level is NN or NN.M — API 37+ ships only
#               under a minor-versioned id, so it is android[37.0], not
#               android[37] (nixpkgs androidenv has no bare `37` key). An
#               unrecognized param is reported, never silently dropped.
#    hosts    : cache.nixos.org (install), dl.google.com (SDK component sources)
#             : Google / JetBrains / fonts registries (build, advisory)
#  Host set mirrors skills/compose-preview/references/agent-cloud.md.
#  Assumes `java` is also requested (Android builds need a JDK).
#
#  Pulls in `android-cli` automatically — Google's official Android CLI agent
#  tool (the `android` command) — so any box with the SDK also has the
#  agent-first CLI on PATH. The CLI is a lightweight standalone binary and does
#  not pull the SDK back; request `android-cli` on its own for just the tool.
# ===========================================================================
# coo.ee:implies android-cli
register_module android
provides_tool android adb   # adopt a complete existing SDK (adb on PATH, or discovered on disk)
# Pre-approve the Android SDK command-line tools for Claude Code sessions.
provides_perms android "Bash(adb:*)" "Bash(sdkmanager:*)" "Bash(avdmanager:*)"
need_host cache.nixos.org        "prebuilt androidenv dependencies from the Nix cache"
want_host dl.google.com          "Android SDK components (platforms, build-tools, system images)"
want_host maven.google.com       "AndroidX / AGP artifacts"
want_host androidx.dev            "AndroidX snapshot builds (androidx.dev/snapshots)"
want_host packages.jetbrains.team "JetBrains-hosted Compose/tooling artifacts"
want_host cache-redirector.jetbrains.com "Kotlin Gradle plugin CDN redirector (Kotlin/Native, compiler artifacts)"
want_host download.jetbrains.com "Kotlin/Native dependencies (LLVM, sysroots, toolchains)"
want_host fonts.googleapis.com   "downloadable font metadata (Compose)"
want_host fonts.gstatic.com      "downloadable font binaries (Compose)"

# Defaults (overridable from the environment). The platform level used when a
# bare `android` is requested, and the build-tools revision to install. Pinning
# them keeps a param-less install reproducible; override for a different target.
COOEE_ANDROID_DEFAULT_PLATFORM="${COOEE_ANDROID_DEFAULT_PLATFORM:-36}"
# Whether the caller pinned the build-tools revision, captured before defaulting:
# an explicit pin is honored exactly, an unset one may be raised to match a newer
# platform (see cooee_android_build_tools_for).
COOEE_ANDROID_BUILD_TOOLS_PINNED="${COOEE_ANDROID_BUILD_TOOLS:+1}"
COOEE_ANDROID_BUILD_TOOLS="${COOEE_ANDROID_BUILD_TOOLS:-36.0.0}"

# Map request params (e.g. 30, 37.0, wear-33) to platform API levels, deduped in
# input order. A `wear-NN` param contributes level NN (the wear system image type
# is added separately). Anything that isn't a level is dropped — and reported by
# cooee_android_unknown_params, because a silently dropped level is how you get a
# container that provisions "successfully" with the wrong SDK.
#
# A level is `NN` or `NN.M`. The minor form is not cosmetic: from API 37 on,
# Google ships platforms *only* under a minor-versioned id, and nixpkgs
# androidenv keys them exactly as Google does — `36`, `36.1`, `37.0`, `37.1`,
# with no bare `37`. So `android[37]` asks androidenv for a key that does not
# exist (it throws "The version 37 is missing in package platforms") and
# `android[37.0]` is the spelling that resolves. Levels are passed through
# verbatim for that reason; the SDK lays each one out at
# platforms/android-<level> (repo.json's own path), so the probe below matches
# without translation.
cooee_android_levels() {  # cooee_android_levels <param>...
  local p lvl seen=" "
  for p in "$@"; do
    if [[ "$p" =~ ^(wear-)?([0-9]+(\.[0-9]+)?)$ ]]; then
      lvl="${BASH_REMATCH[2]}"
      [[ "$seen" == *" $lvl "* ]] || { printf '%s\n' "$lvl"; seen+="$lvl "; }
    fi
  done
}

# The params that name no platform level at all — a typo, or a form this module
# doesn't understand. Echoed one per line, so the caller can say so instead of
# quietly installing the default and letting the build fail much later.
cooee_android_unknown_params() {  # cooee_android_unknown_params <param>...
  local p
  for p in "$@"; do
    [[ "$p" =~ ^(wear-)?([0-9]+(\.[0-9]+)?)$ ]] || printf '%s\n' "$p"
  done
}

# The build-tools revision to install for a set of platform levels. An explicit
# COOEE_ANDROID_BUILD_TOOLS is returned untouched. Otherwise the default acts as
# a *floor*: a platform newer than it raises the revision to that platform's
# major, because AGP resolves a default buildToolsVersion from compileSdk and
# fails ("Failed to find Build Tools revision ...") when the SDK carries only an
# older one — so pinning 36.0.0 next to platform 37.0 installs an SDK that can't
# build the thing it was requested for. Older platforms keep the default, since
# a build-tools older than the floor is not what a stale request wants either.
cooee_android_build_tools_for() {  # cooee_android_build_tools_for <level>...
  if [[ -n "${COOEE_ANDROID_BUILD_TOOLS_PINNED:-}" ]]; then
    printf '%s' "$COOEE_ANDROID_BUILD_TOOLS"; return 0
  fi
  local floor="${COOEE_ANDROID_BUILD_TOOLS%%.*}" max="${COOEE_ANDROID_BUILD_TOOLS%%.*}" l major
  for l in "$@"; do
    major="${l%%.*}"
    [[ "$major" =~ ^[0-9]+$ ]] || continue
    (( major > max )) && max="$major"
  done
  if (( max > floor )); then printf '%s.0.0' "$max"; else printf '%s' "$COOEE_ANDROID_BUILD_TOOLS"; fi
}

# True if any requested param names a Wear OS platform (wear-NN).
cooee_android_wants_wear() {  # cooee_android_wants_wear <param>...
  local p; for p in "$@"; do [[ "$p" =~ ^wear- ]] && return 0; done; return 1
}

# An SDK is "complete" (worth adopting as-is) when it already carries at least
# one installed platform and one build-tools revision — i.e. it can build, not
# just run adb. A platform-tools-only tree (adb but no platforms) is not.
cooee_sdk_is_complete() {  # cooee_sdk_is_complete <sdk dir>
  local s=$1
  [[ -d "$s/platforms"   ]] && compgen -G "$s/platforms/android-*" >/dev/null 2>&1 \
  && [[ -d "$s/build-tools" ]] && compgen -G "$s/build-tools/*"    >/dev/null 2>&1
}

# Locate a complete SDK already on the box, echoing its path (nothing on miss).
# An explicit pointer wins and *suppresses* probing: when ANDROID_HOME /
# ANDROID_SDK_ROOT is set we consider only that location — adopt it if complete,
# otherwise install — and never second-guess an explicit choice by hunting
# elsewhere (this is what lets an emptied ANDROID_HOME force the Nix build path).
# When neither is set we recover an SDK the image shipped but never exported:
# adb's own tree first, then the conventional install locations. Discovery does
# NOT require adb on PATH — an image shipping the SDK without exporting anything
# is precisely the case this rescues.
cooee_android_discover_sdk() {
  local c adb_path
  if [[ -n "${ANDROID_HOME:-}" || -n "${ANDROID_SDK_ROOT:-}" ]]; then
    c="${ANDROID_HOME:-${ANDROID_SDK_ROOT}}"
    cooee_sdk_is_complete "$c" && { printf '%s\n' "$c"; return 0; }
    return 1
  fi
  if adb_path=$(command -v adb 2>/dev/null); then
    c=${adb_path%/platform-tools/adb}
    [[ "$c" != "$adb_path" ]] && cooee_sdk_is_complete "$c" && { printf '%s\n' "$c"; return 0; }
  fi
  for c in /opt/android-sdk "$HOME/.android-sdk" "$HOME/Android/Sdk" \
           /usr/lib/android-sdk /usr/local/lib/android/sdk "$HOME/.android/sdk"; do
    cooee_sdk_is_complete "$c" && { printf '%s\n' "$c"; return 0; }
  done
  return 1
}

# Presence hook for the framework (module_present): android counts as already
# provided not only when adb is on PATH, but whenever a complete SDK can be
# discovered on disk — so a box that ships the SDK isn't forced through a
# redundant Nix install / host preflight just because nothing exported it yet.
cooee_present_android() {
  command -v adb >/dev/null 2>&1 || cooee_android_discover_sdk >/dev/null 2>&1
}

# Pin the SDK location in local.properties for every Gradle build across the
# local checkouts. AGP resolves the SDK from local.properties' sdk.dir first, and
# only then from the ANDROID_HOME / ANDROID_SDK_ROOT env vars. Some harnesses
# launch `./gradlew` in a shell that never sourced our persisted env, so those
# vars are absent and the build dies with "SDK location not found"; an explicit
# sdk.dir makes the SDK findable no matter how the build is started. A cloud
# session often checks out several repos side by side under the workspace root
# (the parent of the project dir), and any of them can be — or contain — an
# Android build; pinning only the one project rooted at the invocation dir leaves
# the siblings' builds broken. So we discover every Gradle build root across the
# checkouts (the same multi-checkout shape the java module seeds wrappers for) and
# pin each. local.properties is machine-specific (the Nix store path is unique to
# this container) and conventionally gitignored, so we (re)write it on every
# provision rather than commit it — preserving any other entries already in the
# file.

# Every Gradle build root across the local checkouts — a directory a `./gradlew`
# build runs in and where AGP reads local.properties. A build root is identified
# by a settings file (settings.gradle[.kts]) or, for a settings-less single-module
# build, a `gradlew` launcher — deliberately NOT by a bare build.gradle, which
# every subproject of a multi-module build also carries (a subproject's SDK comes
# from the root's local.properties, so writing one per subproject would be wrong
# and noisy). The project dir itself is always included when it looks like a build
# root — for the single-repo layout (the root IS the checkout) or when
# COOEE_CHECKOUTS_DIR points somewhere the scan can't reach — and there a bare
# build.gradle counts, since the invocation dir is known to be a root, not a
# subproject. Bounded depth keeps the scan cheap while still catching a repo-root
# build and a nested one (e.g. <repo>/android/). Deduped, sorted. Override the
# search root with COOEE_CHECKOUTS_DIR.
cooee_android_gradle_roots() {
  local root proj m
  root="$(cooee_workspace_root)"
  proj="$(cooee_project_dir)"
  {
    if [[ -d "$root" ]]; then
      find "$root" -maxdepth 5 -type f \
        \( -name settings.gradle -o -name settings.gradle.kts -o -name gradlew \) \
        2>/dev/null | while IFS= read -r f; do printf '%s\n' "${f%/*}"; done
    fi
    # The project dir itself, when it is a build root — covers the single-repo
    # layout and a COOEE_CHECKOUTS_DIR that points away from it. A bare
    # build.gradle counts here (the invocation dir is a root, not a subproject).
    for m in settings.gradle settings.gradle.kts build.gradle build.gradle.kts gradlew; do
      [[ -e "${proj%/}/$m" ]] && { printf '%s\n' "${proj%/}"; break; }
    done
  } | LC_ALL=C sort -u
}

# (Re)write sdk.dir in one project's local.properties, preserving every other
# entry. See cooee_android_pin_sdk_dir for the rationale.
cooee_android_write_sdk_dir() {  # cooee_android_write_sdk_dir <sdk dir> <project root>
  local sdk=$1 f="${2%/}/local.properties"
  if [[ -f "$f" ]] && grep -q '^[[:space:]]*sdk\.dir[[:space:]]*=' "$f"; then
    local cur
    cur=$(sed -n 's/^[[:space:]]*sdk\.dir[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$f" | head -1)
    [[ "$cur" == "$sdk" ]] && { log "android: $f already pins sdk.dir=$sdk."; return 0; }
    local tmp; tmp=$(mktemp)
    # Rewrite the stale sdk.dir line in place; keep every other entry untouched.
    sed "s|^[[:space:]]*sdk\.dir[[:space:]]*=.*|sdk.dir=$sdk|" "$f" > "$tmp" && mv "$tmp" "$f"
    ok "android: updated sdk.dir in $f -> $sdk."
  else
    printf 'sdk.dir=%s\n' "$sdk" >> "$f"
    ok "android: wrote sdk.dir to $f -> $sdk."
  fi
}

# Pin sdk.dir across every Gradle build root in the local checkouts (see above).
cooee_android_pin_sdk_dir() {  # cooee_android_pin_sdk_dir <sdk dir>
  local sdk=$1 root pinned=0
  while IFS= read -r root; do
    [[ -n "$root" ]] || continue
    cooee_android_write_sdk_dir "$sdk" "$root"
    pinned=$((pinned + 1))
  done < <(cooee_android_gradle_roots)
  (( pinned )) || log "android: no Gradle build found in the local checkouts; nothing to pin sdk.dir into."
}

# Publish a conventional, agent-guessable view of the SDK. Nix's androidenv
# builds the SDK at a read-only /nix/store/<hash>-androidsdk/… path and lays the
# command-line tools under a VERSIONED dir (cmdline-tools/<ver>/), with no
# `latest` alias — so `sdkmanager`/`avdmanager` don't resolve (every tool + our
# own PATH expect cmdline-tools/latest/bin), and an agent can't guess the hashed
# store path. Mirror the SDK's entries into a writable conventional location
# (/opt/android-sdk, else ~/Android/Sdk — both are already in the discovery
# probe list) with symlinks, add the missing cmdline-tools/latest alias, and echo
# that path for ANDROID_HOME. So an agent that probes $HOME/Android/Sdk or
# /opt/android-sdk, or runs cmdline-tools/latest/bin/sdkmanager, just works.
# Idempotent. An SDK that already has cmdline-tools/latest and isn't in the store
# (an image's own SDK, or a view from a prior run) is echoed unchanged.
# Override the publish location with COOEE_ANDROID_SDK_LINK_DIR.
cooee_android_publish_sdk() {  # cooee_android_publish_sdk <sdk dir> -> published dir
  local sdk="$1"

  # Already usable + guessable? Leave it.
  if [[ "$sdk" != /nix/store/* && -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]]; then
    printf '%s' "$sdk"; return 0
  fi

  local dest="${COOEE_ANDROID_SDK_LINK_DIR:-}"
  if [[ -z "$dest" ]]; then
    if [[ -w /opt ]]; then dest=/opt/android-sdk; else dest="$HOME/Android/Sdk"; fi
  fi
  # Never mirror a directory onto itself (e.g. re-provision adopting our view).
  [[ "$sdk" == "$dest" ]] && { printf '%s' "$sdk"; return 0; }

  if ! mkdir -p "$dest/cmdline-tools" 2>/dev/null; then
    warn "android: couldn't create an SDK view at $dest; using $sdk directly (agents must rely on ANDROID_HOME)."
    printf '%s' "$sdk"; return 0
  fi

  # Mirror every top-level SDK entry except cmdline-tools (handled below).
  local e name
  for e in "$sdk"/*; do
    [[ -e "$e" ]] || continue
    name=$(basename "$e")
    [[ "$name" == cmdline-tools ]] && continue
    ln -sfn "$e" "$dest/$name"
  done

  # cmdline-tools/latest -> the tools dir that actually holds sdkmanager (prefer
  # an existing `latest`, else the sole versioned dir Nix laid down, e.g. 21.0).
  local clt="$sdk/cmdline-tools/latest" sm
  if [[ ! -x "$clt/bin/sdkmanager" ]]; then
    sm=$(find "$sdk/cmdline-tools" -maxdepth 3 -type f -path '*/bin/sdkmanager' 2>/dev/null | head -1)
    clt=${sm:+$(dirname "$(dirname "$sm")")}
  fi
  [[ -n "$clt" && -x "$clt/bin/sdkmanager" ]] && ln -sfn "$clt" "$dest/cmdline-tools/latest"

  # Expose the other conventional guess path too (Android Studio's Linux default).
  if [[ "$dest" == /opt/android-sdk && -d "$HOME" && "$HOME/Android/Sdk" != "$dest" ]]; then
    mkdir -p "$HOME/Android" 2>/dev/null && ln -sfn "$dest" "$HOME/Android/Sdk"
  fi

  printf '%s' "$dest"
}

module_android() {
  # Requested platform API levels come from the params (android[30,36,wear-33]);
  # default to COOEE_ANDROID_DEFAULT_PLATFORM (API 36) when none are given.
  # Recorded in COOEE_ANDROID_PLATFORMS for reference; the module installs them
  # below (independent of where adb comes from, so record before the branch).
  local -a params=("$@")
  (( ${#params[@]} )) || params=("$COOEE_ANDROID_DEFAULT_PLATFORM")
  add_env COOEE_ANDROID_PLATFORMS "${params[*]}"

  local -a levels=()
  mapfile -t levels < <(cooee_android_levels "${params[@]}")

  # A param that names no level is a request we are about to ignore. Say so:
  # the alternative is installing the default platform, reporting success, and
  # letting the build discover the missing SDK much later.
  local -a unknown=()
  mapfile -t unknown < <(cooee_android_unknown_params "${params[@]}")
  if (( ${#unknown[@]} )); then
    warn "android: ignoring unrecognized platform param(s): ${unknown[*]} — expected an API level like 36, a minor-versioned one like 37.0 (API 37+ ships only as 37.0/37.1), or wear-NN."
  fi

  # A bare major from API 37 on is almost certainly the wrong spelling: Google
  # publishes those platforms only as NN.M, and androidenv keys them the same
  # way, so the request would reach Nix and die there ("The version 37 is
  # missing in package platforms"). Say it here, where the reader can act on it.
  # Advisory, not a rejection: androidenv stays the source of truth on which
  # keys exist, and a future bare key would still go through.
  local bare
  for bare in "${levels[@]}"; do
    [[ "$bare" == *.* ]] && continue
    (( bare >= 37 )) 2>/dev/null \
      && warn "android: platform '$bare' has no bare id in androidenv (API 37+ ships as NN.M) — did you mean ${bare}.0? Continuing as asked; the SDK build will say if it doesn't exist."
  done

  # Does the request also include android-emulator? It runs *after* us (canonical
  # order), but it's already registered and its params are already injected, so
  # we build the emulator + system images into the same SDK now. Its image levels
  # come from its params; fall back to the platform levels when it has none.
  local want_emu=0
  local -a img_levels=()
  if printf '%s\n' "${MODULES[@]}" | grep -qx android-emulator; then
    want_emu=1
    local emu_params="${_MODULE_PARAMS[android-emulator]:-}"
    mapfile -t img_levels < <(cooee_android_levels ${emu_params//,/ })
  fi

  # Adopt a complete SDK already on the box — one an image both installed and
  # exported (ANDROID_HOME/adb), OR one it shipped at a conventional location but
  # never exported (discovery recovers it). Bare `android` (no requested levels)
  # adopts as-is; an explicit level request only adopts when every requested
  # platform is already present — otherwise we install a known-good SDK below.
  # Independent of COOEE_FORCE: a ready SDK is the right answer whether or not
  # we're re-provisioning.
  local sdk
  if sdk=$(cooee_android_discover_sdk); then
    local missing=() l
    for l in "${levels[@]}"; do
      [[ -d "$sdk/platforms/android-$l" ]] || missing+=("$l")
    done
    if (( ${#missing[@]} == 0 )); then
      # Publish a conventional, guessable view (adds cmdline-tools/latest; no-op
      # for an SDK that already has it) and point everything at that.
      local home; home=$(cooee_android_publish_sdk "$sdk")
      [[ "$home" != "$sdk" ]] && ok "android: published a conventional SDK view at $home -> $sdk."
      add_env ANDROID_HOME "$home"
      add_env ANDROID_SDK_ROOT "$home"
      # The SDK may have been on disk with nothing on PATH (the image exported
      # neither the vars nor the tools). Wire platform-tools + cmdline-tools in so
      # adb/sdkmanager resolve here and in every later shell — the missing piece
      # that had the agent complaining about ANDROID_HOME.
      command -v adb >/dev/null 2>&1 \
        || add_env PATH "$home/platform-tools:$home/cmdline-tools/latest/bin:$PATH"
      cooee_android_pin_sdk_dir "$home"
      (( ${#params[@]} )) && warn "requested Android platforms: ${params[*]} (already present in $home)."
      ok "android: adopted complete SDK at $home ($(adb --version 2>/dev/null | head -1 || echo adb))."
      return 0
    fi
    warn "android: existing SDK at $sdk lacks platform(s): ${missing[*]} — installing a complete SDK via Nix."
  fi

  # Build a complete SDK. The backend decides HOW: the nix backend builds an
  # androidenv expression directly; the ?devenv backend writes a native `android`
  # integration into its devenv project and lets devenv build it. Both fetch the
  # SDK components from dl.google.com (fixed-output derivations) over the Nix
  # closure from cache.nixos.org, both honor the i686/ncurses5 stub, and both
  # return the SDK dir (…/libexec/android-sdk) — so everything below is shared.
  command -v nix >/dev/null 2>&1 || die "android: nix is required to install the SDK but isn't on PATH — re-run with COOEE_FORCE=1 so the base module installs it first."

  # Default to one platform when none was requested. The build needs every level
  # that either a platform or a system image targets.
  (( ${#levels[@]} )) || levels=("$COOEE_ANDROID_DEFAULT_PLATFORM")
  (( want_emu && ${#img_levels[@]} == 0 )) && img_levels=("${levels[@]}")
  local -a all_levels=()
  local l; for l in "${levels[@]}" "${img_levels[@]}"; do
    [[ " ${all_levels[*]:-} " == *" $l "* ]] || all_levels+=("$l")
  done

  # System image types: Google APIs always, plus Wear OS when a wear-NN platform
  # was requested. Only consulted when the emulator (hence system images) is
  # installed, but computed unconditionally so the backend always has them.
  local -a img_types=(google_apis)
  cooee_android_wants_wear "${params[@]}" && img_types+=(android-wear)

  # Hand the structured, backend-neutral request to the backend hook via the
  # agreed _COOEE_ANDROID_* globals, then let it provision. The hook sets
  # COOEE_ANDROID_SDK_DIR (and die()s on failure — called directly, NOT in a
  # command substitution, so the die exits the whole script rather than a subshell).
  _COOEE_ANDROID_LEVELS=("${all_levels[@]}")
  _COOEE_ANDROID_IMG_TYPES=("${img_types[@]}")
  _COOEE_ANDROID_WANT_EMU=$want_emu

  # Raise the build-tools revision when a requested platform outruns the default
  # (no-op for an explicit COOEE_ANDROID_BUILD_TOOLS). Set before the log line and
  # the backend, both of which read the variable.
  COOEE_ANDROID_BUILD_TOOLS=$(cooee_android_build_tools_for "${all_levels[@]}")

  log "Installing Android SDK (platforms: ${all_levels[*]}; build-tools ${COOEE_ANDROID_BUILD_TOOLS}$( (( want_emu )) && printf '; emulator + system images' )) ..."
  cooee_backend_android_sdk
  sdk="${COOEE_ANDROID_SDK_DIR:-}"
  [[ -n "$sdk" && -d "$sdk" ]] || die "android: backend did not return a valid SDK dir (got: '${sdk}')."

  # Publish a conventional, guessable view of the read-only store SDK (symlinks +
  # a cmdline-tools/latest alias) and point ANDROID_HOME / sdk.dir / PATH at it,
  # so agents that probe $HOME/Android/Sdk or /opt/android-sdk — or run
  # cmdline-tools/latest/bin/sdkmanager — find a working SDK.
  local home; home=$(cooee_android_publish_sdk "$sdk")
  [[ "$home" != "$sdk" ]] && ok "android: published a conventional SDK view at $home -> $sdk (cmdline-tools/latest + guessable path)."
  add_env ANDROID_HOME "$home"
  add_env ANDROID_SDK_ROOT "$home"
  cooee_android_pin_sdk_dir "$home"

  # Put the SDK's tools on PATH (and persist it) so adb / sdkmanager / emulator
  # resolve in this and every later shell.
  local bins="$home/platform-tools:$home/cmdline-tools/latest/bin"
  (( want_emu )) && bins+=":$home/emulator"
  add_env PATH "$bins:$PATH"

  command -v adb >/dev/null 2>&1 || die "android: adb not on PATH after install ($home)."
  ok "android SDK ready at $home: $(adb --version 2>/dev/null | head -1 || echo adb)"
  warn "platforms ${all_levels[*]} + build-tools ${COOEE_ANDROID_BUILD_TOOLS} installed (ANDROID_HOME=$home)."
}

# ===========================================================================
#  module: android-cli — Google's Android CLI (the agent-first `android` tool)
#    type     : a single prebuilt binary from Google (NOT a Nix package, NOT a
#               Claude SKILL.md). The official Android CLI from
#               developer.android.com/tools/agents — it standardizes agent-driven
#               Android workflows (scaffold projects, manage AVDs, run Journeys)
#               and is the entry point to the Android skills + Knowledge Base.
#               After install we run `android init` to register its agent skill.
#    software : the `android` binary, downloaded to ~/.local/bin and put on PATH.
#               It self-bootstraps the rest of its payload on first run.
#    params   : none.
#    hosts    : dl.google.com (the binary + its first-run payload)
#  A one-token install — `curl -fsSL https://env.coo.ee/android-cli | bash`. The
#  `android` SDK module implies this, so selecting the SDK installs the CLI too.
#  Standalone (it does NOT pull in the heavyweight Nix SDK): the CLI manages its
#  own SDK on demand. Pair it with `android` when you want the Nix-built SDK too.
#  Not a top-level pick: it rides along with `android` (which implies it) or
#  installs via its own one-liner, but the picker leaves it off the module list.
# ===========================================================================
# coo.ee:hidden
register_module android-cli
provides_tool android-cli android   # adopt an Android CLI already on PATH
# Pre-approve Google's agent-first `android` CLI for Claude Code sessions.
provides_perms android-cli "Bash(android:*)"
need_host dl.google.com "download of the Android CLI binary and its first-run payload"

# Where the official install.sh puts it. We manage PATH via the env profile
# (add_env) rather than editing shell rc files the way install.sh does.
COOEE_ANDROID_CLI_BIN_DIR="${COOEE_ANDROID_CLI_BIN_DIR:-$HOME/.local/bin}"

# Register the android-cli agent skill with `android init`. It installs into the
# detected agent skills dir (~/.claude, ~/.gemini/antigravity/skills, ...), i.e.
# the home dir — not this repo. Run non-interactively (stdin from /dev/null so it
# can never hang) and advisory: a hiccup here doesn't fail the provision, the CLI
# itself is installed. Opt out with COOEE_ANDROID_CLI_INIT=0.
cooee_android_cli_init() {  # cooee_android_cli_init <android binary>
  local bin=$1
  if [[ "${COOEE_ANDROID_CLI_INIT:-1}" == 0 ]]; then
    log "android-cli: skipping 'android init' (COOEE_ANDROID_CLI_INIT=0)."
    return 0
  fi
  log "android-cli: registering the agent skill via 'android init'..."
  if "$bin" init </dev/null >/dev/null 2>&1; then
    ok "android-cli: 'android init' registered the android-cli agent skill."
  else
    warn "android-cli: 'android init' didn't complete (network, or it wants"
    warn "interactive agent selection). Run 'android init' yourself to register"
    warn "the skill, or set COOEE_ANDROID_CLI_INIT=0 to silence this."
  fi
}

module_android-cli() {
  local bin
  # Adopt an Android CLI already on PATH (a warm box, or a previous run's install).
  if command -v android >/dev/null 2>&1; then
    bin="$(command -v android)"
    add_env PATH "$(dirname "$bin"):$PATH"
    ok "android-cli: adopted existing Android CLI ($bin)."
    cooee_android_cli_init "$bin"
    return 0
  fi

  command -v curl >/dev/null 2>&1 || die "android-cli: curl is required to download the Android CLI."

  # Map this host to Google's published build, mirroring the official install.sh.
  local os arch url_os
  os="$(uname -s)"; arch="$(uname -m)"
  case "$os $arch" in
    "Linux x86_64")  url_os="linux_x86_64"  ;;
    "Darwin x86_64") url_os="darwin_x86_64" ;;
    "Darwin arm64")  url_os="darwin_arm64"  ;;
    *) die "android-cli: no Android CLI build for '$os $arch' (supported: Linux x86_64, macOS x86_64/arm64)." ;;
  esac

  local dir="$COOEE_ANDROID_CLI_BIN_DIR"
  bin="$dir/android"
  mkdir -p "$dir"

  log "Downloading the Android CLI ($url_os) from dl.google.com..."
  # Retried: same class of failure as the Nix installer — a CDN 5xx here would
  # otherwise take the whole provisioning run down with an opaque curl exit code.
  cooee_fetch "https://dl.google.com/android/cli/latest/${url_os}/android" "$bin" \
    || die "android-cli: download failed after several attempts (is dl.google.com reachable?)."
  chmod +x "$bin"

  # Persist the bin dir on PATH for this and every later shell — via the env
  # profile, not ~/.bashrc (that's the installer's job; we own our own profile).
  add_env PATH "$dir:$PATH"
  export PATH="$dir:$PATH"

  # Force the binary's first-run self-download, exactly as install.sh does. Don't
  # fail the provision if this network step hiccups — the binary is in place and
  # will finish bootstrapping on first real use.
  ANDROID_CLI_FRESH_INSTALL=1 "$bin" >/dev/null 2>&1 \
    || warn "android-cli: first-run bootstrap didn't finish (network?); the 'android' binary is installed and bootstraps on first use."

  ok "android-cli ready: 'android' installed at $bin."

  # Register the agent skill (the whole point of installing the CLI for agents).
  cooee_android_cli_init "$bin"
}

# ===========================================================================
#  module: java
#    software : Temurin JDK (via Nix), JAVA_HOME, JDK TLS fix, plus
#               build-brief (the Gradle output reducer) when the checkout
#               actually builds with Gradle — see the build-brief section below
#    params   : java[17,21] picks the JDK majors. With no param, defaults to
#               BOTH 17 and 21 (the LTS majors this fleet builds against — AGP /
#               Gradle still pin 17 while app code targets 21), plus any distinct
#               toolchainVersion the project pins in gradle/gradle-daemon-jvm.properties.
#               Missing majors are installed via Nix; ones the base image already
#               ships are adopted, so "17 + 21" is cheap when one is present.
#    hosts    : cache.nixos.org (install)
#             : Gradle / Maven / toolchain registries (build; used by the
#               best-effort dependency prefetch, advisory — opt out COOEE_NO_DEPS=1)
#             : github.com + its release-asset CDN (build-brief; bb.staticvar.dev
#               is the fallback installer — advisory, opt out COOEE_NO_BUILD_BRIEF=1)
#  Host set mirrors skills/compose-preview/references/agent-cloud.md.
# ===========================================================================
register_module java
provides_tool java java   # adopt an existing JDK (warm box or cloud base image)
# Pre-approve the JVM build toolchain for Claude Code sessions.
provides_perms java "Bash(./gradlew:*)" "Bash(build-brief:*)" "Bash(gradle:*)" "Bash(java:*)" "Bash(javac:*)" "Bash(kotlin:*)" "Bash(kotlinc:*)" "Bash(mvn:*)"
need_host cache.nixos.org      "prebuilt Temurin JDK from the Nix cache"
want_host services.gradle.org  "Gradle distributions (wrapper download; 307-redirects to GitHub releases)"
want_host github.com           "Gradle distribution redirect target (gradle/gradle-distributions releases) + the build-brief release download (static-var/build-brief)"
want_host api.github.com       "GitHub release API for JDK/tool provisioning (Adoptium et al. resolve download URLs here; also the build-brief latest-release fallback)"
want_host release-assets.githubusercontent.com "GitHub release-asset CDN serving the Gradle distribution zip and the build-brief tarball (current host)"
want_host objects.githubusercontent.com "GitHub release-asset CDN (legacy host; still used for some assets)"
want_host bb.staticvar.dev     "build-brief's own install.sh — the fallback when the direct GitHub release download is blocked"
want_host downloads.gradle.org "Gradle direct-download host (legacy/non-wrapper distribution URLs)"
want_host repo.gradle.org      "Gradle tooling artifacts + the github-downloads-proxy that seeds the wrapper distribution (mirrors the GitHub release the wrapper's services.gradle.org URL 307-redirects to, which is often blocked)"
want_host central.sonatype.com "Maven Central artifacts"
want_host maven-central.storage-download.googleapis.com "Google's Maven Central mirror, which the Gradle init script resolves from before Central itself (see cooee_gradle_maven_mirror)"
# A private Maven cache, when the environment configures one (see cooee_gradle_maven_mirror). Its
# host is whatever COO_MAVEN_URL names; nothing here hard-codes one.
if [[ -n "${COO_MAVEN_URL:-}" ]]; then
  _cooee_cache_host="${COO_MAVEN_URL#*://}"; _cooee_cache_host="${_cooee_cache_host%%[/:]*}"
  [[ -n "$_cooee_cache_host" ]] && want_host "$_cooee_cache_host" "the private Maven cache COO_MAVEN_URL names, which the Gradle init script resolves Central, Google Maven and the plugin portal through when COO_MAVEN_USER/COO_MAVEN_TOKEN are set"
  unset _cooee_cache_host
fi
want_host api.foojay.io        "Java distro metadata for Gradle toolchains"
want_host api.adoptium.net     "JDK/toolchain provisioning API"
want_host cdn.azul.com         "Azul Zulu JDK builds for Gradle toolchain provisioning"
want_host download.java.net    "OpenJDK reference builds (a Gradle toolchain provisioning source)"
want_host jitpack.io           "dependencies published via JitPack"
# Amazon Corretto (another Gradle toolchain vendor) serves its JDK builds from
# a CloudFront distribution (*.cloudfront.net), which we can't pin to a concrete
# host for the IP firewall — allow *.cloudfront.net manually if you provision it.

# The JDK a Gradle project pins for its build — the toolchainVersion in the
# version file in the gradle directory (gradle/gradle-daemon-jvm.properties),
# the file Gradle's "Daemon JVM criteria" reads to decide which JDK the daemon
# must run on, e.g.
#   toolchainVersion=17
# yields 17. Empty when there is no such file, so the caller falls back to its
# own default. This is the JDK `./gradlew` would select, so matching it here
# means a param-less `java` provisions exactly what the build expects.
cooee_java_project_version() {
  local props=gradle/gradle-daemon-jvm.properties
  [[ -f "$props" ]] || return 0
  sed -n 's/^[[:space:]]*toolchainVersion[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$props" | head -1
}

# The default JDK major set when the request names none: BOTH LTS majors this
# fleet builds against (17 and 21), plus any distinct toolchain major the project
# pins for its Gradle daemon. One per line, unsorted (the caller canonicalizes).
cooee_java_default_versions() {
  local detected; detected=$(cooee_java_project_version)
  printf '17\n21\n'
  [[ -n "$detected" ]] && printf '%s\n' "$detected"
  return 0   # the trailing [[ ]] must not leak a non-zero rc (pipefail-safe)
}

# The feature (major) version of the JDK at the given home, e.g. 17. Prefers the
# `release` file (no JVM start); falls back to `java -version`. Empty on failure.
cooee_jdk_major() {
  local home="$1" v=""
  [[ -x "$home/bin/java" ]] || return 0
  [[ -r "$home/release" ]] && \
    v=$(sed -n 's/^JAVA_VERSION="\([0-9][0-9]*\).*/\1/p' "$home/release" | head -1)
  [[ -n "$v" ]] || v=$("$home/bin/java" -version 2>&1 | sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p' | head -1)
  printf '%s' "$v"
}

# Echo the JAVA_HOME of an already-installed JDK of the given feature major, or
# nothing (rc 1). Checks the active `java`, then the common toolchain locations
# Gradle also scans — so we don't reinstall a JDK the base image already ships.
cooee_jdk_home_for_major() {
  local major="$1" home cand
  if command -v java >/dev/null 2>&1; then
    home="$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")"
    [[ "$(cooee_jdk_major "$home")" == "$major" ]] && { printf '%s' "$home"; return 0; }
  fi
  for cand in /usr/lib/jvm/* /opt/jdk* /opt/*jdk* "$HOME"/.sdkman/candidates/java/*; do
    [[ -x "$cand/bin/java" ]] || continue
    [[ "$(cooee_jdk_major "$cand")" == "$major" ]] && { printf '%s' "$cand"; return 0; }
  done
  return 1
}

# The store path (JAVA_HOME) of a Nix-installed Temurin JDK of the given major,
# resolved from the flake ref. Needed because a JDK that isn't the profile's
# priority-winner has no bin/java in ~/.nix-profile/bin, so cooee_jdk_home_for_major
# (which scans PATH + on-disk locations) can't see it. Empty (rc 1) on failure.
cooee_nix_jdk_home() {  # <major>
  local major="$1" p
  command -v nix >/dev/null 2>&1 || return 1
  p=$(nix path-info "nixpkgs#temurin-bin-$major" 2>/dev/null | head -1)
  [[ -n "$p" && -x "$p/bin/java" ]] && { printf '%s' "$p"; return 0; }
  return 1
}

# JAVA_HOME of an installed JDK of the given major, from anywhere: an on-disk /
# base-image JDK first (cooee_jdk_home_for_major), else the Nix store. Empty (rc1).
cooee_jdk_home_any() {  # <major>
  local major="$1" h
  h=$(cooee_jdk_home_for_major "$major") && [[ -n "$h" ]] && { printf '%s' "$h"; return 0; }
  cooee_nix_jdk_home "$major"
}

# Symlink a JDK into /usr/lib/jvm — a Gradle "Common Linux Location" for toolchain
# auto-detection, and where agents look for a JDK by major — so a Nix JDK at an
# unguessable /nix/store path (and the non-active majors, absent from PATH) is
# discoverable. A JDK already under /usr/lib/jvm (a base image's own) is left as
# is. Best-effort: warns and skips if the dir isn't writable (needs root).
# Override the link dir with COOEE_JVM_LINK_DIR.
cooee_link_jdk_typical() {  # <java_home> <major>
  local home="$1" major="$2"
  [[ -x "$home/bin/java" ]] || return 0
  local jvmdir="${COOEE_JVM_LINK_DIR:-/usr/lib/jvm}"
  # Already in the conventional dir (base-image JDK)? Nothing to do.
  case "$home/" in "$jvmdir"/*) return 0 ;; esac
  local link="$jvmdir/temurin-$major"
  [[ "$(readlink -f "$link" 2>/dev/null)" == "$(readlink -f "$home" 2>/dev/null)" ]] && return 0
  mkdir -p "$jvmdir" 2>/dev/null || { warn "java: can't create $jvmdir; JDK $major not linked."; return 0; }
  [[ -w "$jvmdir" ]] || { warn "java: $jvmdir not writable; JDK $major not linked (needs root)."; return 0; }
  ln -sfn "$home" "$link" && ok "java: linked $link -> $home (Gradle toolchain auto-detection + guessable path)."
}

module_java() {
  # Requested JDK majors come from the request params (java[17,21]).
  local -a versions=("$@")

  # With no explicit param, default to BOTH LTS majors this fleet builds against
  # (17 and 21) plus any distinct toolchain major the project pins — a box that
  # ships only one of them makes half the builds fail. See cooee_java_default_versions.
  if (( ! ${#versions[@]} )); then
    mapfile -t versions < <(cooee_java_default_versions)
    log "java: no JDK requested; defaulting to 17 + 21 (plus any project-pinned toolchain)."
  fi

  # Canonicalize: ascending + unique, so the lowest major stays first (it owns
  # java/javac + JAVA_HOME) and a project pinning 17/21 doesn't duplicate.
  mapfile -t versions < <(printf '%s\n' "${versions[@]}" | sort -un)

  # Let the backend decide which of the requested majors it can install: the nix
  # backend keeps them all (each gets its own --priority below), the devenv
  # backend keeps only the first (one buildEnv can't hold two colliding JDKs).
  mapfile -t versions < <(cooee_backend_jdks "${versions[@]}")

  # Ensure every required major is present: adopt one already on the box (the
  # cloud base image, or a prior run) and install only the MISSING majors via
  # Nix. Detecting what's already there avoids a redundant ~200MB Temurin fetch
  # per JDK — which is what makes "default to 17 + 21" cheap when the image
  # already ships one. COOEE_FORCE=1 reinstalls everything via Nix. (nix_ensure is
  # itself idempotent, so a Nix JDK the dir-scan can't see is still not re-fetched.)
  local -a to_install=()
  local v home
  for v in "${versions[@]}"; do
    if [[ "${COOEE_FORCE:-0}" != 1 ]] && home=$(cooee_jdk_home_for_major "$v") && [[ -n "$home" ]]; then
      ok "java: JDK $v already present ($home); skipping install."
    else
      to_install+=("$v")
    fi
  done

  if (( ${#to_install[@]} )); then
    log "Installing missing Temurin JDK(s) (${to_install[*]}) via Nix..."
    # Multiple JDKs ship colliding files (e.g. lib/modules), so a single profile
    # can't hold them at the same priority — `nix profile add` aborts. Give each a
    # distinct priority (lower wins), ascending from 5 in install order (ascending
    # by major), so the lowest JDK owns the java/javac symlinks and the rest stay
    # discoverable by Gradle toolchain resolution.
    local prio=5
    for v in "${to_install[@]}"; do
      nix_ensure "temurin-bin-$v" "nixpkgs#temurin-bin-$v" --accept-flake-config --priority "$prio"
      prio=$((prio + 1))
    done
  else
    ok "java: all required JDK(s) (${versions[*]}) already present; nothing to install."
  fi

  # JAVA_HOME -> the lowest required major (the toolchain most repos pin to),
  # resolved whether it came from the base image or Nix. Fall back to whatever
  # `java` resolves to if the lowest can't be located (shouldn't happen).
  local jhome; jhome=$(cooee_jdk_home_for_major "${versions[0]}") || jhome=""
  if [[ -z "$jhome" ]]; then
    command -v java >/dev/null 2>&1 || die "java not on PATH after install."
    jhome="$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")"
  fi
  add_env JAVA_HOME "$jhome"

  # Link every provisioned JDK into the conventional /usr/lib/jvm so Gradle
  # toolchain auto-detection (a "Common Linux Location") and agents find each
  # major by a guessable path — a Nix JDK otherwise lives only at an unguessable
  # /nix/store path, and the non-active majors aren't even on PATH. Best-effort.
  local lv lh
  for lv in "${versions[@]}"; do
    if lh=$(cooee_jdk_home_any "$lv") && [[ -n "$lh" ]]; then
      cooee_link_jdk_typical "$lh" "$lv"
    else
      warn "java: couldn't locate JDK $lv on disk to link into /usr/lib/jvm; skipping."
    fi
  done

  # Cloud fixes (applied once — each sets JAVA_TOOL_OPTIONS, which every JVM
  # reads, so a single call covers all the JDKs): a Nix JDK ignores the system
  # trust store, so teach the shared truststore the sandbox proxy CA (else Gradle
  # HTTPS fails with PKIX errors); route the JVM through the sandbox proxy (it
  # ignores http(s)_proxy) so the Gradle wrapper/daemon can reach
  # services.gradle.org et al.; and force a UTF-8 locale + JVM file encoding so
  # Gradle report paths with non-ASCII chars don't die under a C locale.
  cooee_trust_cas_in_jdk "$JAVA_HOME"
  cooee_jvm_proxy_opts
  cooee_jvm_utf8_opts
  # Pin the same flags through Gradle's own canonical channel (user-dir
  # gradle.properties). This is the channel that actually carries them to a
  # build: JAVA_TOOL_OPTIONS is shell-only now and is restored before we exit.
  cooee_gradle_props_jvmargs
  # Before the prefetch, so the prefetch's own downloads already take the mirror
  # and ride out a 429.
  cooee_gradle_maven_mirror

  ok "java ready: $("$JAVA_HOME/bin/java" -version 2>&1 | head -1) (toolchains: ${versions[*]})"

  cooee_seed_gradle_wrapper
  cooee_build_brief_setup
  cooee_prefetch_gradle
}

# Cloud fix, the Gradle-native companion to the JAVA_TOOL_OPTIONS flags: pin
# every flag the cloud fixes added (see cooee_add_jvm_flag — proxy host/port +
# nonProxyHosts, the extra-CA truststore, -Dfile.encoding=UTF-8) on
#   org.gradle.jvmargs
# in the *user-dir* gradle.properties ($GRADLE_USER_HOME/gradle.properties,
# default ~/.gradle/gradle.properties).
#
# This is now the PRIMARY channel, not a backstop. JAVA_TOOL_OPTIONS is no longer
# forwarded to harness env files (see COOEE_ENV_SHELL_ONLY: harnesses replay
# those as unquoted shell, and a value with spaces and pipes turns into a spray
# of bogus commands on every command the agent runs) and is restored to its entry
# value before we exit. gradle.properties has neither problem: Gradle parses it
# as properties, it needs no environment inheritance, and it reaches the client,
# its daemon, and every forked worker — including a daemon started by an IDE or a
# bare ./gradlew, which never saw this shell.
#
# Merge-safe and idempotent — never clobbers a user's existing heap/other args:
#   * no gradle.properties / no org.gradle.jvmargs line -> append a fresh line
#     with our flags.
#   * an org.gradle.jvmargs line -> append only the flags whose *property name*
#     (the -Dkey= prefix) isn't already on it, so a value the user chose
#     deliberately (a different charset, their own proxy) always wins.
# Opt out entirely with COOEE_NO_GRADLE_PROPS=1.
cooee_gradle_props_jvmargs() {
  if [[ "${COOEE_NO_GRADLE_PROPS:-0}" == 1 ]]; then
    log "java: skipping user-dir gradle.properties JVM-args write (COOEE_NO_GRADLE_PROPS=1)."
    return 0
  fi
  [[ -n "${COOEE_JVM_FLAGS:-}" ]] || { log "java: no cloud JVM flags to pin in gradle.properties."; return 0; }

  local guh="${GRADLE_USER_HOME:-$HOME/.gradle}"
  local props="$guh/gradle.properties"
  mkdir -p "$guh" 2>/dev/null || {
    warn "java: can't create $guh; skipping user-dir gradle.properties JVM-args write."; return 0; }

  # Existing org.gradle.jvmargs line (empty when absent) — used to decide which
  # of our flags are already represented.
  local existing=""
  [[ -f "$props" ]] && existing=$(grep -E '^[[:space:]]*org\.gradle\.jvmargs[[:space:]]*=' "$props" | head -1)

  local -a add=()
  local flag key
  for flag in $COOEE_JVM_FLAGS; do
    key="${flag%%=*}="                       # -Dfile.encoding=UTF-8 -> -Dfile.encoding=
    [[ "$existing" == *"$key"* ]] && continue
    add+=("$flag")
  done
  if (( ! ${#add[@]} )); then
    log "java: $props already carries every cloud JVM flag; leaving it as-is."
    return 0
  fi

  if [[ -z "$existing" ]]; then
    if printf 'org.gradle.jvmargs=%s\n' "${add[*]}" >> "$props"; then
      ok "java: set org.gradle.jvmargs=${add[*]} in $props."
    else
      warn "java: couldn't write $props; skipping user-dir gradle.properties JVM-args write."
    fi
    return 0
  fi

  # Extend the existing line in place so the user's other JVM args (heap, GC, …)
  # are preserved.
  local tmp; tmp=$(mktemp "${TMPDIR:-/tmp}/cooee-gradle-props.XXXXXX") || {
    warn "java: couldn't stage a gradle.properties edit; leaving $props unchanged."; return 0; }
  if sed -E "s|^([[:space:]]*org\.gradle\.jvmargs[[:space:]]*=.*)$|\1 ${add[*]}|" \
       "$props" > "$tmp" && mv -f "$tmp" "$props"; then
    ok "java: appended ${add[*]} to org.gradle.jvmargs in $props."
  else
    warn "java: couldn't update $props; leaving it unchanged."; rm -f "$tmp"
  fi
}

# Cloud fix: Maven Central answers HTTP 429 to cloud sessions. They leave through a
# handful of shared egress IPs, Central's rate limit counts per IP, and every new
# session starts with an empty dependency cache — so the limit is hit by the fleet,
# not by any one build. Two changes, both in the Gradle user home so they reach
# every checkout on the box without touching any repository:
#
#   * $GRADLE_USER_HOME/init.d/cooee-maven-mirror.init.gradle puts Google's
#     Central mirror (maven-central.storage-download.googleapis.com, a GCS copy of
#     Central with far more headroom) in front of each repository handler that
#     declares Central — settings plugin/dependency repositories, buildscript and
#     project repositories alike. Central itself stays in place behind it, so an
#     artifact published minutes ago and not yet mirrored still resolves. A
#     handler that never declares Central gains no repository, so a
#     FAIL_ON_PROJECT_REPOS build is unaffected.
#   * The same script keeps Google Maven's groups (androidx, com.android, Play
#     services, Material, Firebase) off Central, its mirror and the plugin portal
#     whenever the build also declares Google Maven. The portal proxies to
#     Central, so a build that lists gradlePluginPortal() before google() would
#     otherwise send AGP's own dependencies there first — and a 429 is a failure,
#     not the 404 that would have moved on to Google. `androidx` (on Google Maven
#     alone) is kept off them even without Google Maven declared.
#   * A private Maven cache, when the environment configures one. The script reads
#     COO_MAVEN_URL (the cache's base URL), COO_MAVEN_USER and COO_MAVEN_TOKEN when
#     Gradle runs, not when this module provisions, so a variable set later (or only
#     in CI) still applies, and its secret is never written to disk. With all three
#     set, the build's own Central, Google Maven and plugin-portal repositories
#     resolve through <base>/central, <base>/google and <base>/gradle-plugins with
#     those credentials, keeping their place and content filters; the GCS mirror is
#     not added, since the cache already absorbs Central's rate limit. Other
#     repositories (jitpack, …) stay direct. With any of the three unset, the build
#     is exactly as above.
#   * systemProp.org.gradle.internal.network.retry.* in the user-dir
#     gradle.properties. Gradle already retries a 429 as transient, but only 3
#     attempts from a 1s backoff — gone before the window resets. 6 attempts from
#     2s (doubling) waits about a minute in all.
#
# Opt out with COOEE_NO_MAVEN_MIRROR=1 (an existing init script is retired; the
# retry settings still apply). The init script also honours COOEE_NO_GRADLE_INIT=1,
# and COOEE_NO_GRADLE_PROPS=1 turns off both, like every Gradle-user-home write.
COOEE_MAVEN_MIRROR_URL="${COOEE_MAVEN_MIRROR_URL:-https://maven-central.storage-download.googleapis.com/maven2/}"
COOEE_GRADLE_MIRROR_INIT_FILE="cooee-maven-mirror.init.gradle"

cooee_gradle_maven_mirror() {
  if [[ "${COOEE_NO_GRADLE_PROPS:-0}" == 1 ]]; then
    log "java: skipping the Maven Central mirror and retry settings (COOEE_NO_GRADLE_PROPS=1)."
    return 0
  fi
  local guh="${GRADLE_USER_HOME:-$HOME/.gradle}"
  local file="$guh/init.d/$COOEE_GRADLE_MIRROR_INIT_FILE"
  cooee_gradle_props_network_retry "$guh/gradle.properties"

  if [[ "${COOEE_NO_MAVEN_MIRROR:-0}" == 1 || "${COOEE_NO_GRADLE_INIT:-0}" == 1 ]]; then
    # An init script from an earlier run would keep applying; retire it.
    [[ -e "$file" ]] && { rm -f "$file" && log "java: retired the Maven Central mirror init script ($file)."; }
    log "java: skipping the Maven Central mirror (COOEE_NO_MAVEN_MIRROR/COOEE_NO_GRADLE_INIT=1)."
    return 0
  fi

  mkdir -p "$guh/init.d" 2>/dev/null || {
    warn "java: can't create $guh/init.d; builds will resolve straight from Maven Central."; return 0; }
  local tmp; tmp=$(mktemp "${TMPDIR:-/tmp}/cooee-gradle-mirror.XXXXXX") || {
    warn "java: couldn't stage $file; leaving it unchanged."; return 0; }

  # Groovy, applied to every build (and every included build) in this Gradle user
  # home. whenObjectAdded rather than one pass after settings: plugin repositories
  # are used before the settings script finishes, and a project's repositories
  # block runs after any hook placed ahead of it.
  cat > "$tmp" <<EOF
// Generated by coo.ee/env — do not edit; regenerated on each provision.
//
// Resolves from Google's Maven Central mirror before Central itself, wherever a
// build declares Central. Cloud sessions share a few egress IPs and Central rate-
// limits per IP (HTTP 429); the mirror has far more headroom. Central stays in
// place behind it for anything published too recently to be mirrored yet.
def mirrorUrl = '${COOEE_MAVEN_MIRROR_URL}'
def mirrorName = 'CooeeMavenCentralMirror'
def centralHosts = ['repo.maven.apache.org', 'repo1.maven.org'] as Set
def googleHosts = ['dl.google.com', 'maven.google.com'] as Set
// Only ever on Google Maven: never worth a round trip anywhere else.
def googleOnly = ['androidx']
// Served by Google Maven, with at most stale copies elsewhere. Kept off the
// portal, Central and the mirror only when the same build also declares Google
// Maven, so a lookup a 429 would fail never leaves for a host that would 404 it.
def googleHosted = ['androidx', 'com.android', 'com.google.android.gms',
                    'com.google.android.material', 'com.google.firebase']

// A private Maven cache, configured by the environment at build time: COO_MAVEN_URL (its base URL),
// COO_MAVEN_USER and COO_MAVEN_TOKEN. Only with all three set do the build's own Central, Google
// Maven and plugin-portal repositories resolve through it, each keeping its place and content
// filters; no host is named here, and nothing changes when any of them is unset.
def envOf = { n -> (System.getenv(n) ?: '').trim() }
def cacheBase = envOf('COO_MAVEN_URL').replaceAll('/+\$', '')
def cacheUser = envOf('COO_MAVEN_USER')
def cacheToken = envOf('COO_MAVEN_TOKEN')
def cacheOn = cacheBase && cacheUser && cacheToken
def cachePaths = ['repo.maven.apache.org': 'central', 'repo1.maven.org': 'central',
                  'dl.google.com': 'google', 'maven.google.com': 'google',
                  'plugins.gradle.org': 'gradle-plugins']
// The upstream host of each repository the cache took over, so the rules below still see what it is.
def upstream = new IdentityHashMap()
def hostOf = { r -> upstream.containsKey(r) ? upstream[r] :
  ((r instanceof MavenArtifactRepository && r.url != null) ? r.url.host : null) }
def route = { r ->
  if (!cacheOn || upstream.containsKey(r) || !(r instanceof MavenArtifactRepository) || r.url == null) return
  def path = cachePaths[r.url.host]
  if (path == null) return
  upstream[r] = r.url.host
  r.url = "\${cacheBase}/\${path}"
  r.credentials { c -> c.username = cacheUser; c.password = cacheToken }
}
def isMirror = { r -> r instanceof MavenArtifactRepository && r.name == mirrorName }
def isCentral = { r -> !isMirror(r) && centralHosts.contains(hostOf(r)) }
def isGoogle = { r -> googleHosts.contains(hostOf(r)) }
// The plugin portal proxies non-plugin artifacts to Central, 429s included.
def isFallThrough = { r -> isMirror(r) || isCentral(r) || hostOf(r) == 'plugins.gradle.org' }
def exclude = { r, groups -> r.content { c -> groups.each { g -> c.excludeGroupAndSubgroups(g) } } }

def wire = { RepositoryHandler repos ->
  repos.whenObjectAdded { r ->
    try {
      if (isGoogle(r)) {
        repos.toList().each { o -> if (isFallThrough(o)) exclude(o, googleHosted) }
        route(r)
        return
      }
      if (!isFallThrough(r)) return
      exclude(r, repos.any { o -> isGoogle(o) } ? googleHosted : googleOnly)
      route(r)
      if (cacheOn || !isCentral(r) || repos.findByName(mirrorName) != null) return
      def m = repos.maven { name = mirrorName; url = mirrorUrl }
      repos.remove(m)
      repos.addFirst(m)
    } catch (Throwable t) {
      // A build that resolves straight from Central beats one that won't configure.
      org.gradle.api.logging.Logging.getLogger('coo.ee/env').info("coo.ee/env: Maven mirror not applied: \${t}")
    }
  }
}

beforeSettings { s ->
  wire(s.buildscript.repositories)
  wire(s.pluginManagement.repositories)
  wire(s.dependencyResolutionManagement.repositories)
}
allprojects { p ->
  wire(p.buildscript.repositories)
  wire(p.repositories)
}
EOF

  if mv -f "$tmp" "$file"; then
    ok "java: Gradle resolves from Google's Maven Central mirror first, Central second ($file)."
  else
    warn "java: couldn't write $file; builds will resolve straight from Maven Central."
    rm -f "$tmp"
  fi
}

# Add Gradle's network-retry system properties to the user-dir gradle.properties
# unless the key is already there — a value the user chose wins, and a re-run adds
# nothing.
cooee_gradle_props_network_retry() {  # <props file>
  local props="$1" kv key
  local -a added=()
  mkdir -p "$(dirname "$props")" 2>/dev/null || {
    warn "java: can't create $(dirname "$props"); leaving Gradle's network retries at their defaults."; return 0; }
  for kv in \
    "systemProp.org.gradle.internal.network.retry.max.attempts=${COOEE_GRADLE_RETRY_ATTEMPTS:-6}" \
    "systemProp.org.gradle.internal.network.retry.initial.backOff=${COOEE_GRADLE_RETRY_BACKOFF_MS:-2000}"; do
    key="${kv%%=*}"
    [[ -f "$props" ]] && grep -qE "^[[:space:]]*${key//./\\.}[[:space:]]*=" "$props" && continue
    printf '%s\n' "$kv" >> "$props" || {
      warn "java: couldn't write $props; leaving Gradle's network retries at their defaults."; return 0; }
    added+=("$kv")
  done
  if (( ${#added[@]} )); then
    ok "java: set ${added[*]} in $props."
  else
    log "java: $props already sets Gradle's network retries; leaving them as-is."
  fi
}

# Seed the Gradle wrapper distributions into the wrapper cache from an
# allowlisted mirror, so the first `./gradlew` never has to fetch one over a path
# the sandbox blocks — for EVERY checkout in the workspace, not just the one
# project dir.
#
# The problem: a wrapper's distributionUrl points at services.gradle.org, which
# 307-redirects to a GitHub release
# (github.com/gradle/gradle-distributions/releases/…). Cloud sandboxes routinely
# block github.com's release assets, so the very first `./gradlew` dies fetching
# the distribution — before the build even starts, and before any host we
# allowlist for the *build* matters.
#
# The multi-checkout wrinkle: a session often has several repos checked out side
# by side (the workspace root, i.e. the parent of the project dir), and they can
# pin *different* Gradle versions in their own gradle/wrapper/gradle-wrapper.properties.
# Seeding only cooee_project_dir's wrapper covers one version; the other
# checkouts' first `./gradlew` still dies on the blocked redirect. So we discover
# every wrapper across the local checkouts, dedup by distributionUrl, and seed
# each distinct version.
#
# The fix: repo.gradle.org's github-downloads-proxy serves the identical bytes
# (it proxies the same GitHub release server-side) and is a normal Gradle host
# we already allow. So we fetch the distribution from there, checksum-verify it,
# and drop the zip into $GRADLE_USER_HOME/wrapper/dists exactly where the wrapper
# looks for it — Gradle then unpacks + re-verifies + marks it ready itself on the
# first invocation, with no network. No repo changes: the wrapper properties are
# read, never written.
#
# No-op when there is no wrapper anywhere, when a distribution is already cached
# (warm box / prior run), or when a distributionUrl isn't the services.gradle.org
# default (a custom/self-hosted URL is the project's own call). Best-effort: any
# failure warns and leaves that download to Gradle — it never fails provisioning.
# Runs regardless of COOEE_NO_DEPS, since it's about `./gradlew` working at all,
# not about warming dependencies.
cooee_seed_gradle_wrapper() {
  local -A seen=()
  local props url seeded=0
  while IFS= read -r props; do
    [[ -n "$props" ]] || continue
    url=$(cooee_gradle_distribution_url "$props")
    [[ -n "$url" ]] || continue
    # Same distribution pinned by more than one checkout — seed it once.
    [[ -n "${seen[$url]:-}" ]] && continue
    seen[$url]=1
    cooee_seed_one_gradle_wrapper "$props" "$url"
    seeded=$((seeded + 1))
  done < <(cooee_gradle_wrapper_props)
  (( seeded )) || log "java: no Gradle wrapper found in the local checkouts; nothing to seed."
}

# Every gradle-wrapper.properties across the local checkouts. Repos are typically
# checked out side by side under the workspace root (the parent of the project
# dir); each can pin its own Gradle version. Override the search root with
# COOEE_CHECKOUTS_DIR. Bounded depth keeps the scan cheap and still catches both a
# repo-root wrapper and a nested build's wrapper (e.g. <repo>/android/gradle/…).
cooee_gradle_wrapper_props() {
  local root; root="$(cooee_workspace_root)"   # parent holding the side-by-side checkouts
  [[ -d "$root" ]] || return 0
  find "$root" -maxdepth 5 -type f \
    -path '*/gradle/wrapper/gradle-wrapper.properties' 2>/dev/null | sort
}

# A property value from a gradle-wrapper.properties, unescaping the properties-file
# '\:' -> ':' and stripping any trailing CR. Empty output when absent.
cooee_gradle_prop() {
  local props="$1" key="$2" v
  [[ -f "$props" ]] || return 0
  v=$(sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" "$props" | head -1)
  v="${v%$'\r'}"; v="${v//\\:/:}"
  printf '%s' "$v"
}

# distributionUrl from a gradle-wrapper.properties (see cooee_gradle_prop).
cooee_gradle_distribution_url() { cooee_gradle_prop "$1" distributionUrl; }

# Resolve a wrapper store-base keyword (GRADLE_USER_HOME | PROJECT) to a path.
# PROJECT is the build root (the dir holding gradle/wrapper/…); the default and
# any unknown value map to GRADLE_USER_HOME (~/.gradle).
cooee_gradle_store_base() {
  local base="$1" repo="$2"
  case "$base" in
    PROJECT) printf '%s' "$repo" ;;
    *)       printf '%s' "${GRADLE_USER_HOME:-$HOME/.gradle}" ;;
  esac
}

# The wrapper cache dir where Gradle looks for a distribution's downloaded zip:
# <store>/<zipStorePath>/<distname>/<hash>, hash = base36(md5(url)). The zip AND
# the `.ok` marker Gradle writes both live in the zip store (zipStoreBase /
# zipStorePath), not the distribution dir (distributionBase / distributionPath) —
# so honor those rather than assuming the GRADLE_USER_HOME / wrapper/dists
# defaults. A wrapper pinning zipStoreBase=PROJECT or a custom zipStorePath would
# otherwise get the seed placed where Gradle never looks, and it would still fall
# back to the blocked services.gradle.org download. Args: props, url, repo (the
# build root holding gradle/wrapper/…). Empty output + rc 1 if the hash can't be
# computed (no md5 tool).
cooee_gradle_zip_dest() {
  local props="$1" url="$2" repo="$3"
  local zipname="${url##*/}" distname hash zbase zpath store
  distname="${zipname%.zip}"
  hash=$(cooee_gradle_wrapper_hash "$url") || return 1
  zbase=$(cooee_gradle_prop "$props" zipStoreBase); zbase="${zbase:-GRADLE_USER_HOME}"
  zpath=$(cooee_gradle_prop "$props" zipStorePath); zpath="${zpath:-wrapper/dists}"
  store=$(cooee_gradle_store_base "$zbase" "$repo")
  printf '%s/%s/%s/%s' "$store" "$zpath" "$distname" "$hash"
}

# Seed one wrapper's distribution (props file + its already-extracted distributionUrl)
# into the wrapper cache. See cooee_seed_gradle_wrapper for the rationale.
cooee_seed_one_gradle_wrapper() {
  local props="$1" url="$2"
  local repo="${props%/gradle/wrapper/gradle-wrapper.properties}"

  # Only handle the stock services.gradle.org distribution — the one whose GitHub
  # redirect is what gets blocked. A custom distributionUrl is left untouched.
  case "$url" in
    https://services.gradle.org/distributions/*.zip) : ;;
    *) log "java: $repo wrapper distributionUrl isn't the services.gradle.org default ($url); leaving the wrapper download to Gradle."; return 0 ;;
  esac

  local zipname="${url##*/}"                 # gradle-9.6.1-bin.zip
  local distname="${zipname%.zip}"           # gradle-9.6.1-bin
  local ver="${distname#gradle-}"            # 9.6.1-bin
  ver="${ver%-bin}"; ver="${ver%-all}"       # 9.6.1

  # The wrapper cache dir where Gradle looks for the downloaded zip, honoring the
  # wrapper's zipStoreBase/zipStorePath (see cooee_gradle_zip_dest).
  local dest; dest=$(cooee_gradle_zip_dest "$props" "$url" "$repo") \
    || { warn "java: couldn't compute the wrapper cache hash for $repo; leaving the wrapper download to Gradle."; return 0; }

  # Already there? Either Gradle installed it (.ok marker) or a prior seed placed
  # the zip pending unpack. Nothing to do.
  if [[ -f "$dest/$zipname.ok" || -f "$dest/$zipname" ]]; then
    log "java: Gradle $ver already present in the wrapper cache; nothing to seed."
    return 0
  fi

  # Rewrite services.gradle.org -> the github-downloads-proxy, which mirrors the
  # exact GitHub release asset (v<ver>/<zipname>) the redirect points at.
  local mirror="https://repo.gradle.org/gradle/github-downloads-proxy/gradle/gradle-distributions/releases/download/v$ver/$zipname"

  log "java: seeding Gradle $ver into the wrapper cache from repo.gradle.org (services.gradle.org's GitHub redirect is blocked here)..."

  local tmp; tmp=$(mktemp -d "${TMPDIR:-/tmp}/cooee-gradle-dist.XXXXXX") \
    || { warn "java: couldn't create a temp dir for the wrapper seed; skipping."; return 0; }
  local zip="$tmp/$zipname"
  if ! curl -fsSL --retry 3 -o "$zip" "$mirror"; then
    warn "java: couldn't download Gradle $ver from $mirror; leaving the wrapper download to Gradle."
    rm -rf "$tmp"; return 0
  fi

  # Verify: prefer the wrapper's pinned distributionSha256Sum, else the mirror's
  # published .sha256. A mismatch means don't trust the bytes — bail, don't seed.
  local want
  want=$(sed -n 's/^[[:space:]]*distributionSha256Sum[[:space:]]*=[[:space:]]*//p' "$props" | head -1)
  want="${want%$'\r'}"
  [[ -n "$want" ]] || want=$(curl -fsSL "$mirror.sha256" 2>/dev/null | tr -d '[:space:]')
  if [[ -n "$want" ]]; then
    local got; got=$(sha256sum "$zip" | cut -d' ' -f1)
    if [[ "$got" != "$want" ]]; then
      warn "java: Gradle $ver checksum mismatch (got $got, want $want); refusing to seed. Leaving the wrapper download to Gradle."
      rm -rf "$tmp"; return 0
    fi
    log "java: Gradle $ver checksum verified ($want)."
  else
    warn "java: no checksum available for Gradle $ver; seeding the download unverified."
  fi

  # Place the verified zip where the wrapper expects it. Gradle finds it there,
  # (re-)checksums it against any pinned sum, unpacks it, and writes the .ok
  # marker on the first `./gradlew` — all offline.
  mkdir -p "$dest" && mv -f "$zip" "$dest/$zipname" || {
    warn "java: couldn't place the Gradle $ver zip in the wrapper cache ($dest); skipping."
    rm -rf "$tmp"; return 0; }
  rm -rf "$tmp"
  ok "java: Gradle $ver seeded into the wrapper cache; ./gradlew will unpack it without fetching the distribution."
}

# base36(md5(s)) — reproduces org.gradle.wrapper.PathAssembler#getHash, the
# scheme Gradle uses to name a distribution's wrapper cache directory from its
# distributionUrl (MD5 of the URL, rendered as an unsigned BigInteger in base
# 36). Pure bash so it needs no python/bc: md5 -> hex, then repeated
# long-division of that base-16 bignum by 36, collecting remainders.
cooee_gradle_wrapper_hash() {
  local url="$1" hex
  if command -v md5sum >/dev/null 2>&1; then hex=$(printf '%s' "$url" | md5sum | cut -d' ' -f1)
  elif command -v md5 >/dev/null 2>&1; then hex=$(printf '%s' "$url" | md5 -q)
  else return 1; fi
  [[ ${#hex} -eq 32 ]] || return 1

  local -a nib=() out=(); local i
  for (( i=0; i<32; i++ )); do nib+=($((16#${hex:i:1}))); done

  local digits="0123456789abcdefghijklmnopqrstuvwxyz"
  while ((${#nib[@]})); do
    local -a q=(); local carry=0 started=0 d val qi
    for d in "${nib[@]}"; do
      val=$(( carry*16 + d )); qi=$(( val/36 )); carry=$(( val%36 ))
      if (( started || qi )); then q+=("$qi"); started=1; fi
    done
    out=("$carry" "${out[@]}")      # prepend this base36 digit (the remainder)
    nib=("${q[@]}")
  done

  local s=""; ((${#out[@]})) || s=0
  for d in "${out[@]}"; do s+="${digits:d:1}"; done
  printf '%s' "$s"
}

# Warm the Gradle build cache: with a JDK ready (and the Gradle/Maven hosts
# reachable), download the project's dependencies now so a later `./gradlew
# build` — possibly under tighter egress — can run from cache. Best-effort and
# never fatal.
#
# By default this resolves every resolvable configuration's *files* via a
# transient init script, so the actual artifact JARs land in the cache — not
# just the metadata that the `dependencies` report task alone fetches — without
# compiling anything. Set COOEE_GRADLE_DEPS_TASK to run a specific task instead
# (e.g. "assemble -x test"); the value is word-split so it can carry flags.
#
# Skipped when there is no Gradle build in the project dir, when neither a
# wrapper nor `gradle` is available, or when COOEE_NO_DEPS=1.
cooee_prefetch_gradle() {
  cooee_deps_enabled || { log "java: skipping Gradle dependency prefetch (COOEE_NO_DEPS=1)."; return 0; }

  local dir; dir=$(cooee_project_dir)
  if ! compgen -G "$dir"/settings.gradle* >/dev/null 2>&1 \
     && ! compgen -G "$dir"/build.gradle* >/dev/null 2>&1; then
    log "java: no Gradle build in $dir; skipping dependency prefetch."
    return 0
  fi

  # Prefer the project's wrapper (pins the exact Gradle version) over any
  # system gradle.
  local gradle
  if [[ -x "$dir/gradlew" ]]; then gradle="$dir/gradlew"
  elif command -v gradle >/dev/null 2>&1; then gradle=gradle
  else log "java: Gradle build present but no wrapper or gradle on PATH; skipping dependency prefetch."; return 0; fi

  local -a inv=(--no-daemon --console=plain)
  local label init=""
  if [[ -n "${COOEE_GRADLE_DEPS_TASK:-}" ]]; then
    # Explicit task override — word-split so it can carry flags.
    local -a task; read -r -a task <<< "$COOEE_GRADLE_DEPS_TASK"
    inv+=("${task[@]}")
    label="task: ${task[*]}"
  else
    # Default: an init script resolves every resolvable configuration's files
    # (leniently, so one unresolvable config can't fail the warm-up), forcing
    # artifact download across all projects. `help` just drives evaluation.
    init=$(mktemp "${TMPDIR:-/tmp}/cooee-gradle-prefetch.XXXXXX") || {
      warn "java: could not create a temp init script; skipping dependency prefetch."; return 0; }
    cat > "$init" <<'GRADLE'
gradle.projectsEvaluated {
  rootProject.allprojects { proj ->
    proj.configurations.each { conf ->
      if (conf.canBeResolved) {
        try { conf.resolvedConfiguration.lenientConfiguration.files }
        catch (Throwable t) { proj.logger.lifecycle("cooee: skip ${proj.path}:${conf.name} (${t.message})") }
      }
    }
  }
}
GRADLE
    inv+=(--init-script "$init" help)
    label="all resolvable configurations"
  fi

  log "java: prefetching Gradle build dependencies ($label)..."
  # Capture output so the success path stays quiet (resolution is noisy);
  # surface it only on failure, for diagnosis.
  local out rc=0
  out=$( cd "$dir" && "$gradle" "${inv[@]}" </dev/null 2>&1 ) || rc=$?
  [[ -n "$init" ]] && rm -f "$init"
  if [[ $rc -eq 0 ]]; then
    ok "java: Gradle build dependencies prefetched ($label)."
  else
    printf '%s\n' "$out" >&2
    warn "java: Gradle dependency prefetch failed (continuing). Allowlist the Gradle/Maven hosts, or set COOEE_NO_DEPS=1 to skip."
  fi
}

# ===========================================================================
#  build-brief — the Gradle output reducer, set up when Gradle is selected
# ===========================================================================
# `build-brief` (https://bb.staticvar.dev, static-var/build-brief, MIT, a single
# Go binary with no runtime deps) sits in front of Gradle: it writes every line
# Gradle emits to a log file and prints only what changes the next move — status,
# failed tasks, failed tests, warnings, build scan URLs, generated output paths.
# The Gradle exit code passes through unchanged, so it is safe anywhere a bare
# `./gradlew` was.
#
# Why this belongs in the *environment* rather than in a repo: an agent session
# runs `check` and full render pipelines in-session, and those bury their one
# real line in thousands — the reducer is what keeps that affordable, and it is
# only useful if the binary is already on PATH when the session starts. It is
# also a hard prerequisite for the shared-host Gradle launchers that repos are
# starting to ship (compose-ai-tools' `scripts/agent-gradle.sh` exits 1 with
# "build-brief is required" when it isn't installed), so provisioning it here is
# what makes those checkouts work unattended.
#
# Everything below is best-effort: a blocked CDN warns and moves on. Gradle
# still builds without the reducer, so this never fails a `java` provision.
#   COOEE_NO_BUILD_BRIEF=1        skip entirely
#   COOEE_BUILD_BRIEF=1           install even when no Gradle build was detected
#   COOEE_BUILD_BRIEF_VERSION=x.y.z   pin a release (default: latest); a
#                                     different version already on PATH is
#                                     replaced rather than adopted
#   COOEE_BUILD_BRIEF_BIN_DIR=dir     install location (default ~/.local/bin)
#   COOEE_NO_BUILD_BRIEF_GUIDE=1  install the binary but write no usage guide
COOEE_BUILD_BRIEF_REPO="${COOEE_BUILD_BRIEF_REPO:-static-var/build-brief}"
COOEE_BUILD_BRIEF_BIN_DIR="${COOEE_BUILD_BRIEF_BIN_DIR:-$HOME/.local/bin}"

# True when this environment is being provisioned *for Gradle*, which is the
# only case build-brief is for (it reduces Gradle output and nothing else —
# a Maven-only or plain-JDK checkout has no use for it).
#
# Gradle counts as selected when any of these hold:
#   * a Gradle wrapper exists in one of the side-by-side checkouts — the same
#     scan cooee_seed_gradle_wrapper uses, so "we seeded a distribution for it"
#     and "we install the reducer for it" can never disagree;
#   * `gradle` is already on PATH (a warm box, a base image, or `tools[gradle]`
#     from an earlier run);
#   * `tools[gradle]` is part of *this* request — the tools module may not have
#     run yet, so read the request params rather than PATH.
cooee_gradle_selected() {
  [[ -n "$(cooee_gradle_wrapper_props)" ]] && return 0
  command -v gradle >/dev/null 2>&1 && return 0
  local t; local -a requested=()
  IFS=',' read -r -a requested <<< "${_MODULE_PARAMS[tools]:-}"
  for t in "${requested[@]}"; do
    [[ "$t" == gradle || "$t" == *.gradle ]] && return 0
  done
  return 1
}

# Entry point, called from module_java: gate, adopt or install, then write the
# usage guide so an agent actually knows to reach for it.
cooee_build_brief_setup() {
  if [[ "${COOEE_NO_BUILD_BRIEF:-0}" == 1 ]]; then
    log "java: skipping build-brief (COOEE_NO_BUILD_BRIEF=1)."
    return 0
  fi
  if [[ "${COOEE_BUILD_BRIEF:-0}" != 1 ]] && ! cooee_gradle_selected; then
    log "java: no Gradle build selected (no wrapper in the checkouts, no gradle on PATH, no tools[gradle]); skipping build-brief. Force it with COOEE_BUILD_BRIEF=1."
    return 0
  fi

  # Adopt an existing install (warm box, or a previous run) — but still make
  # sure its dir is on PATH for later shells and that the guide is in place.
  # A version pin is the one thing adoption must not paper over: the request
  # asked for that release, so a different one on PATH falls through to the
  # install below rather than being accepted as good enough.
  local bin
  if bin="$(command -v build-brief 2>/dev/null)" && [[ -n "$bin" ]]; then
    if cooee_build_brief_pin_satisfied "$bin"; then
      cooee_build_brief_path "$(dirname "$bin")"
      ok "java: adopted existing build-brief ($bin, $("$bin" --version 2>/dev/null | head -1))."
      cooee_build_brief_guide
      return 0
    fi
    log "java: build-brief at $bin is not the pinned ${COOEE_BUILD_BRIEF_VERSION#v}; installing the pinned release."
  fi

  if cooee_build_brief_install; then
    cooee_build_brief_guide
  fi
  return 0
}

# Put <dir> on PATH for this shell and persist it, unless it is already there.
cooee_build_brief_path() {  # <dir>
  local dir="$1"
  case ":${PATH}:" in
    *":$dir:"*) return 0 ;;
  esac
  add_env PATH "$dir:$PATH"
  export PATH="$dir:$PATH"
}

# True when an already-present build-brief satisfies the request: either no
# version was pinned, or the binary reports exactly the pinned one. A binary
# that won't report a version counts as *not* satisfying a pin — under a pin,
# "can't tell" has to mean "install the release that was asked for".
cooee_build_brief_pin_satisfied() {  # <binary>
  local want="${COOEE_BUILD_BRIEF_VERSION:-}"
  [[ -n "$want" ]] || return 0
  local got; got=$("$1" --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+)+' | head -1)
  [[ -n "$got" && "$got" == "${want#v}" ]]
}

# This host's build-brief release asset suffix (<os>_<arch>), mirroring the
# upstream install.sh naming. Empty + rc 1 on a platform it doesn't publish for.
cooee_build_brief_platform() {
  local os arch
  case "$(uname -s)" in
    Linux)  os=linux  ;;
    Darwin) os=darwin ;;
    *) return 1 ;;
  esac
  case "$(uname -m)" in
    x86_64|amd64)  arch=amd64 ;;
    arm64|aarch64) arch=arm64 ;;
    *) return 1 ;;
  esac
  printf '%s_%s' "$os" "$arch"
}

# The latest published version, without the leading `v`. Resolved from the
# /releases/latest redirect (no API token, no rate limit); falls back to the
# release API when the redirect can't be followed. Empty + rc 1 on failure.
cooee_build_brief_latest_version() {
  local repo="$COOEE_BUILD_BRIEF_REPO" tag=""
  tag=$(curl -fsSL --retry 2 -o /dev/null -w '%{url_effective}' \
          "https://github.com/${repo}/releases/latest" 2>/dev/null \
        | sed -n 's#.*/releases/tag/\([^/?#]*\).*#\1#p' | head -1)
  if [[ -z "$tag" ]]; then
    tag=$(curl -fsSL --retry 2 "https://api.github.com/repos/${repo}/releases/latest" 2>/dev/null \
          | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
  fi
  [[ -n "$tag" ]] || return 1
  printf '%s' "${tag#v}"
}

# Download, verify and install the binary. Returns non-zero (after warning) when
# anything goes wrong — the caller treats that as "no reducer today", not fatal.
cooee_build_brief_install() {
  command -v curl >/dev/null 2>&1 || { warn "java: curl is required to install build-brief; skipping."; return 1; }
  command -v tar  >/dev/null 2>&1 || { warn "java: tar is required to install build-brief; skipping."; return 1; }

  local platform
  platform=$(cooee_build_brief_platform) || {
    warn "java: no build-brief release for '$(uname -s) $(uname -m)'; skipping (Gradle still builds without it)."
    return 1
  }

  local version="${COOEE_BUILD_BRIEF_VERSION:-}"
  if [[ -z "$version" ]]; then
    version=$(cooee_build_brief_latest_version) || {
      warn "java: couldn't resolve the latest build-brief release (github.com blocked?); trying the upstream installer."
      cooee_build_brief_install_sh; return $?
    }
  fi
  version="${version#v}"

  local repo="$COOEE_BUILD_BRIEF_REPO"
  local asset="build-brief_${version}_${platform}.tar.gz"
  local base="https://github.com/${repo}/releases/download/v${version}"
  local tmp; tmp=$(mktemp -d "${TMPDIR:-/tmp}/cooee-build-brief.XXXXXX") || {
    warn "java: couldn't create a temp dir for the build-brief download; skipping."; return 1; }

  log "java: installing build-brief ${version} (${platform}) from github.com..."
  if ! cooee_fetch "${base}/${asset}" "$tmp/$asset"; then
    rm -rf "$tmp"
    warn "java: couldn't download ${asset} (release-asset CDN blocked?); trying the upstream installer."
    cooee_build_brief_install_sh; return $?
  fi

  # Verify against the release's SHA256SUMS. A missing sums file (or no sha256
  # tool) is a warn-and-continue, exactly as upstream's installer treats it; a
  # *mismatch* is not — don't install bytes that failed their own checksum.
  local want got
  want=$(cooee_fetch "${base}/SHA256SUMS" "$tmp/SHA256SUMS" 2 2>/dev/null \
         && awk -v n="$asset" '$2 == n || $2 == "./"n { print $1; exit }' "$tmp/SHA256SUMS")
  if [[ -z "$want" ]]; then
    warn "java: no published checksum for ${asset}; installing unverified."
  elif command -v sha256sum >/dev/null 2>&1; then
    got=$(sha256sum "$tmp/$asset" | cut -d' ' -f1)
    if [[ "$got" != "$want" ]]; then
      rm -rf "$tmp"
      warn "java: build-brief checksum mismatch (got $got, want $want); refusing to install."
      return 1
    fi
  fi

  if ! tar -xzf "$tmp/$asset" -C "$tmp"; then
    rm -rf "$tmp"; warn "java: couldn't unpack ${asset}; skipping build-brief."; return 1
  fi

  local src; src=$(find "$tmp" -type f -name build-brief -print -quit 2>/dev/null)
  if [[ -z "$src" ]]; then
    rm -rf "$tmp"; warn "java: no build-brief binary inside ${asset}; skipping."; return 1
  fi

  local dir="$COOEE_BUILD_BRIEF_BIN_DIR"
  if ! (mkdir -p "$dir" && install -m 755 "$src" "$dir/build-brief"); then
    rm -rf "$tmp"; warn "java: couldn't install build-brief into $dir; skipping."; return 1
  fi
  rm -rf "$tmp"

  cooee_build_brief_path "$dir"
  ok "java: build-brief ready ($dir/build-brief, $("$dir/build-brief" --version 2>/dev/null | head -1))."
  # PATH is only prepended when $dir wasn't on it, so a copy sitting in an
  # earlier entry still wins. Say so rather than letting a pinned install look
  # like it took effect.
  local resolved; resolved="$(command -v build-brief 2>/dev/null)"
  [[ "$resolved" == "$dir/build-brief" ]] \
    || warn "java: another build-brief earlier on PATH ($resolved) shadows the one just installed in $dir."
  return 0
}

# Fallback path: upstream's own install.sh. Used only when the direct release
# download failed, since it needs one more host (bb.staticvar.dev) and resolves
# the same GitHub asset itself.
cooee_build_brief_install_sh() {
  local dir="$COOEE_BUILD_BRIEF_BIN_DIR"
  mkdir -p "$dir" || { warn "java: couldn't create $dir for build-brief; skipping."; return 1; }
  local args=(--bin-dir "$dir")
  [[ -n "${COOEE_BUILD_BRIEF_VERSION:-}" ]] && args+=(--version "${COOEE_BUILD_BRIEF_VERSION#v}")
  local sh; sh=$(mktemp "${TMPDIR:-/tmp}/cooee-bb-install.XXXXXX") || return 1
  if ! cooee_fetch "https://bb.staticvar.dev/install.sh" "$sh" 2; then
    rm -f "$sh"
    warn "java: build-brief install skipped — neither the GitHub release nor bb.staticvar.dev is reachable. Allowlist github.com (+ its release-asset CDN), or set COOEE_NO_BUILD_BRIEF=1 to silence this."
    return 1
  fi
  if bash "$sh" "${args[@]}" >/dev/null 2>&1; then
    rm -f "$sh"
    cooee_build_brief_path "$dir"
    ok "java: build-brief ready via bb.staticvar.dev ($dir/build-brief)."
    return 0
  fi
  rm -f "$sh"
  warn "java: build-brief's installer failed; continuing without the reducer."
  return 1
}

# ---- the guide -------------------------------------------------------------
# Installing the binary is half the job: a reducer nobody reaches for changes
# nothing. So write the usage rules where an agent will actually read them —
# a managed block in each agent's GLOBAL rules file (cooee_agent_rules_files:
# ~/.claude/CLAUDE.md always, plus Codex's AGENTS.md and Gemini's GEMINI.md when
# those agents are on the box), which every session in this container loads
# regardless of which checkout it opens.
#
# Deliberately NOT `build-brief --install`: that regenerates a managed block in
# the *checkout's* AGENTS.md, a git-tracked file. Dirtying provisioned working
# trees is the thing this project avoids everywhere else (see
# cooee_install_session_hook), and a repo that wants those rules committed
# already has them. The environment's copy carries the same rules plus the
# environment-specific ones (the repo launcher, the raw log path).
#
# The block is delimited by markers and rewritten in place, so re-running
# provisioning updates it rather than stacking copies. Opt out with
# COOEE_NO_BUILD_BRIEF_GUIDE=1.
COOEE_BUILD_BRIEF_MARK_START='<!-- coo.ee/env:build-brief:start -->'
COOEE_BUILD_BRIEF_MARK_END='<!-- coo.ee/env:build-brief:end -->'

cooee_build_brief_guide() {
  if [[ "${COOEE_NO_BUILD_BRIEF_GUIDE:-0}" == 1 ]]; then
    log "java: skipping the build-brief usage guide (COOEE_NO_BUILD_BRIEF_GUIDE=1)."
    return 0
  fi
  local md
  while IFS= read -r md; do
    cooee_build_brief_guide_write "$md"
  done < <(cooee_agent_rules_files)
  return 0
}

cooee_build_brief_guide_write() {  # <rules file>
  local md="$1" dir; dir="$(dirname "$1")"
  mkdir -p "$dir" || { warn "java: couldn't create $dir; skipping the build-brief guide."; return 0; }

  local tmp; tmp=$(mktemp "${TMPDIR:-/tmp}/cooee-bb-guide.XXXXXX") || {
    warn "java: couldn't stage the build-brief guide; skipping."; return 0; }

  # Existing content minus any previous block (awk drops start..end inclusive),
  # then the freshly rendered block appended.
  if [[ -f "$md" ]]; then
    if ! awk -v s="$COOEE_BUILD_BRIEF_MARK_START" -v e="$COOEE_BUILD_BRIEF_MARK_END" '
          $0 == s { skip = 1; next }
          skip    { if ($0 == e) skip = 0; next }
          { print }' "$md" > "$tmp"; then
      rm -f "$tmp"; warn "java: couldn't rewrite ${md/#$HOME/\~}; skipping the build-brief guide."; return 0
    fi
    # Collapse the trailing blank lines the removal may have left behind.
    printf '%s\n' "$(cat "$tmp")" > "$tmp.trim" && mv "$tmp.trim" "$tmp"
    [[ -s "$tmp" ]] && printf '\n' >> "$tmp"
  fi

  cooee_build_brief_guide_block >> "$tmp" || {
    rm -f "$tmp"; warn "java: couldn't render the build-brief guide; skipping."; return 0; }

  if mv "$tmp" "$md"; then
    ok "java: build-brief usage guide written to ${md/#$HOME/\~} (opt out: COOEE_NO_BUILD_BRIEF_GUIDE=1)."
  else
    rm -f "$tmp"; warn "java: couldn't write ${md/#$HOME/\~}; skipping the build-brief guide."
  fi
  return 0
}

# The guide itself. Rules 1-6 are build-brief's own documented behaviour (the
# block `build-brief --install` writes); the rest is what this environment adds.
cooee_build_brief_guide_block() {
  printf '%s\n' "$COOEE_BUILD_BRIEF_MARK_START"
  cat <<'MD'
## Gradle: run it through `build-brief`

This environment installed [`build-brief`](https://bb.staticvar.dev) because the
checkout builds with Gradle. It keeps the full log on disk and prints only the
parts that decide what you do next — failed tasks, failed tests, warnings, build
scan URLs, artifact paths — and it preserves Gradle's exit code exactly. Use it
for every Gradle invocation; `check` and render pipelines otherwise bury their
one real line in thousands.

- Prefer `build-brief ./gradlew ...` for the project wrapper, `build-brief gradle ...` for a PATH Gradle.
- Rewrite each Gradle segment of a chained command separately: `build-brief ./gradlew test && build-brief ./gradlew check`.
- The default output is intentionally short on clean success — that is the tool working, not output going missing.
- Report-style commands (`tasks`, `help`, `projects`, `dependencies`, `dependencyInsight`) keep their full bodies, so dependency debugging is unaffected.
- `build-brief ./gradlew --stacktrace ...` when you need Gradle's stack traces. Output-shaping flags (`--quiet`, `--warn`, `--warning-mode`, `--console`) are normalized, and explicit `--daemon` / `--no-daemon` are stripped so daemon reuse still happens.
- Preserve the raw log path it prints when handing a failure to another tool or agent — that file has everything the brief dropped.
- `build-brief doctor` is read-only and never runs Gradle; use it to check the setup.

Two things specific to this environment:

- **If the checkout ships its own Gradle launcher, prefer it.** A repo that caps
  automated builds on a shared host (e.g. `scripts/agent-gradle.sh`) already
  wraps `build-brief` and adds its own worker/priority limits; it requires
  `build-brief` on PATH, which is why this environment installs it. Follow that
  repo's rules for when to take its exclusive/serialized profile (typically
  `check` and broad render pipelines).
- **Don't run `build-brief --install` in a checkout.** It rewrites that repo's
  git-tracked `AGENTS.md`; these rules are installed in the environment instead,
  so a provisioned working tree stays clean.
MD
  printf '%s\n' "$COOEE_BUILD_BRIEF_MARK_END"
}

# ===========================================================================
#  FOOTER — short-circuit if already provisioned, else preconditions + install.
# ===========================================================================

# Fast path: skip the whole install when a previous run already provisioned
# exactly this module set and every tool is still on PATH. COOEE_FORCE=1 opts
# out (forces a re-provision / repair).
cooee_already_provisioned() {
  [[ "${COOEE_FORCE:-0}" == 1 ]] && return 1
  [[ -f "$COOEE_STAMP" ]] || return 1
  [[ "$(cat "$COOEE_STAMP" 2>/dev/null)" == "${MODULES[*]:-}" ]] || return 1
  local m
  for m in "${MODULES[@]}"; do
    if [[ "$m" == base ]]; then
      command -v nix >/dev/null 2>&1 || return 1
    else
      module_present "$m" || return 1
    fi
  done
  return 0
}

# Advisory pass: for every requested module, either note that we'll adopt an
# already-present tool, or — on a provider that ships it built-in — nudge the
# user to prefer the built-in instead of installing a redundant copy via Nix.
cooee_builtin_pass() {
  local m cmd envvar
  for m in "${MODULES[@]}"; do
    [[ "$m" == base ]] && continue
    cmd=${_PROVIDES_CMD["$m"]:-}
    envvar=${_BUILTIN_ENVVAR["$m"]:-}
    # Modules that don't install a tool-on-PATH (e.g. skills) opt out of the
    # adopt/built-in advice — they always do their own (idempotent) work.
    [[ -z "$cmd" ]] && continue
    if module_present "$m"; then
      ok "module '$m': '$cmd' already on PATH — adopting ${COOEE_PROVIDER_LABEL}'s build, no Nix install."
      continue
    fi
    case "$COOEE_PROVIDER" in
      codex)
        if [[ -n "$envvar" ]]; then
          warn "module '$m': Codex provides this from its base image — prefer it by setting ${envvar} in your Codex environment instead of installing via Nix."
        else
          warn "module '$m': Codex's base image may already ship this — prefer the image's version over a Nix install when available."
        fi ;;
      claude|gemini)
        : ;;   # adoption covers the present case; no version selector to point at
    esac
  done
}

main() {
  cooee_detect_provider
  log "coo.ee/env ${COOEE_VERSION} — modules: ${MODULES[*]:-} — provider: ${COOEE_PROVIDER}"
  cooee_heal_harness_env

  if cooee_already_provisioned; then
    ok "Already provisioned (${MODULES[*]:-}); skipping install."
    cooee_forward_persisted_env
    # Idempotent fixups that must survive the fast path. The stamp only proves
    # each module's *tool* is on PATH, so anything a module installs beside it
    # would otherwise never arrive on a box provisioned before that step
    # existed — and the SessionStart hook re-runs this on every session, so the
    # fast path is the common case, not the rare one. Each is cheap when there
    # is nothing to do, and guarded by declare -F because the rendered script
    # only contains the modules that were requested.
    # (java: build-brief, which the stamp's `command -v java` says nothing about.)
    if declare -F cooee_build_brief_setup >/dev/null; then
      cooee_build_brief_setup
    fi
    # (postgres: no server process survives a fresh container, and the stamp
    # only proves pg_ctl is installed — start the dev cluster every session.)
    if declare -F cooee_postgres_start >/dev/null; then
      cooee_postgres_start || warn "postgres: dev server not running; re-run with COOEE_FORCE=1 to repair."
    fi
    # (playwright: a project's lockfile can move to a new browser revision
    # between sessions — re-check the project's Playwright against the browsers.)
    if declare -F cooee_playwright_align >/dev/null; then
      cooee_playwright_align
    fi
    cooee_trust_cas_in_nss     # idempotent: browsers trust the proxy CA (see _header.sh)
    cooee_install_activation   # idempotent: ensure auto-activation is wired up
    log "Re-exported env from ${COOEE_PROFILE} (source it in a fresh shell)."
    log "Force a re-provision with COOEE_FORCE=1."
    return 0
  fi

  cooee_builtin_pass

  # Nix is only worth installing if at least one requested module will actually
  # install (i.e. is not adopted from the environment). If everything is already
  # provided, base is skipped too.
  local need_nix=0 m
  for m in "${MODULES[@]}"; do
    [[ "$m" == base ]] && continue
    if module_present "$m" && [[ "${COOEE_FORCE:-0}" != 1 ]]; then continue; fi
    need_nix=1
  done

  # Is this request adding to a box an earlier, different request provisioned?
  # Then keep that request's env and fold both into one (see "cumulative
  # requests" in _header.sh): the merged request is what the hook re-runs.
  local prev_seg this_seg additive=0
  prev_seg="$(cooee_previous_segment)"
  this_seg="$(cooee_request_segment)"
  COOEE_HOOK_SEGMENT="$(cooee_merge_segments "$prev_seg" "$this_seg")"
  if [[ -n "$prev_seg" && "$COOEE_HOOK_SEGMENT" != "$(cooee_merge_segments "" "$this_seg")" ]]; then
    additive=1
    log "Adding to this box's earlier request (${prev_seg}): it now carries ${COOEE_HOOK_SEGMENT} (COOEE_REPLACE=1 to replace instead)."
  fi

  if [[ "$additive" == 1 ]]; then
    # Keep the persisted env, and carry its PATH into this run so every PATH
    # snapshot a module persists still includes the earlier toolchains.
    local old_path
    old_path="$( (set +eu; . "$COOEE_PROFILE" >/dev/null 2>&1; printf '%s' "$PATH") 2>/dev/null || true)"
    [[ -n "$old_path" ]] && export PATH="$(printf '%s' "$old_path:$PATH" | tr ':' '\n' | awk 'NF && !seen[$0]++' | paste -sd: -)"
  else
    cooee_init_profile
  fi

  # Host preflight only matters when something will actually install — if every
  # requested tool is adopted from the environment, there is nothing to probe.
  if [[ "$need_nix" == 1 ]]; then
    gha_group "coo.ee: preconditions"
    check_preconditions
    gha_endgroup
  else
    ok "Nothing to install — every requested tool is already provided; skipping host preflight."
  fi

  for m in "${MODULES[@]}"; do
    gha_group "coo.ee: module ${m}"
    log "---- module: ${m} --------------------------------"
    if [[ "$m" == base ]]; then
      if [[ "$need_nix" == 1 ]]; then
        module_base
      else
        ok "base: every requested tool is already provided — skipping Nix install."
      fi
    else
      # Pass any request params (set_params, injected by the renderer) as args;
      # each module adopts an existing tool or installs via Nix.
      if [[ -n "${_MODULE_PARAMS[$m]:-}" ]]; then
        local -a _args
        IFS=',' read -r -a _args <<< "${_MODULE_PARAMS[$m]}"
        "module_${m}" "${_args[@]}"
      else
        "module_${m}"
      fi
    fi
    gha_endgroup
  done

  # Post-module finalizers: steps that need the *combined* result of several
  # modules and so can't live inside any one of them. Guarded by declare -F
  # because the rendered script only contains the modules that were requested.
  # (compose: point JAVA_HOME at a JDK that carries the Compose Desktop GL libs —
  # needs the JDK the `java` module settled on, and module order isn't fixed.)
  if declare -F cooee_compose_wrap_render_jdk >/dev/null; then
    cooee_compose_wrap_render_jdk
  fi
  # Not tied to any module: whichever one brought a browser (playwright, or the
  # provider's image), Chromium reads the proxy CA from ~/.pki/nssdb only.
  cooee_trust_cas_in_nss

  # The stamp names every module the box now carries — this run's plus, on an
  # additive run, the earlier request's — in the renderer's canonical order, so
  # the merged hook's next run matches it and takes the fast path.
  if [[ "$additive" == 1 && -f "$COOEE_STAMP" ]]; then
    { tr ' ' '\n' < "$COOEE_STAMP"; echo; printf '%s\n' "${MODULES[@]}"; } \
      | awk 'NF && $0 != "base" && !seen[$0]++' | LC_ALL=C sort \
      | { printf 'base'; while IFS= read -r m; do printf ' %s' "$m"; done; } > "$COOEE_STAMP.tmp"
    mv -f "$COOEE_STAMP.tmp" "$COOEE_STAMP"
  else
    printf '%s' "${MODULES[*]:-}" > "$COOEE_STAMP"
  fi
  printf '%s\n' "$COOEE_HOOK_SEGMENT" > "$COOEE_REQUEST_FILE"

  # Wire up auto-activation so the env applies without a manual `source`.
  cooee_install_activation

  # Hand JAVA_TOOL_OPTIONS back exactly as we found it. Provisioning above needed
  # the enriched value for its own JVM calls, but leaving it changed is what puts
  # it into a harness's environment snapshot — where a value with spaces and
  # pipes is replayed as unquoted shell and sprays "command not found" into every
  # later command. Gradle already has these flags via org.gradle.jvmargs, and a
  # shell that sources $COOEE_PROFILE still gets the full value, properly quoted.
  cooee_restore_java_tool_options

  echo
  ok "Environment ready: ${MODULES[*]:-}"
  log "Persisted env -> ${COOEE_PROFILE}"
  log "Auto-activation installed; new shells/sessions pick it up (opt out: COOEE_NO_ACTIVATE=1)."
}
main "$@"
