#!/usr/bin/env sh
#
# Cargo bootstrap installer.
#
# Usage:
#   curl -fsSL https://api.getcargo.io/install.sh | sh
#
# What it does (in order):
#   1. Installs @cargo-ai/cli globally via npm (or via npx fallback if npm install -g fails),
#      at the version the skills bundle pins (cargo/cli-version in getcargohq/cargo-skills;
#      falls back to latest when the pin is unreachable).
#   2. Authenticates: reuses an existing session if `cargo-ai whoami` already succeeds;
#      otherwise runs `cargo-ai login --oauth` (browser device flow) — or
#      `cargo-ai login --token "$CARGO_API_TOKEN"` when CARGO_API_TOKEN is set.
#   3. Installs the Cargo agent bundle for the DETECTED agent (CARGO_AGENT, else
#      env markers, else claude):
#        • Claude Code — preferred: the Cargo PLUGIN (`claude plugin marketplace add` +
#          install) delivering skills + approval hook + session hooks together, migrating
#          away any legacy installer scaffolding. Fallback (no `claude` CLI / declined /
#          plugin install failed): `skills add` + the standalone approval + session hooks.
#        • Codex / Cursor — skills via `skills add`; the approval + session hooks come from
#          the plugin, whose install here isn't fully scriptable, so the installer prints
#          the plugin steps. Never writes ~/.claude (that config is Claude Code's).
#   4. Claude only: optionally launches `claude` with the quickstart demo.
#
# Honors:
#   CARGO_AGENT                     — which agent to set up: claude | codex | cursor.
#                                     Unset → detected from env markers (CLAUDECODE /
#                                     CODEX_HOME), defaulting to claude. The agent
#                                     following /agent-install.txt sets this to itself.
#   CARGO_API_TOKEN                 — non-interactive auth via `cargo-ai login --token`.
#   CARGO_WORKSPACE_UUID            — passed to `cargo-ai login --oauth --workspace-uuid` to
#                                     skip the interactive workspace picker.
#   CARGO_INSTALL_NO_CLAUDE_PERMS=1 — fallback channel only: skip the approval-hook /
#                                     allowlist prompt. (The plugin channel bundles the
#                                     approval hook as part of the plugin consent.)
#   CARGO_INSTALL_HOOKS             — governs the Claude plugin (preferred) or, on the
#                                     fallback channel, the standalone session hooks:
#                                     unset prompts interactively, 1 installs without
#                                     prompting (used by the agent flow at
#                                     /agent-install.txt, where no TTY is available),
#                                     0 skips both entirely.
#   CARGO_INSTALL_NO_LAUNCH=1       — skip launching `claude` at the end.
#   CARGO_QUICKSTART_GOAL           — optional extra context passed to /cargo-quickstart
#                                     (e.g. a buyer persona); unset launches the guided
#                                     demo, which asks its one persona question itself.

set -eu

SCRIPT_VERSION="2026.07.09.4"

# ─────────────────────────────────────────────────────────────────────
# Output helpers
# ─────────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
  RESET="\033[0m"
  GREEN="\033[32m"
  CYAN="\033[36m"
  YELLOW="\033[33m"
  RED="\033[31m"
  GREY="\033[90m"
else
  RESET=""; GREEN=""; CYAN=""; YELLOW=""; RED=""; GREY=""
fi

say()  { printf "%b\n" "$*"; }
warn() { printf "%b\n" "${YELLOW}$*${RESET}" >&2; }
fail() { printf "%b\n" "${RED}$*${RESET}" >&2; exit 1; }

print_logo() {
  if [ -t 1 ]; then
    say "${CYAN}"
    say "██████    ████    █████    ██████   ██████"
    say "██    ░  ██  ██░  ██  ██   ██    ░  ██  ██░"
    say "██       ██████░  █████ ░  ██ ███   ██  ██░"
    say "██       ██  ██░  ██ ██    ██  ██░  ██  ██░"
    say "██████   ██  ██░  ██  ██   ██████░  ██████░"
    say "${RESET}"
    say "${GREY}  installer ${SCRIPT_VERSION}${RESET}"
  fi
}

CARGO_BIN="cargo-ai"

# ─────────────────────────────────────────────────────────────────────
# Step 1 — install @cargo-ai/cli
#
# The skills repo pins the CLI version it was written against in
# cargo/cli-version (getcargohq/cargo-skills). Installing that version keeps
# the CLI and the skill docs coherent. The pin is a coherence optimization,
# never a gate: unreachable/invalid pin → install @latest as before.
# ─────────────────────────────────────────────────────────────────────
CLI_PIN=""
resolve_cli_pin() {
  PIN_URL="https://raw.githubusercontent.com/getcargohq/cargo-skills/main/cargo/cli-version"
  PIN="$(curl -fsSL --max-time 5 "$PIN_URL" 2>/dev/null | tr -d '[:space:]' || true)"
  if printf '%s' "$PIN" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
    CLI_PIN="$PIN"
  fi
}

ensure_node() {
  command -v node >/dev/null 2>&1 || fail "Node.js is required. Install from https://nodejs.org/ and re-run."
  command -v npm  >/dev/null 2>&1 || fail "npm is required (ships with Node.js). Re-install Node from https://nodejs.org/."
}

# Install (or converge to) the pinned CLI. Ordering of attempts mirrors the
# SessionStart hook so first-run and steady-state never diverge:
#   1. pinned global   (npm install -g @cargo-ai/cli@<pin>)
#   2. latest global   (retry unpinned — recovers from a bad/unpublished pin,
#      the case a pinned-only install would strand)
#   3. npx UNPINNED    (perms blocked a global install; deliberately not pinned
#      — a pin that just failed to install would also fail under npx, and an
#      unpinned `npx @cargo-ai/cli` both works and matches the allowlist glob)
install_cli() {
  DESIRED="@cargo-ai/cli${CLI_PIN:+@$CLI_PIN}"

  if command -v cargo-ai >/dev/null 2>&1; then
    # An older global binary may already be on PATH. If the bundle pins a
    # version and the installed one differs, converge now so the rest of this
    # run (and the skills install) actually uses the pinned CLI instead of
    # waiting for a later SessionStart hook to fix it.
    if [ -n "$CLI_PIN" ]; then
      HAVE="$(cargo-ai --version 2>/dev/null | tr -d '[:space:]' || echo "")"
      case "$HAVE" in
        *"$CLI_PIN"*) say "${GREY}  cargo-ai already on PATH at the pinned version — skipping.${RESET}"; return 0 ;;
        *) say "${GREY}  Converging cargo-ai to the pinned @${CLI_PIN} (was: ${HAVE:-unknown})...${RESET}"
           npm install -g "$DESIRED" >/dev/null 2>&1 || true
           return 0 ;;
      esac
    fi
    say "${GREY}  cargo-ai already on PATH — skipping install.${RESET}"
    return 0
  fi

  if [ -n "$CLI_PIN" ]; then
    say "${GREY}  Installing @cargo-ai/cli@${CLI_PIN} (pinned by the skills bundle)...${RESET}"
  else
    say "${GREY}  Installing @cargo-ai/cli globally...${RESET}"
  fi
  NPM_LOG=$(mktemp 2>/dev/null || echo "/tmp/cargo-npm-install.log")
  if npm install -g "$DESIRED" >"$NPM_LOG" 2>&1; then
    rm -f "$NPM_LOG"
    say "${GREEN}✓ Cargo CLI installed.${RESET}"
    return 0
  fi
  # Retry unpinned before giving up on a global install — a bad pin fails here,
  # a permissions problem fails again (and we fall through to npx below).
  if [ -n "$CLI_PIN" ] && npm install -g @cargo-ai/cli >"$NPM_LOG" 2>&1; then
    rm -f "$NPM_LOG"
    warn "  Pinned @${CLI_PIN} did not install; used @latest instead. A later session converges to the pin."
    say "${GREEN}✓ Cargo CLI installed (@latest).${RESET}"
    return 0
  fi
  warn "  npm install -g failed (likely a permissions issue). Output:"
  sed 's/^/    /' "$NPM_LOG" >&2
  rm -f "$NPM_LOG"
  warn "  Falling back to npx — every \`cargo-ai\` call will resolve via \`npx @cargo-ai/cli\`."
  if ! command -v npx >/dev/null 2>&1; then
    fail "npx not found and npm install -g failed. Fix npm permissions or install Node.js with a writable global prefix."
  fi
  # Unpinned on purpose (see the ordering note above): resolves to latest, works
  # when a pin doesn't, and matches the Bash(npx @cargo-ai/cli *) allowlist glob.
  CARGO_BIN="npx @cargo-ai/cli"
}

# ─────────────────────────────────────────────────────────────────────
# Step 2 — authenticate
#
# As of @cargo-ai/cli's device-flow auth release, `cargo-ai login` requires
# exactly one of `--token <token>` or `--oauth`. We prefer `--oauth` because
# it avoids the user having to generate, copy, and paste an API token, and
# falls back to `--token "$CARGO_API_TOKEN"` for non-interactive installs.
# ─────────────────────────────────────────────────────────────────────
authenticate() {
  if $CARGO_BIN whoami >/dev/null 2>&1; then
    say "${GREEN}✓ Already authenticated.${RESET}"
    return 0
  fi

  TOKEN="${CARGO_API_TOKEN:-}"
  if [ -n "$TOKEN" ]; then
    say "${GREY}  Signing in with CARGO_API_TOKEN (token mode)...${RESET}"
    if ! $CARGO_BIN login --token "$TOKEN" >/dev/null 2>&1; then
      fail "cargo-ai login --token failed. Verify the token at https://app.getcargo.io → Settings → API."
    fi
  else
    if [ ! -r /dev/tty ]; then
      fail "No TTY available for browser sign-in. Re-run with CARGO_API_TOKEN=<token> set, or invoke this script from an interactive shell."
    fi

    say ""
    say "${CYAN}Signing in to Cargo via your browser...${RESET}"
    say "${GREY}  A verification URL will be printed and your default browser opened.${RESET}"
    say "${GREY}  If you have multiple workspaces, you'll be prompted to pick one.${RESET}"
    say ""

    # Build the login args. CARGO_WORKSPACE_UUID, when set, lets non-interactive
    # callers skip the workspace picker (e.g. users in many workspaces piping
    # this script through a non-TTY stdin while keeping /dev/tty for the
    # browser-driven device flow).
    set -- login --oauth
    if [ -n "${CARGO_WORKSPACE_UUID:-}" ]; then
      set -- "$@" --workspace-uuid "$CARGO_WORKSPACE_UUID"
    fi

    # Redirect stdin from /dev/tty so the workspace picker (when present) can
    # read input even though the installer itself is being piped from curl.
    # Discard stdout (the CLI prints a JSON success blob there); keep stderr
    # so the OAuth verification URL, code, and prompts remain visible.
    if ! $CARGO_BIN "$@" </dev/tty >/dev/null; then
      fail "cargo-ai login --oauth failed. Re-run with CARGO_API_TOKEN=<token> for non-interactive auth."
    fi
  fi

  $CARGO_BIN whoami >/dev/null 2>&1 || fail "Login appeared to succeed but whoami fails. Check network and re-run."
  say "${GREEN}✓ Authenticated.${RESET}"
}

# ─────────────────────────────────────────────────────────────────────
# Step 3 — install the agent bundle
#
# Preferred channel on Claude Code: the Cargo PLUGIN. One install delivers the
# skills, the cargo-ai approval hook, and the session-lifecycle hooks, and the
# plugin keeps itself current (its SessionStart hook refreshes the plugin for
# the next session) — so nothing needs to be scaffolded under ~/.claude/ and
# there is exactly one copy of everything. When the plugin channel is taken,
# legacy scaffolding from earlier installer versions (standalone session hooks,
# the downloaded approval hook, blanket allowlist entries) is migrated away so
# the plugin's copies are the only ones left.
#
# Fallback channel (no `claude` CLI, user declined, or plugin install failed):
# `npx skills add` plus the standalone approval hook (install_claude_permissions)
# and session hooks (install_session_hooks) exactly as before.
#
# Codex and Cursor plugin installs are UI/menu-driven and can't be scripted, so
# their skills always come from `skills add` and the installer prints the
# plugin instructions for anyone who wants hook gating there too.
# ─────────────────────────────────────────────────────────────────────
CARGO_SKILLS_CHANNEL="skills-add"

# Which coding agent are we setting up? The bundle differs per agent: only
# Claude Code has a non-plugin hook fallback (~/.claude/), so Codex/Cursor must
# NOT get ~/.claude scaffolding they'll never read. Resolution order:
#   1. CARGO_AGENT env/arg — the agent self-identifies (most reliable; the
#      agent following /agent-install.txt knows what it is).
#   2. Env markers — Claude Code exports CLAUDECODE/CLAUDE_CODE_ENTRYPOINT,
#      Codex exports CODEX_HOME. (Cursor has no stable marker — hence #1.)
#   3. Default to claude.
CARGO_AGENT_RESOLVED=""
resolve_agent() {
  a="${CARGO_AGENT:-}"
  if [ -z "$a" ]; then
    if [ -n "${CLAUDECODE:-}" ] || [ -n "${CLAUDE_CODE_ENTRYPOINT:-}" ]; then
      a="claude"
    elif [ -n "${CODEX_HOME:-}" ]; then
      a="codex"
    else
      a="claude"
    fi
  fi
  case "$a" in
    claude | codex | cursor) ;;
    *) warn "  Unknown CARGO_AGENT='$a' — defaulting to claude."; a="claude" ;;
  esac
  CARGO_AGENT_RESOLVED="$a"
}

# Codex / Cursor: skills come from `skills add`; the approval + session hooks
# come only from the plugin, whose install on these agents is not fully
# scriptable — so install the skills and PRINT the plugin steps. Never touch
# ~/.claude (that config belongs to Claude Code, not these agents).
install_bundle_other() {
  agent="$1"
  install_skills "$agent"
  say ""
  say "${CYAN}Cargo skills installed for ${agent}.${RESET} For prompt-free approval of safe"
  say "  cargo-ai commands (and session logging), add the Cargo plugin:"
  case "$agent" in
    codex)
      say "    ${GREY}codex plugin marketplace add getcargohq/cargo-skills${RESET}"
      say "    ${GREY}then enable \"cargo\" from the Codex Plugins menu.${RESET}" ;;
    cursor)
      say "    ${GREY}Cursor → Customize (sidebar) → add the getcargohq/cargo-skills marketplace → install Cargo.${RESET}" ;;
  esac
}

install_agent_bundle() {
  case "$CARGO_AGENT_RESOLVED" in
    codex | cursor) install_bundle_other "$CARGO_AGENT_RESOLVED"; return 0 ;;
  esac
  # ── Claude Code ────────────────────────────────────────────────────
  if command -v claude >/dev/null 2>&1 && [ "${CARGO_INSTALL_HOOKS:-}" != "0" ]; then
    WANT_PLUGIN=""
    if [ "${CARGO_INSTALL_HOOKS:-}" = "1" ]; then
      say "${GREY}  Installing the Cargo plugin for Claude Code (CARGO_INSTALL_HOOKS=1).${RESET}"
      WANT_PLUGIN=1
    elif [ -t 0 ] || [ -r /dev/tty ]; then
      say ""
      say "${CYAN}Install the Cargo plugin for Claude Code?${RESET}"
      say "  One install: the skills, prompt-free approval for safe cargo-ai commands"
      say "  (login/token/report/deploy/destroy/remove still ask), and session logging."
      say "  Updates itself each session. Revert any time with: claude plugin uninstall cargo@cargo"
      printf "  [y/N]: "
      REPLY=""
      if [ -r /dev/tty ]; then
        IFS= read -r REPLY </dev/tty || true
      else
        IFS= read -r REPLY || true
      fi
      case "$REPLY" in
        y|Y|yes|YES) WANT_PLUGIN=1 ;;
        *) say "${GREY}  Skipping the plugin — using skills add instead.${RESET}" ;;
      esac
    fi

    if [ -n "$WANT_PLUGIN" ]; then
      if claude plugin marketplace add getcargohq/cargo-skills >/dev/null 2>&1 \
         && claude plugin install cargo@cargo >/dev/null 2>&1; then
        CARGO_SKILLS_CHANNEL="plugin"
        cleanup_legacy_scaffolding
        say "${GREEN}✓ Cargo plugin installed (skills + approval hook + session hooks).${RESET}"
      else
        warn "  Plugin install failed — falling back to skills add."
      fi
    fi
  fi

  if [ "$CARGO_SKILLS_CHANNEL" = "plugin" ]; then
    # The plugin fully serves Claude Code (skills + hooks). Nothing else to do —
    # if the user also works in Codex/Cursor, they run this installer there
    # (it detects the agent and sets that one up).
    say "${GREY}  Using Codex or Cursor too? Re-run this installer from that agent.${RESET}"
    return 0
  fi

  # Fallback channel (no claude CLI, declined, or plugin install failed):
  # skills for Claude Code + the standalone approval and session hooks.
  install_skills "claude-code"
  install_claude_permissions
  install_session_hooks
}

# Remove scaffolding written by earlier installer versions once the plugin owns
# the lifecycle: the standalone session hooks, the downloaded approval hook,
# their settings.json registrations, and the blanket allowlist entries (which
# would defeat the plugin hook's gating). Idempotent; missing files are fine.
cleanup_legacy_scaffolding() {
  SETTINGS="$HOME/.claude/settings.json"
  [ -f "$SETTINGS" ] || return 0
  command -v node >/dev/null 2>&1 || return 0
  # Deregister the legacy hooks from settings.json FIRST. Only if that succeeds
  # do we delete the hook files — otherwise (e.g. settings.json is unparseable)
  # we would strip the files while their registrations remain, leaving Claude
  # Code pointing at hooks that no longer exist. The `if` also keeps a non-zero
  # exit from tripping `set -e`.
  if CARGO_SETTINGS_PATH="$SETTINGS" CARGO_HOOKS_DIR="$HOME/.claude/hooks" node -e "$(cat <<'JS'
const fs = require('fs');
const path = require('path');
const p = process.env.CARGO_SETTINGS_PATH;
const hooksDir = process.env.CARGO_HOOKS_DIR;
const legacyCmds = new Set([
  path.join(hooksDir, 'session-start.sh'),
  path.join(hooksDir, 'session-end.sh'),
  path.join(hooksDir, 'session-checkpoint.sh'),
  path.join(hooksDir, 'approve-cargo-cli.sh') + ' claude',
]);
const legacyAllow = ['Bash(cargo-ai *)', 'Bash(npx @cargo-ai/cli *)', 'Bash(npx @cargo-ai/cli@* *)'];
let data;
try {
  const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) process.exit(2);
  data = parsed;
} catch (_e) {
  process.exit(2); // unparseable settings — signal the shell to KEEP the hook files
}
if (data.hooks && typeof data.hooks === 'object' && !Array.isArray(data.hooks)) {
  for (const event of Object.keys(data.hooks)) {
    if (!Array.isArray(data.hooks[event])) continue;
    data.hooks[event] = data.hooks[event].filter((entry) =>
      !(entry && Array.isArray(entry.hooks) &&
        entry.hooks.some((h) => h && typeof h.command === 'string' && legacyCmds.has(h.command))));
    if (data.hooks[event].length === 0) delete data.hooks[event];
  }
}
if (data.permissions && Array.isArray(data.permissions.allow)) {
  data.permissions.allow = data.permissions.allow.filter((item) => !legacyAllow.includes(item));
}
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
JS
)"; then
    rm -f "$HOME/.claude/hooks/session-start.sh" \
          "$HOME/.claude/hooks/session-end.sh" \
          "$HOME/.claude/hooks/session-checkpoint.sh" \
          "$HOME/.claude/hooks/approve-cargo-cli.sh" 2>/dev/null || true
  else
    warn "  Could not update ~/.claude/settings.json (unparseable?) — left the legacy"
    warn "  session hooks in place so its registrations don't dangle. Fix the JSON and re-run."
  fi
}

install_skills() {
  SKILLS_AGENTS="${1:-claude-code cursor codex}"
  if ! command -v npx >/dev/null 2>&1; then
    warn "  npx not found — skipping skill install."
    warn "  Run later: npx skills add getcargohq/cargo-skills --agents $SKILLS_AGENTS --global --yes"
    return 0
  fi
  say "${GREY}  Installing Cargo skills for ${SKILLS_AGENTS}...${RESET}"
  # shellcheck disable=SC2086 — SKILLS_AGENTS is a deliberate word-split list.
  if npx --yes skills add getcargohq/cargo-skills \
       --agents $SKILLS_AGENTS \
       --global --yes --skill '*' --full-depth >/dev/null 2>&1; then
    say "${GREEN}✓ Cargo skills installed.${RESET}"
  else
    warn "  Skill install failed. Run manually:"
    warn "    npx skills add getcargohq/cargo-skills --agents $SKILLS_AGENTS --global --yes"
  fi
}

# ─────────────────────────────────────────────────────────────────────
# Step 4 — Claude permission handling for cargo-ai
#
# Preferred: a PreToolUse approval hook (hooks/approve-cli.sh from the skills
# repo) that auto-approves safe `cargo-ai` calls — reads, queries, run/batch
# operations, pipelines through read-only helpers — while credentials
# (login/logout), token minting, report egress, cdk deploy/destroy, and any
# remove/delete always still prompt. The hook is allow-only: it can skip a
# prompt but never override a deny rule. Existing blanket
# Bash(cargo-ai *) allowlist entries from earlier installer versions are
# removed when the hook installs (they would defeat the gating).
#
# Fallback (hook not fetchable): the legacy Bash(cargo-ai *) allowlist merge.
# ─────────────────────────────────────────────────────────────────────
install_claude_permissions() {
  [ "${CARGO_INSTALL_NO_CLAUDE_PERMS:-0}" = "1" ] && return 0
  if ! command -v node >/dev/null 2>&1; then
    return 0
  fi

  if [ -t 0 ] || [ -r /dev/tty ]; then
    say ""
    say "${CYAN}Let Claude Code run safe \`cargo-ai\` commands without prompting?${RESET}"
    say "  Installs an approval hook — reads/queries/runs auto-approve; login, token"
    say "  minting, reports, and deploy/destroy/remove still ask. (~/.claude/ only.)"
    printf "  [y/N]: "
    REPLY=""
    if [ -r /dev/tty ]; then
      IFS= read -r REPLY </dev/tty || true
    else
      IFS= read -r REPLY || true
    fi
    case "$REPLY" in
      y|Y|yes|YES) ;;
      *) say "${GREY}  Keeping existing Claude settings.${RESET}"; return 0 ;;
    esac
  else
    return 0
  fi

  SETTINGS="$HOME/.claude/settings.json"
  HOOKS_DIR="$HOME/.claude/hooks"
  mkdir -p "$HOOKS_DIR"
  APPROVE_HOOK="$HOOKS_DIR/approve-cargo-cli.sh"
  APPROVE_URL="https://raw.githubusercontent.com/getcargohq/cargo-skills/main/hooks/approve-cli.sh"

  if curl -fsSL --max-time 10 "$APPROVE_URL" -o "$APPROVE_HOOK" 2>/dev/null \
     && head -n 1 "$APPROVE_HOOK" 2>/dev/null | grep -q '^#!/bin/sh'; then
    chmod +x "$APPROVE_HOOK" 2>/dev/null || true
    CARGO_SETTINGS_PATH="$SETTINGS" CARGO_APPROVE_CMD="$APPROVE_HOOK claude" node -e "$(cat <<'JS'
const fs = require('fs');
const path = require('path');
const p = process.env.CARGO_SETTINGS_PATH;
const cmd = process.env.CARGO_APPROVE_CMD;
// Blanket entries written by earlier installer versions — superseded by the
// hook, and they would defeat its gating (settings allow beats a hook ask).
const legacyAllow = ['Bash(cargo-ai *)', 'Bash(npx @cargo-ai/cli *)', 'Bash(npx @cargo-ai/cli@* *)'];
let data = {};
try {
  if (fs.existsSync(p)) {
    const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
      data = parsed;
    }
  }
} catch (_e) {
  data = {};
}
const hooks = (data.hooks && typeof data.hooks === 'object' && !Array.isArray(data.hooks)) ? data.hooks : {};
const list = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
const already = list.some((entry) =>
  entry && Array.isArray(entry.hooks) &&
  entry.hooks.some((h) => h && typeof h.command === 'string' && h.command === cmd),
);
if (!already) {
  list.push({ matcher: 'Bash', hooks: [{ type: 'command', command: cmd }] });
}
hooks.PreToolUse = list;
data.hooks = hooks;
if (data.permissions && Array.isArray(data.permissions.allow)) {
  data.permissions.allow = data.permissions.allow.filter(
    (item) => !legacyAllow.includes(item),
  );
}
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
JS
)"
    say "${GREEN}✓ Claude approval hook installed — safe cargo-ai calls run without prompting;${RESET}"
    say "${GREEN}  login, token minting, reports, and deploy/destroy/remove still ask.${RESET}"
    return 0
  fi

  # Hook download failed. If a PRIOR run already installed and registered the
  # approval hook, keep it — falling back to blanket allowlist entries here
  # would sit alongside the hook and, because a settings allow beats a hook
  # ask, silently defeat the hook's gating (sensitive cargo-ai commands would
  # auto-approve again). Only reach for the allowlist when there is no hook.
  if [ -x "$APPROVE_HOOK" ] \
     && CARGO_SETTINGS_PATH="$SETTINGS" CARGO_APPROVE_CMD="$APPROVE_HOOK claude" node -e "$(cat <<'JS'
const fs = require('fs');
try {
  const d = JSON.parse(fs.readFileSync(process.env.CARGO_SETTINGS_PATH, 'utf8'));
  const cmd = process.env.CARGO_APPROVE_CMD;
  const reg = (d.hooks && Array.isArray(d.hooks.PreToolUse) ? d.hooks.PreToolUse : [])
    .some((e) => e && Array.isArray(e.hooks) && e.hooks.some((h) => h && h.command === cmd));
  process.exit(reg ? 0 : 1);
} catch (_e) { process.exit(1); }
JS
)"; then
    warn "  Could not re-fetch the approval hook, but the existing one is still installed — keeping it."
    return 0
  fi

  warn "  Could not fetch the approval hook — falling back to a permissions allowlist."
  CARGO_SETTINGS_PATH="$SETTINGS" node -e "$(cat <<'JS'
const fs = require('fs');
const path = require('path');
const p = process.env.CARGO_SETTINGS_PATH;
const allowEntries = ['Bash(cargo-ai *)', 'Bash(npx @cargo-ai/cli *)', 'Bash(npx @cargo-ai/cli@* *)'];
let data = {};
try {
  if (fs.existsSync(p)) {
    const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
      data = parsed;
    }
  }
} catch (_e) {
  data = {};
}
const perms = (data.permissions && typeof data.permissions === 'object' && !Array.isArray(data.permissions)) ? data.permissions : {};
const allow = Array.isArray(perms.allow) ? perms.allow : [];
const seen = new Set();
const out = [];
for (const item of allow) {
  if (typeof item === 'string' && !seen.has(item)) {
    seen.add(item);
    out.push(item);
  }
}
for (const item of allowEntries) {
  if (!seen.has(item)) {
    seen.add(item);
    out.push(item);
  }
}
perms.allow = out;
data.permissions = perms;
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
JS
)"
  say "${GREEN}✓ Claude settings updated (~/.claude/settings.json).${RESET}"
}

# ─────────────────────────────────────────────────────────────────────
# Step 5 (fallback channel only) — Claude session-lifecycle hooks
#
# Downloads the session-lifecycle scripts from the skills repo (the same
# single-sourced files the plugin ships — they auto-detect standalone mode when
# run from ~/.claude/hooks/) and registers SessionStart + Stop + SessionEnd so
# that every new Claude Code session automatically:
#   • refreshes the cargo-skills bundle and converges @cargo-ai/cli to its pin,
#   • writes a placeholder row to workspace_management.sessions at start,
#   • checkpoints the row each turn (Stop) with the latest request + timestamp,
#     so a session that never reaches SessionEnd still shows recent context, and
#   • overwrites it with an AI-generated title/summary and stamps finished_at
#     at end.
# All hooks swallow errors (`|| true`) so missing `cargo-ai`/`claude`/`jq`
# binaries never block a session.
# ─────────────────────────────────────────────────────────────────────
install_session_hooks() {
  [ "${CARGO_INSTALL_HOOKS:-}" = "0" ] && return 0
  if ! command -v node >/dev/null 2>&1; then
    return 0
  fi

  if [ "${CARGO_INSTALL_HOOKS:-}" = "1" ]; then
    say "${GREY}  Installing Claude session hooks (CARGO_INSTALL_HOOKS=1).${RESET}"
  elif [ -t 0 ] || [ -r /dev/tty ]; then
    say ""
    say "${CYAN}Wire up Claude Code session hooks?${RESET}"
    say "  SessionStart keeps the CLI + skills current and logs each session;"
    say "  SessionEnd writes an AI-generated summary. (~/.claude/ only — revert any time.)"
    printf "  [y/N]: "
    REPLY=""
    if [ -r /dev/tty ]; then
      IFS= read -r REPLY </dev/tty || true
    else
      IFS= read -r REPLY || true
    fi
    case "$REPLY" in
      y|Y|yes|YES) ;;
      *) say "${GREY}  Skipping session hooks.${RESET}"; return 0 ;;
    esac
  else
    return 0
  fi

  HOOKS_DIR="$HOME/.claude/hooks"
  mkdir -p "$HOOKS_DIR"

  # The lifecycle scripts are SINGLE-SOURCED from the skills repo — the same
  # files the plugin ships. They auto-detect standalone mode when run from
  # ~/.claude/hooks/, so downloading them here (instead of embedding copies)
  # means the two channels can never drift. Download to temp names and swap
  # all three at once, so a failed re-run never clobbers working hooks; if any
  # download fails, scaffold nothing — the router skill's manual session jobs
  # still cover the lifecycle.
  HOOKS_BASE="https://raw.githubusercontent.com/getcargohq/cargo-skills/main/hooks"
  DL_OK=1
  for h in session-start.sh session-checkpoint.sh session-end.sh; do
    if ! curl -fsSL --max-time 10 "$HOOKS_BASE/$h" -o "$HOOKS_DIR/$h.dl-$$" 2>/dev/null \
       || ! head -n 1 "$HOOKS_DIR/$h.dl-$$" 2>/dev/null | grep -q '^#!/usr/bin/env bash'; then
      DL_OK=""
      break
    fi
  done
  if [ -z "$DL_OK" ]; then
    rm -f "$HOOKS_DIR"/session-*.sh.dl-$$ 2>/dev/null || true
    warn "  Could not fetch the session-lifecycle scripts from the skills repo —"
    warn "  skipping session hooks. The agent still handles the session lifecycle"
    warn "  manually (see the cargo skill); re-run this installer to retry."
    return 0
  fi
  for h in session-start.sh session-checkpoint.sh session-end.sh; do
    mv -f "$HOOKS_DIR/$h.dl-$$" "$HOOKS_DIR/$h"
  done
  chmod +x "$HOOKS_DIR/session-start.sh" "$HOOKS_DIR/session-end.sh" "$HOOKS_DIR/session-checkpoint.sh" 2>/dev/null || true

  SETTINGS="$HOME/.claude/settings.json"
  CARGO_SETTINGS_PATH="$SETTINGS" CARGO_HOOKS_DIR="$HOOKS_DIR" node -e "$(cat <<'JS'
const fs = require('fs');
const path = require('path');
const p = process.env.CARGO_SETTINGS_PATH;
const hooksDir = process.env.CARGO_HOOKS_DIR;
const startCmd = path.join(hooksDir, 'session-start.sh');
const endCmd = path.join(hooksDir, 'session-end.sh');
const checkpointCmd = path.join(hooksDir, 'session-checkpoint.sh');

let data = {};
try {
  if (fs.existsSync(p)) {
    const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
      data = parsed;
    }
  }
} catch (_e) {
  data = {};
}

const hooks = (data.hooks && typeof data.hooks === 'object' && !Array.isArray(data.hooks)) ? data.hooks : {};

// Append a command hook for `event` only when no entry already runs `cmd`.
function ensureHook(event, cmd) {
  const list = Array.isArray(hooks[event]) ? hooks[event] : [];
  const already = list.some((entry) =>
    entry && Array.isArray(entry.hooks) &&
    entry.hooks.some((h) => h && typeof h.command === 'string' && h.command === cmd),
  );
  if (!already) {
    list.push({ hooks: [{ type: 'command', command: cmd }] });
  }
  hooks[event] = list;
}

ensureHook('SessionStart', startCmd);
ensureHook('SessionEnd', endCmd);
ensureHook('Stop', checkpointCmd);
data.hooks = hooks;

fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
JS
)"
  say "${GREEN}✓ Claude session hooks installed — start, checkpoint (Stop), end (~/.claude/hooks/).${RESET}"
}

# ─────────────────────────────────────────────────────────────────────
# Step 6 — launch Claude with a starter quickstart
# ─────────────────────────────────────────────────────────────────────
launch_claude() {
  [ "${CARGO_INSTALL_NO_LAUNCH:-0}" = "1" ] && return 0
  # Only Claude Code has a `claude '/skill'` launch entry point; on Codex/Cursor
  # the running agent just continues (its /agent-install.txt flow invokes the
  # quickstart skill itself).
  [ "$CARGO_AGENT_RESOLVED" = "claude" ] || return 0
  if ! command -v claude >/dev/null 2>&1; then
    say ""
    say "${YELLOW}Claude Code not found.${RESET} Install: ${CYAN}https://code.claude.com/docs/en/overview${RESET}"
    say "Then run: ${CYAN}claude '/cargo-quickstart'${RESET} for the two-minute guided demo."
    return 0
  fi
  say ""
  say "${GREEN}Launching Claude with the quickstart...${RESET}"
  # Plugin-installed skills are namespaced (cargo:cargo-quickstart); skills-add
  # copies are not. Use the form that matches the channel taken in Step 3.
  QUICKSTART="/cargo-quickstart"
  [ "$CARGO_SKILLS_CHANNEL" = "plugin" ] && QUICKSTART="/cargo:cargo-quickstart"
  # Intentionally not using `exec`: we want the installer to exit cleanly so
  # the success output is preserved and any sourcing pipeline (curl | sh) is
  # not replaced mid-stream.
  if [ -n "${CARGO_QUICKSTART_GOAL:-}" ]; then
    claude "$QUICKSTART $CARGO_QUICKSTART_GOAL"
  else
    claude "$QUICKSTART"
  fi
}

# ─────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────
print_logo
resolve_agent
ensure_node
resolve_cli_pin
install_cli
authenticate
install_agent_bundle
launch_claude
