#!/bin/sh
# Polylane CLI setup script for macOS / Linux — sign-in-first.
#
#   curl -fsSL https://polylane.com/setup | bash
#
# Downloads the latest release bundle to ~/.polylane/bin/polylane (Node.js 20+
# required), verifies its sha256, puts it on PATH (symlinking into a writable
# directory already on PATH when possible, else persisting the install dir in
# your shell rc), wires the CLI into your coding agents (`polylane setup`),
# installs the Polylane agent skills, then signs you in BEFORE anything else
# happens in your workspace: `polylane auth login` runs directly, and its
# own picker is the single method question (browser OAuth via GitHub or
# Google, email, device code for machines without a browser, API key).
# Sign-in provisions your account and workspace immediately, so everything
# the first run produces is workspace-keyed from the start. After sign-in the
# script mints a least-privilege workspace API key for the `polylane` MCP,
# listable and revocable from the console, and authenticates the MCP entry in
# every coding-agent config it knows how to write. Exactly one MCP server is
# registered: the authed `polylane` server. After sign-in it asks to connect
# GitHub, Slack, cloud accounts, observability tools, and a cloud coding agent
# — each connect kicks off background discovery server-side. Plans cap cloud
# accounts; the CLI reads the live plan before each connect and, at the cap,
# offers the upgrade itself (this script only reads its exit code). The
# connection questions repeat until the workspace has a verified source, and
# opening the workspace in the browser is always the very last act.
#
# The installer emits anonymous pre-auth funnel events keyed to a run
# identifier minted at first execution (fire-and-forget; telemetry can never
# block or fail the install). DO_NOT_TRACK=1 or POLYLANE_TELEMETRY=0 turns
# those events off entirely. Safe to re-run: steps that are already done are
# skipped — the CLI download, sign-in, and key mint print a short ✓ instead
# of repeating work — and an interrupted run resumes where it stopped.
#
# Every download is verified against the release's sha256 (from the GitHub
# API digest, falling back to the release's checksums.txt asset); without a
# checksum the installer refuses to install (POLYLANE_ALLOW_UNVERIFIED=1 is
# the explicit override). The script itself is inert until fully transferred:
# everything above the last line is a function definition or a side-effect-free
# variable assignment, and main "$@" on the last line is the only statement
# that does work, so a connection that dies mid-transfer executes nothing.
#
# Uninstall any time (the uninstaller also sweeps legacy 0.1.x-era footprints):
#   curl -fsSL https://polylane.com/uninstall | sh
#
#   curl -fsSL https://polylane.com/setup | bash -s -- --dry-run
#   curl -fsSL https://polylane.com/setup | bash -s -- --no-setup
#   POLYLANE_PREFIX=$HOME/bin curl -fsSL https://polylane.com/setup | bash
#   POLYLANE_NO_PATH=1 curl -fsSL https://polylane.com/setup | bash   # never touch PATH/shell configs

set -eu

REPO="coreplanelabs/cli"
BIN_NAME="polylane"
BUNDLE_ASSET="polylane.mjs"
PREFIX_DIR="${POLYLANE_PREFIX:-$HOME/.polylane/bin}"
PREFIX_DIR="${PREFIX_DIR%/}"
NO_SETUP="${POLYLANE_SKIP_SETUP:-0}"
NO_PATH="${POLYLANE_NO_PATH:-0}"
DRY_RUN=0
NEED_PATH_EXPORT=0
PATH_PERSISTED=0
PATH_RC=""
PATH_SOURCE_CMD=""
DOWNLOAD_SKIPPED=0
PATH_LINE="export PATH=\"$PREFIX_DIR:\$PATH\""
# The PATH line lives between these markers in shell rc files, so a rerun
# with a different POLYLANE_PREFIX rewrites the block instead of stacking a
# second (stale, possibly shadowing) entry. The uninstaller strips the block.
RC_MARKER_BEGIN="# >>> polylane installer >>>"
RC_MARKER_END="# <<< polylane installer <<<"
CONFIG_DIR="$HOME/.polylane"
API_DOMAIN="${POLYLANE_API_DOMAIN:-api.polylane.com}"
SKILLS_REPO="${POLYLANE_SKILLS_REPO:-coreplanelabs/skills}"
SKILLS_URL="${POLYLANE_SKILLS_URL:-https://github.com/$SKILLS_REPO/archive/refs/heads/main.tar.gz}"
SKILLS_CLI="skills@1.5.22"
POLYLANE_REF="${POLYLANE_REF:-}"
SETUP_URL="https://polylane.com/setup"
CONSOLE_DOMAIN="${POLYLANE_CONSOLE_DOMAIN:-console.polylane.com}"
CONSOLE_URL="https://$CONSOLE_DOMAIN"
# Pre-auth onboarding funnel (R30/KTD12): one client-minted run identifier,
# per-run sequence numbers, fire-and-forget POSTs to the unauthenticated
# CLI-telemetry transport. Lossy by design; never blocks the install.
TELEMETRY_URL="https://$API_DOMAIN/v1/telemetry/onboarding"
RUN_ID_FILE="$CONFIG_DIR/onboarding-run"
RUN_ID=""
SEQ=0
CURRENT_STEP=""
STEP_T0=0
# Historical key replacement is a transaction. The EXIT/signal trap consults
# this state so any stop after the rollback snapshot and before client commit
# restores local state and revokes only a replacement the server actually
# identified. These defaults must exist before setup_tmp_root installs the trap.
MCP_REPLACEMENT_ARMED=0
MCP_KEY_MINTED=0
MCP_KEY_ID=""
MCP_KEY=""
REPLACED_MCP_KEY=""
RETAINED_MCP_KEY_ID=""
MCP_CONFIG_BACKUP=""
MCP_MANAGED_CONFIG_BACKUP=""
MCP_REPLACEMENT_CAN_REVOKE=1

# Scopes for the workspace API key the MCP registrations use. This temporarily
# mirrors the server-owned Settings > Coding Agents read-only preset so both
# agent tools and generic MCP REST reads are authorized. scripts/test-setup.sh
# pins that complete contract until key minting can consume the server preset
# directly. The key is not what `polylane setup` or the CLI's connect commands
# run on (those use the OAuth session), and it carries no write scope.
MCP_KEY_SCOPES='workspaces:read workspace_members:read billing:read subscriptions:read threads:read repositories:read messages:read integrations:read cloud_accounts:read memories:read cloud_infra:read analytics:read audit_logs:read api_keys:read telemetry_tokens:read datasets:read autofixes:read teams:read labels:read oauth_clients:read agent_tools:read issues:read'
# Historical installer cohorts are supporting evidence during a rerun. They
# never authorize replacement by themselves: the exact server identity and a
# matching token in a managed MCP client must independently correlate first.
MCP_KEY_SCOPES_RETIRED_CURRENT="$MCP_KEY_SCOPES local_source:write"
MCP_KEY_SCOPES_HISTORICAL_BROAD='agent_tools:read agent_tools:write local_source:write cloud_accounts:read cloud_accounts:write integrations:read integrations:write repositories:read repositories:write'
MCP_KEY_SCOPES_HISTORICAL_NARROW='agent_tools:read local_source:write cloud_accounts:read integrations:read repositories:read cloud_infra:read datasets:read audit_logs:read'

info() { printf '%s\n' "$*"; }
ok() { printf '\033[32m✓\033[0m %s\n' "$*"; }
warn() { printf '\033[33mwarning\033[0m: %s\n' "$*"; }
would() { printf '\033[33m[dry-run]\033[0m %s\n' "$*"; }
# Cyan bordered box for one-line informational callouts, matching the CLI's
# clack notes (terms/privacy) so the install and sign-in read as one flow. The
# text stays on a single line so it can still be grepped verbatim.
notice() {
  # sed, not tr or a concat loop: macOS bash 3.2 mangles multibyte in both.
  _bar="$(printf '%*s' "$((${#1} + 4))" '' | sed 's/ /─/g')"
  printf '\033[36m╭%s╮\n│%*s│\n│  %s  │\n│%*s│\n╰%s╯\033[0m\n' "$_bar" "$((${#1} + 4))" "" "$1" "$((${#1} + 4))" "" "$_bar"
}

has() { command -v "$1" >/dev/null 2>&1; }

# ---------------------------------------------------------------------------
# Early guards: wrong platform and unsafe env, before anything else runs.
# ---------------------------------------------------------------------------

# Git Bash / MSYS / Cygwin land in `sh` but this installer writes POSIX
# paths, rc files, and symlinks that don't survive there. Stop with the real
# Windows path instead of half-working.
windows_shell_guard() {
  case "$(uname -s 2>/dev/null || true)" in
    MINGW*|MSYS*|CYGWIN*)
      printf '\033[31merror\033[0m: this installer is for macOS and Linux.\n' >&2
      printf 'On Windows, use PowerShell:\n  irm https://polylane.com/setup.ps1 | iex\n' >&2
      printf 'Or run this installer inside WSL.\n' >&2
      exit 1
      ;;
  esac
}

# A user-supplied POLYLANE_PREFIX is interpolated into shell rc files and
# printed commands, so it must be an absolute path made of characters that
# survive a double-quoted string in every shell we write ($, `, ", \ and
# newlines are rejected — they would change what the rc line executes).
# POLYLANE_CONSOLE_DOMAIN is printed and handed to the browser opener, so it
# is held to a hostname charset (with an optional port): nothing that could
# turn a printed link into something else.
validate_console_domain() {
  [ -n "${POLYLANE_CONSOLE_DOMAIN:-}" ] || return 0
  case "$CONSOLE_DOMAIN" in
    ''|*[!a-zA-Z0-9.:-]*|-*|.*|*/*)
      printf '\033[31merror\033[0m: POLYLANE_CONSOLE_DOMAIN must be a bare hostname like console.example.com (got: %s)\n' "$POLYLANE_CONSOLE_DOMAIN" >&2
      exit 1
      ;;
  esac
}

validate_prefix() {
  [ -n "${POLYLANE_PREFIX:-}" ] || return 0
  case "$PREFIX_DIR" in
    /?*) ;;
    *)
      printf '\033[31merror\033[0m: POLYLANE_PREFIX must be an absolute path (got: %s)\n' "$POLYLANE_PREFIX" >&2
      exit 1
      ;;
  esac
  PV_NL="$(printf '\nx')"; PV_NL="${PV_NL%x}"
  case "$PREFIX_DIR" in
    *'$'* | *'`'* | *'"'* | *"\\"* | *"$PV_NL"*)
      printf '\033[31merror\033[0m: POLYLANE_PREFIX contains a character that cannot be written safely into shell configs ($ \140 " \\ or a newline): %s\n' "$POLYLANE_PREFIX" >&2
      exit 1
      ;;
  esac
}

# ---------------------------------------------------------------------------
# Telemetry. Every emit is optional, backgrounded, and time-capped: a missing
# uuid source, a down endpoint, or a slow network must never slow the install.
# Sequence numbers make event loss distinguishable from abandonment.
# ---------------------------------------------------------------------------

valid_ref() {
  [ -n "$POLYLANE_REF" ] || return 1
  case "$POLYLANE_REF" in *[!a-zA-Z0-9._-]*) return 1 ;; esac
  [ "${#POLYLANE_REF}" -le 64 ]
}

# DO_NOT_TRACK (any non-empty value except "0") or POLYLANE_TELEMETRY=0 turns
# off the installer's own funnel events too, not just the CLI-telemetry
# disclosure line: no run id is minted, nothing is written to ~/.polylane for
# attribution, and every emit is a no-op.
telemetry_opted_out() {
  [ "${POLYLANE_TELEMETRY:-}" = "0" ] && return 0
  [ -n "${DO_NOT_TRACK:-}" ] && [ "${DO_NOT_TRACK:-}" != "0" ] && return 0
  return 1
}

mint_uuid() {
  if has uuidgen; then
    uuidgen | tr '[:upper:]' '[:lower:]'
    return 0
  fi
  if [ -r /proc/sys/kernel/random/uuid ]; then
    cat /proc/sys/kernel/random/uuid
    return 0
  fi
  # Last resort: 16 urandom bytes shaped 8-4-4-4-12. Hex-only, which is all
  # the ingestion route's uuid validation requires.
  UUID_HEX="$(od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')" || return 1
  [ "${#UUID_HEX}" -eq 32 ] || return 1
  printf '%s' "$UUID_HEX" | sed 's/^\(.\{8\}\)\(.\{4\}\)\(.\{4\}\)\(.\{4\}\)\(.\{12\}\)$/\1-\2-\3-\4-\5/'
}

mint_run_id() {
  if telemetry_opted_out; then
    return 0
  fi
  RUN_ID="$(mint_uuid 2>/dev/null || true)"
  case "$RUN_ID" in
    [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-*) ;;
    *) RUN_ID="" ;;
  esac
  [ -n "$RUN_ID" ] || return 0
  RUN_T0="$(date +%s)"
  # The run identifier is written where the CLI reads referral attribution
  # from (~/.polylane), so auth flows that know how to forward it can bind
  # the run to the user server-side at sign-in. Best-effort.
  mkdir -p "$CONFIG_DIR" 2>/dev/null || return 0
  printf '%s\n' "$RUN_ID" > "$RUN_ID_FILE" 2>/dev/null || true
}

# emit_event EVENT [STEP] [FAILURE_REASON] [EXTRA_JSON]
# All values interpolated into the JSON body are either script-fixed strings
# or sanitized to a JSON-safe charset before they get here.
emit_event() {
  [ "$DRY_RUN" = "1" ] && return 0
  if telemetry_opted_out; then return 0; fi
  [ -n "$RUN_ID" ] || return 0
  has curl || return 0
  EVT_SEQ="$SEQ"
  SEQ=$((SEQ + 1))
  EVT_BODY="{\"event\":\"$1\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"runId\":\"$RUN_ID\",\"seq\":$EVT_SEQ"
  [ -n "${2:-}" ] && EVT_BODY="$EVT_BODY,\"step\":\"$2\""
  [ -n "${3:-}" ] && EVT_BODY="$EVT_BODY,\"failureReason\":\"$3\""
  [ -n "${4:-}" ] && EVT_BODY="$EVT_BODY,$4"
  EVT_BODY="$EVT_BODY}"
  curl -fsS --proto '=https' -m 5 --connect-timeout 3 -X POST -H "Content-Type: application/json" \
    --data "$EVT_BODY" "$TELEMETRY_URL" >/dev/null 2>&1 &
}

# JSON-safe token: the server accepts [a-zA-Z0-9._ +-] up to 64 chars per
# system field; anything else is dropped here so a hostile $SHELL or uname
# can never reach the wire.
sys_token() {
  printf '%s' "$1" | tr -cd 'a-zA-Z0-9._ +-' | cut -c1-64
}

# Machine facts observable before anything is installed — the answer to
# "what system is this?" for a run that may never reach sign-in. Read-only,
# no network, no PII: OS, release, arch, shell, node version, brew presence,
# tty, CI, whether a polylane binary already resolves (a rerun), and which
# coding agents have a config file (the same detection setup uses).
system_facts_json() {
  SYS_OS="$(sys_token "$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]')")"
  SYS_REL="$(sys_token "$(uname -r 2>/dev/null)")"
  SYS_ARCH="$(sys_token "$(uname -m 2>/dev/null)")"
  # $SHELL is absent in many CI runners and minimal containers; with set -u
  # an unguarded expansion would abort the whole install here.
  SYS_SHELL="$(sys_token "${SHELL:+${SHELL##*/}}")"
  SYS_NODE=""
  if has node; then SYS_NODE="$(sys_token "$(node -v 2>/dev/null)")"; fi
  if has brew; then SYS_PM=brew; else SYS_PM=none; fi
  if has_tty; then SYS_TTY=true; else SYS_TTY=false; fi
  if [ -n "${CI:-}" ]; then SYS_CI=true; else SYS_CI=false; fi
  # A rerun is "this machine already has our binary": on PATH, or at the
  # prefix from an earlier run (PATH is only re-sourced in a new shell).
  if [ -x "$PREFIX_DIR/$BIN_NAME" ] || has "$BIN_NAME"; then SYS_RERUN=true; else SYS_RERUN=false; fi
  SYS_AGENTS=""
  for a in $(detected_agent_list | tr ',' ' '); do
    a="$(sys_token "$a")"
    [ -n "$a" ] && SYS_AGENTS="${SYS_AGENTS:+$SYS_AGENTS,}\"$a\""
  done
  SYS_JSON="\"os\":\"$SYS_OS\",\"osRelease\":\"$SYS_REL\",\"arch\":\"$SYS_ARCH\",\"shell\":\"$SYS_SHELL\""
  [ -n "$SYS_NODE" ] && SYS_JSON="$SYS_JSON,\"node\":\"$SYS_NODE\""
  SYS_JSON="$SYS_JSON,\"packageManager\":\"$SYS_PM\",\"tty\":$SYS_TTY,\"ci\":$SYS_CI,\"rerun\":$SYS_RERUN,\"agents\":[$SYS_AGENTS]"
  printf '{%s}' "$SYS_JSON"
}

emit_started() {
  STARTED_EXTRA=""
  CLI_VERSION=""
  # Same two places rerun detection looks: a rerun's binary may sit at the
  # prefix without being on this shell's PATH yet.
  if [ -x "$PREFIX_DIR/$BIN_NAME" ]; then
    CLI_VERSION="$("$PREFIX_DIR/$BIN_NAME" --version 2>/dev/null | awk '{print $2}' | tr -cd '0-9a-zA-Z.-' | cut -c1-32)" || CLI_VERSION=""
  elif has "$BIN_NAME"; then
    CLI_VERSION="$("$BIN_NAME" --version 2>/dev/null | awk '{print $2}' | tr -cd '0-9a-zA-Z.-' | cut -c1-32)" || CLI_VERSION=""
  fi
  # installSource is what this script is, on every run. The wire schema wants
  # a version string too, and a fresh machine has no binary to ask yet, so the
  # version is empty until a rerun can report it.
  STARTED_EXTRA="\"cli\":{\"version\":\"$CLI_VERSION\",\"installSource\":\"curl\"}"
  if valid_ref; then
    STARTED_EXTRA="${STARTED_EXTRA:+$STARTED_EXTRA,}\"ref\":\"$POLYLANE_REF\""
  fi
  STARTED_EXTRA="${STARTED_EXTRA:+$STARTED_EXTRA,}\"system\":$(system_facts_json)"
  emit_event started "" "" "$STARTED_EXTRA"
}

step_begin() {
  CURRENT_STEP="$1"
  STEP_T0="$(date +%s)"
}

step_done() {
  [ -n "$CURRENT_STEP" ] || return 0
  emit_event step_completed "$CURRENT_STEP" "" "\"durationMs\":$((($(date +%s) - STEP_T0) * 1000))"
  CURRENT_STEP=""
}

# Sub-step outcomes ride the same step_completed event with a dotted step
# name, <step>.<leg>.<outcome> (e.g. connect.github.declined,
# agent_setup.skipped, install.completed). They never touch CURRENT_STEP, so
# the coarse step rows and die() attribution are unchanged; the funnel presets
# match coarse steps by equality and the timeline/outcome presets group the
# dotted names by prefix. leg_begin starts the optional timer; mark without a
# preceding leg_begin records durationMs 0 (an instantaneous fact).
leg_begin() {
  LEG_T0="$(date +%s)"
}

mark() {
  if [ -n "${LEG_T0:-}" ]; then
    MARK_MS=$((($(date +%s) - LEG_T0) * 1000))
  else
    MARK_MS=${2:-0}
  fi
  LEG_T0=""
  emit_event step_completed "$1" "" "\"durationMs\":$MARK_MS"
}

sanitize_reason() {
  # Newlines/tabs become spaces first so a multi-line die message keeps its
  # word boundaries in the funnel's failureReason instead of running together.
  printf '%s' "$1" | tr '\n\t' '  ' | tr -cd 'a-zA-Z0-9 ._:/()+=-' | cut -c1-200
}

# Failures are legible AND attributable: die names the failed step in the
# terminal (rerunning resumes — completed steps skip themselves), and the
# funnel records the exact drop-off point against the run identifier.
# The one "source your rc" hint. Printed by the closing summary on success and
# by the EXIT trap on any non-zero exit after the rc write (die() and the
# hand-rolled sign-in exits alike) — once the rc file has the PATH block, the
# user must hear about it exactly once, whichever way the run ends. Only when
# the shell can't find the binary yet (NEED_PATH_EXPORT) and the rc write
# actually landed (PATH_PERSISTED); the failed-write branch prints its own
# manual instruction at the site.
path_hint() {
  [ "$NEED_PATH_EXPORT" = "1" ] && [ "$PATH_PERSISTED" = "1" ] || return 0
  printf 'In this shell, run \033[1m%s\033[0m (or open a new terminal) before using \033[1m%s\033[0m directly.\n\n' "$PATH_SOURCE_CMD" "$BIN_NAME"
}

die() {
  if [ -n "$CURRENT_STEP" ]; then
    emit_event step_failed "$CURRENT_STEP" "$(sanitize_reason "$*")"
    printf '\033[31merror\033[0m (step: %s): %s\n' "$CURRENT_STEP" "$*" >&2
  else
    printf '\033[31merror\033[0m: %s\n' "$*" >&2
  fi
  printf 'Re-run the installer any time to resume where it stopped:\n  curl -fsSL %s | bash\n' "$SETUP_URL" >&2
  exit 1
}

# ---------------------------------------------------------------------------
# Temp space: one root directory, cleaned up by trap on exit AND on signal
# (Ctrl-C mid-install used to leave whichever scratch dir was live behind).
# Helpers take fresh subdirectories from it via new_tmp.
# ---------------------------------------------------------------------------

TMP_ROOT=""
on_exit() {
  _st=$?
  # Once historical replacement is armed, every non-local exit path (including
  # Ctrl-C) uses the same rollback as an explicit mint/client failure. The
  # rollback disarms itself before doing work, so this trap cannot recurse.
  if [ "${MCP_REPLACEMENT_ARMED:-0}" = "1" ]; then
    rollback_mcp_key_replacement || true
  fi
  # A run that stops early still leaves the PATH block in the rc file; say
  # so before the "re-run" advice scrolls it away.
  [ "$_st" -ne 0 ] && path_hint >&2
  rm -rf "$TMP_ROOT"
}
setup_tmp_root() {
  TMP_ROOT="$(mktemp -d)" || { printf '\033[31merror\033[0m: mktemp failed\n' >&2; exit 1; }
  trap 'on_exit' EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
}

new_tmp() {
  mktemp -d "$TMP_ROOT/step.XXXXXX"
}

# ---------------------------------------------------------------------------
# CLI install machinery (same as the 0.1.x installer).
# ---------------------------------------------------------------------------

node_ok() {
  has node || return 1
  [ "$(node -p 'process.versions.node.split(".")[0]')" -ge 20 ]
}

sha256_of() {
  if has sha256sum; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi
}

install_with_brew() {
  if brew list --formula "$BIN_NAME" >/dev/null 2>&1; then
    info "Upgrading $BIN_NAME with Homebrew..."
    brew upgrade coreplanelabs/tap/polylane 2>/dev/null || true
  else
    info "Installing $BIN_NAME with Homebrew..."
    brew install coreplanelabs/tap/polylane
  fi
}

install_from_release() {
  TAG=""
  EXPECTED_SHA=""
  if [ -n "${POLYLANE_VERSION:-}" ]; then
    TAG="v${POLYLANE_VERSION#v}"
    API_URL="https://api.github.com/repos/$REPO/releases/tags/$TAG"
  else
    API_URL="https://api.github.com/repos/$REPO/releases/latest"
  fi

  RELEASE_JSON="$(curl -fsSL --proto '=https' --retry 3 --connect-timeout 10 "$API_URL" 2>/dev/null || true)"
  if [ -n "$RELEASE_JSON" ]; then
    [ -n "$TAG" ] || TAG="$(printf '%s\n' "$RELEASE_JSON" | sed -n 's/^ *"tag_name": *"\([^"]*\)".*/\1/p' | head -1)"
    # The digest must be the one on the $BUNDLE_ASSET asset entry. A release
    # carries several assets, each with its own digest, and checksums.txt
    # sorts before polylane.mjs — matching the first digest in the file
    # compared the bundle against checksums.txt's own hash and failed every
    # install (v0.2.20). Track the nearest preceding "name" and only accept
    # the digest that belongs to the bundle; any parse doubt leaves
    # EXPECTED_SHA empty and the checksums.txt fallback below takes over.
    EXPECTED_SHA="$(printf '%s\n' "$RELEASE_JSON" | awk -v asset="$BUNDLE_ASSET" '
      /"name": *"/ { name = $0; sub(/.*"name": *"/, "", name); sub(/".*/, "", name) }
      name == asset && /"digest": *"sha256:/ {
        hash = $0
        sub(/.*"digest": *"sha256:/, "", hash)
        sub(/".*/, "", hash)
        print hash
        exit
      }
    ' 2>/dev/null | tr -cd '0-9a-f')"
    [ "${#EXPECTED_SHA}" -eq 64 ] || EXPECTED_SHA=""
  fi

  # Rerun: skip the download when the installed binary already reports the
  # target version. Any doubt (no tag resolved, --version fails or differs)
  # falls through to the download, same as a fresh install.
  if [ -n "$TAG" ] && [ -x "$PREFIX_DIR/$BIN_NAME" ]; then
    INSTALLED_VERSION="$("$PREFIX_DIR/$BIN_NAME" --version 2>/dev/null || true)"
    if [ "$INSTALLED_VERSION" = "$BIN_NAME ${TAG#v}" ]; then
      ok "$BIN_NAME $TAG already installed"
      DOWNLOAD_SKIPPED=1
      persist_path
      return 0
    fi
  fi

  if [ -n "$TAG" ]; then
    DOWNLOAD_BASE="https://github.com/$REPO/releases/download/$TAG"
    info "Downloading $BIN_NAME ${TAG}..."
  else
    DOWNLOAD_BASE="https://github.com/$REPO/releases/latest/download"
    info "Downloading $BIN_NAME (latest)..."
  fi
  DOWNLOAD_URL="$DOWNLOAD_BASE/$BUNDLE_ASSET"

  TMP_DIR="$(new_tmp)"
  TMP_FILE="$TMP_DIR/$BUNDLE_ASSET"

  # The GitHub API digest above is the primary checksum source. When the API
  # was unavailable (anonymous rate limits are routine on shared IPs and CI),
  # the release's own checksums.txt asset is the fallback — served from the
  # same release download path as the bundle, no API involved.
  if [ -z "$EXPECTED_SHA" ] \
    && curl -fsSL --proto '=https' --retry 3 --connect-timeout 10 "$DOWNLOAD_BASE/checksums.txt" -o "$TMP_DIR/checksums.txt" 2>/dev/null; then
    EXPECTED_SHA="$(awk -v asset="$BUNDLE_ASSET" '{ f = $2; sub(/^\*/, "", f); if (f == asset) { print $1; exit } }' "$TMP_DIR/checksums.txt" 2>/dev/null | tr -cd '0-9a-f')"
    [ "${#EXPECTED_SHA}" -eq 64 ] || EXPECTED_SHA=""
  fi

  # A checksum with no tool to check it is treated as unavailable — but only
  # after the explicit override, never silently.
  if [ -n "$EXPECTED_SHA" ] && ! has sha256sum && ! has shasum; then
    [ "${POLYLANE_ALLOW_UNVERIFIED:-}" = "1" ] \
      || die "sha256sum or shasum is required to verify the download and neither is installed. Install coreutils, or set POLYLANE_ALLOW_UNVERIFIED=1 to skip verification"
    EXPECTED_SHA=""
  fi

  # No verifiable checksum, no install: a corrupted or tampered download must
  # never land on disk as an executable. POLYLANE_ALLOW_UNVERIFIED=1 is the
  # explicit, loud escape hatch (e.g. a full GitHub API outage on an old
  # release that predates checksums.txt).
  if [ -z "$EXPECTED_SHA" ] && [ "${POLYLANE_ALLOW_UNVERIFIED:-}" != "1" ]; then
    die "can't verify this download: no sha256 was available from the release metadata or checksums.txt. This is usually transient (GitHub API rate limiting) — re-run in a minute, or set POLYLANE_ALLOW_UNVERIFIED=1 to install without verification"
  fi

  curl -fsSL --proto '=https' --retry 3 --connect-timeout 10 "$DOWNLOAD_URL" -o "$TMP_FILE" \
    || die "download failed ($DOWNLOAD_URL) — check your network and that the release exists"

  head -1 "$TMP_FILE" | grep -q '^#!' || die "downloaded file does not look like a $BIN_NAME CLI bundle"

  if [ -n "$EXPECTED_SHA" ]; then
    ACTUAL_SHA="$(sha256_of "$TMP_FILE")"
    [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || die "sha256 mismatch: expected $EXPECTED_SHA, got $ACTUAL_SHA — nothing was installed. Re-run; if it persists, report it"
    ok "sha256 verified"
  else
    warn "installing WITHOUT checksum verification (POLYLANE_ALLOW_UNVERIFIED=1)"
  fi

  mkdir -p "$PREFIX_DIR"
  TARGET="$PREFIX_DIR/$BIN_NAME"
  # Keep the .mjs extension on disk: the bundle is ESM, and Node < 22.7 has no
  # module-syntax detection, so an extensionless copy is parsed as CommonJS
  # and dies with "Cannot use import statement outside a module". The symlink
  # provides the bare command name; Node resolves it to the real path, so the
  # .mjs suffix selects ESM on every Node >= 20.
  # Staged next to the target and moved into place: a polylane that's running
  # right now keeps its old inode instead of seeing a half-written binary.
  install -m 0755 "$TMP_FILE" "$TARGET.mjs.tmp.$$"
  mv -f "$TARGET.mjs.tmp.$$" "$TARGET.mjs"
  ln -sf "$BIN_NAME.mjs" "$TARGET"
  ok "installed $TARGET"

  persist_path
}

record_ref() {
  valid_ref || return 0
  [ -f "$HOME/.polylane/ref" ] && return 0
  mkdir -p "$HOME/.polylane" 2>/dev/null || return 0
  printf '%s\n' "$POLYLANE_REF" > "$HOME/.polylane/ref" 2>/dev/null || true
}

# Exact-entry PATH membership check (split on ':'), tolerating a trailing
# slash on the PATH entry.
dir_on_path() {
  case ":$PATH:" in
    *":${1%/}:"* | *":${1%/}/:"*) return 0 ;;
  esac
  return 1
}

# True when a resolved $BIN_NAME on PATH is THIS install: the prefix binary
# itself, or a symlink the installer placed that points back at it. Anything
# else (npm global, brew, an old prefix) is a foreign install that would
# shadow the fresh one.
is_ours() {
  [ "$1" = "$PREFIX_DIR/$BIN_NAME" ] && return 0
  [ -L "$1" ] || return 1
  IO_TARGET="$(readlink "$1" 2>/dev/null || true)"
  [ "$IO_TARGET" = "$PREFIX_DIR/$BIN_NAME" ] || [ "$IO_TARGET" = "$PREFIX_DIR/$BIN_NAME.mjs" ]
}

# Make $BIN_NAME work IMMEDIATELY in the current shell: an export can't
# outlive the curl|bash child process, but a symlink into a directory that
# is already on the user's PATH can — and it keeps working in every future
# shell too. Whitelisted candidates only, in preference order, and only when
# the directory already exists, is writable, and is on PATH — never scan
# arbitrary PATH entries, create directories, or use sudo.
link_into_path() {
  for LINK_DIR in /usr/local/bin /opt/homebrew/bin "$HOME/.local/bin" "$HOME/bin"; do
    # shellcheck disable=SC2015 # intentional A && B || skip, not if-then-else
    [ -d "$LINK_DIR" ] && [ -w "$LINK_DIR" ] || continue
    dir_on_path "$LINK_DIR" || continue
    # Never clobber a foreign polylane (npm global, brew) with our symlink —
    # that file belongs to another package manager. The rc PATH prepend in
    # persist_path outranks it instead, and the shadow check warns about it.
    if [ -e "$LINK_DIR/$BIN_NAME" ] && ! is_ours "$LINK_DIR/$BIN_NAME"; then continue; fi
    ln -sf "$PREFIX_DIR/$BIN_NAME" "$LINK_DIR/$BIN_NAME" 2>/dev/null || continue
    ok "linked $BIN_NAME into $LINK_DIR"
    return 0
  done
  return 1
}

# Replace the managed block's content in an rc file with $2, keeping
# everything outside the markers byte-identical. Writes back through the rc
# path (a dotfiles-managed rc is often a symlink; renaming over it would
# silently detach it from its repo).
rewrite_rc_block() {
  RRB_TMP="$(new_tmp)/rc"
  awk -v begin="$RC_MARKER_BEGIN" -v end="$RC_MARKER_END" -v line="$2" '
    $0 == begin { print; print line; inblock = 1; next }
    $0 == end { inblock = 0; print; next }
    inblock { next }
    { print }
  ' "$1" > "$RRB_TMP" 2>/dev/null || return 1
  [ -s "$RRB_TMP" ] || return 1
  cat "$RRB_TMP" > "$1"
}

persist_path() {
  case ":$PATH:" in *":$PREFIX_DIR:"*) return ;; esac
  # Rerun: THIS install already resolves (a previous run's symlink or an
  # active rc line) — nothing to do, silently. A foreign polylane on PATH
  # deliberately does NOT short-circuit any more: it used to leave the fresh
  # install unreachable while the stale binary kept answering.
  RESOLVED_EXISTING="$(command -v "$BIN_NAME" 2>/dev/null || true)"
  if [ -n "$RESOLVED_EXISTING" ] && is_ours "$RESOLVED_EXISTING"; then return; fi
  if [ "$NO_PATH" = "1" ]; then
    info "POLYLANE_NO_PATH=1: not touching PATH or shell configs. To use $BIN_NAME, add it yourself:"
    printf '  %s\n' "$PATH_LINE"
    export PATH="$PREFIX_DIR:$PATH"
    return
  fi
  # Linked into a dir already on PATH: works now and in every future shell,
  # so no rc persistence and no source/restart hint needed.
  link_into_path && return
  NEED_PATH_EXPORT=1
  RC=""
  RC_LINE="$PATH_LINE"
  SOURCE_CMD="source"
  case "${SHELL:-}" in
    */zsh) RC="${ZDOTDIR:-$HOME}/.zshrc" ;;
    */bash)
      if [ "$(uname -s)" = "Darwin" ]; then RC="$HOME/.bash_profile"; else RC="$HOME/.bashrc"; fi
      ;;
    */fish)
      RC="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/polylane.fish"
      RC_LINE="fish_add_path \"$PREFIX_DIR\""
      PATH_LINE="$RC_LINE"
      ;;
    */csh|*/tcsh)
      RC="$HOME/.tcshrc"
      RC_LINE="setenv PATH \"$PREFIX_DIR:\$PATH\""
      PATH_LINE="$RC_LINE"
      ;;
    # Unknown or unset shell: fall back to ~/.profile. POSIX login shells
    # source it, and the export syntax is plain sh — never ask the user to
    # edit a file themselves. Hint with `.` — dash and friends have no
    # `source` builtin, and `.` works in bash/zsh too.
    *)
      RC="$HOME/.profile"
      SOURCE_CMD="."
      ;;
  esac
  PATH_RC="$RC"
  PATH_SOURCE_CMD="$SOURCE_CMD $RC"
  mkdir -p "$(dirname "$RC")" 2>/dev/null || true
  if grep -qsF "$RC_LINE" "$RC"; then
    # Current line already present — as this installer's marker block or as a
    # bare line an older installer appended. Either keeps working; leave it.
    info "$RC already has $PREFIX_DIR on PATH."
    PATH_PERSISTED=1
  elif grep -qsF "$RC_MARKER_BEGIN" "$RC"; then
    # A managed block from an earlier run points at a different prefix:
    # rewrite it in place so no stale entry (and no stale binary) lingers.
    if rewrite_rc_block "$RC" "$RC_LINE"; then
      ok "updated the PATH entry in $RC to $PREFIX_DIR"
      PATH_PERSISTED=1
    else
      info "Couldn't write $RC. Add $PREFIX_DIR to your PATH yourself:"
      printf '  %s\n' "$PATH_LINE"
    fi
  # No "restart your shell" hint here: path_hint prints the one source hint
  # at the end of the run — closing summary or die() — so the install steps
  # read as a clean run of check marks.
  elif { printf '\n%s\n%s\n%s\n' "$RC_MARKER_BEGIN" "$RC_LINE" "$RC_MARKER_END" >> "$RC"; } 2>/dev/null; then
    ok "added $PREFIX_DIR to PATH in $RC"
    PATH_PERSISTED=1
  else
    info "Couldn't write $RC. Add $PREFIX_DIR to your PATH yourself:"
    printf '  %s\n' "$PATH_LINE"
  fi
  export PATH="$PREFIX_DIR:$PATH"
}

# ---------------------------------------------------------------------------
# Shared helpers (same conventions as the 0.1.x installer).
# ---------------------------------------------------------------------------

# bash 3.2 mis-parses heredocs inside $(...) — capture node output via temp files.
json_field() {
  [ -f "$1" ] || return 1
  POLYLANE_JSON_FILE="$1" POLYLANE_JSON_FIELD="$2" node 2>/dev/null <<'EOF'
const v = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"))[
  process.env.POLYLANE_JSON_FIELD
];
if (typeof v !== "string" || v === "") process.exit(1);
process.stdout.write(v);
EOF
}

signed_in() {
  [ -f "$CONFIG_DIR/credentials.json" ] && return 0
  json_field "$CONFIG_DIR/config.json" api_key >/dev/null
}

run_tty() {
  if [ -t 0 ]; then
    "$@"
  elif [ -r /dev/tty ] && [ -t 1 ]; then
    "$@" </dev/tty
  else
    return 1
  fi
}

has_tty() {
  if [ -t 0 ]; then return 0; fi
  [ -r /dev/tty ] && [ -t 1 ]
}

ask_yn() {
  ANSWER=""
  if [ -t 0 ]; then
    printf '%s [Y/n] ' "$1"
    read -r ANSWER
  elif [ -r /dev/tty ] && [ -t 1 ]; then
    printf '%s [Y/n] ' "$1"
    read -r ANSWER </dev/tty
  else
    return 1
  fi
  case "$ANSWER" in [nN]*) return 1 ;; *) return 0 ;; esac
}

# Default-No sibling of ask_yn: plain Enter moves on.
ask_ny() {
  ANSWER=""
  if [ -t 0 ]; then
    printf '%s [y/N] ' "$1"
    read -r ANSWER
  elif [ -r /dev/tty ] && [ -t 1 ]; then
    printf '%s [y/N] ' "$1"
    read -r ANSWER </dev/tty
  else
    return 1
  fi
  case "$ANSWER" in [yY]*) return 0 ;; *) return 1 ;; esac
}

# ---------------------------------------------------------------------------
# Dry run: print the full plan and stop — before authentication, before any
# mutation, local or server-side (no telemetry either). Detection below is
# read-only.
# ---------------------------------------------------------------------------

detected_agent_configs() {
  if [ "$(uname -s)" = "Darwin" ]; then
    VSCODE_USER_DIR="$HOME/Library/Application Support/Code/User"
  else
    VSCODE_USER_DIR="$HOME/.config/Code/User"
  fi
  # opencode reads both; detect (and later authenticate) whichever exists,
  # preferring opencode.jsonc the way opencode itself does.
  OPENCODE_CONFIG="$HOME/.config/opencode/opencode.jsonc"
  [ -f "$OPENCODE_CONFIG" ] || OPENCODE_CONFIG="$HOME/.config/opencode/opencode.json"
  DETECTED=""
  DETECTED_LIST=""
  for pair in \
    "claude=$HOME/.claude.json" \
    "cursor=$HOME/.cursor/mcp.json" \
    "vscode=$VSCODE_USER_DIR/mcp.json" \
    "opencode=$OPENCODE_CONFIG" \
    "windsurf=$HOME/.codeium/windsurf/mcp_config.json" \
    "pi=$HOME/.pi/agent/mcp.json" \
    "warp=$HOME/.warp/.mcp.json" \
    "cline=$VSCODE_USER_DIR/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" \
    "cline-cli=$HOME/.cline/data/settings/cline_mcp_settings.json" \
    "roo=$VSCODE_USER_DIR/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json" \
    "gemini=$HOME/.gemini/settings.json" \
    "zed=$HOME/.config/zed/settings.json" \
    "codex=$HOME/.codex/config.toml"; do
    agent="${pair%%=*}"
    file="${pair#*=}"
    if [ -f "$file" ]; then
      DETECTED="${DETECTED:+$DETECTED, }$agent"
      DETECTED_LIST="${DETECTED_LIST:+$DETECTED_LIST,}$agent"
    fi
  done
  printf '%s' "${DETECTED:-none found yet — polylane setup creates them}"
}

# Machine-readable sibling of detected_agent_configs: the same list, one
# comma-separated string of agent ids (empty when none), for the funnel.
detected_agent_list() {
  detected_agent_configs >/dev/null
  printf '%s' "$DETECTED_LIST"
}

print_dry_run_plan() {
  info "Dry run — nothing will be installed or changed, locally or server-side"
  info "(no telemetry is sent in dry-run mode either)."
  info ""
  info "This installer would, in order:"
  if telemetry_opted_out; then
    would "send NO funnel telemetry (DO_NOT_TRACK/POLYLANE_TELEMETRY opt-out honored) and mint no run id"
  else
    would "mint a pre-auth onboarding run id, write it to $RUN_ID_FILE,"
    would "  and emit funnel events (started, per-step, sign-in) to $TELEMETRY_URL"
  fi
  if node_ok; then
    would "download the latest $BIN_NAME release to $PREFIX_DIR (sha256-verified against the release digest or its checksums.txt; refuses to install unverified; Node.js 20+ found)"
  elif has brew; then
    would "install $BIN_NAME with Homebrew (Node.js 20+ not found; brew installs it as a dependency)"
  else
    would "stop: Node.js 20+ and Homebrew are both missing (install one, then re-run)"
  fi
  if [ "$NO_PATH" = "1" ]; then
    would "leave PATH and shell configs alone (POLYLANE_NO_PATH=1) and print the line to add yourself"
  else
    would "put $BIN_NAME on PATH (symlink into a PATH dir, else a marker-block rc line) if it isn't already"
  fi
  would "disclose the anonymous usage telemetry (polylane telemetry disable opts out)"
  would "  and export POLYLANE_TELEMETRY_NOTICE_ACK=1 so the CLI never repeats the notice mid-flow"
  if valid_ref; then
    would "record referral '$POLYLANE_REF' in $HOME/.polylane/ref (first touch only)"
  fi
  if [ "$NO_SETUP" = "1" ]; then
    would "skip agent wiring and skills (--no-setup)"
  else
    would "wire the authed polylane MCP into your coding agents ($BIN_NAME setup) and install agent skills"
  fi
  if signed_in; then
    would "skip sign-in (already signed in)"
  else
    would "prompt sign-in BEFORE anything touches a workspace:"
    would "  $BIN_NAME auth login   (the CLI's own picker: browser OAuth via GitHub or Google, email, device code)"
  fi
  would "mint a workspace API key named \"coding-agent $(machine_short_id)\" scoped to:"
  would "  $MCP_KEY_SCOPES"
  would "authenticate the polylane MCP entry in detected agent configs ($(detected_agent_configs))"
  would "ask to connect GitHub, Slack, cloud accounts, observability tools, and a cloud coding agent (already-connected ones skip; each connect starts background discovery)"
  would "repeat the connection questions until the workspace has at least one verified source"
  would "let the CLI offer a plan upgrade when the plan's cloud-account limit is reached (limits come from the live plan; declining keeps what is connected)"
  would "end by printing $CONSOLE_URL/<workspace>/topology and offering to open it in your browser"
  info ""
  info "Dry run complete — nothing was changed. Stopped before authentication."
  info "Run again without --dry-run to install:"
  printf '  curl -fsSL %s | bash\n' "$SETUP_URL"
}

machine_short_id() {
  MID="$(hostname -s 2>/dev/null || uname -n 2>/dev/null || echo machine)"
  MID="$(printf '%s' "$MID" | tr -cd 'a-zA-Z0-9._-' | cut -c1-32)"
  [ -n "$MID" ] || MID="machine"
  printf '%s' "$MID"
}

# ---------------------------------------------------------------------------
# Connection loop (ported from 0.1.x): capture GitHub, Slack, cloud accounts,
# observability, and cloud coding agents right after sign-in. Each connect
# kicks off background discovery server-side; the user never hears the word
# "scan".
# ---------------------------------------------------------------------------

# Rerun support: ask only about integrations that aren't connected yet.
# CONNECTED_TYPES holds the space-separated integration types already in the
# workspace. It stays empty when the lookup fails, and an empty list means
# every question is asked — a failed check never makes a rerun worse than a
# fresh install. CONNECTED_LOOKUP_OK records whether the list call itself
# succeeded: an empty list from a failed lookup means "not verified", never
# "nothing is connected".
CONNECTED_TYPES=""
CONNECTED_LOOKUP_OK=0
load_connected_types() {
  CONNECTED_TYPES=""
  CONNECTED_LOOKUP_OK=0
  signed_in || return 0
  LIST_TMP="$(new_tmp)"
  if "$BIN" integration list --limit 100 --output json > "$LIST_TMP/integrations.json" 2>/dev/null; then
    # The lookup only counts as OK when the parse succeeds too: malformed
    # output must read as "ask again", never as "verified nothing connected".
    if POLYLANE_JSON_FILE="$LIST_TMP/integrations.json" node > "$LIST_TMP/types" 2>/dev/null <<'EOF'
const parsed = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
const items = Array.isArray(parsed.items) ? parsed.items : [];
const types = [...new Set(items.filter((i) => i && !i.disabled && typeof i.type === "string").map((i) => i.type))];
process.stdout.write(types.join(" "));
EOF
    then
      CONNECTED_LOOKUP_OK=1
      CONNECTED_TYPES="$(cat "$LIST_TMP/types" 2>/dev/null || true)"
    fi
  fi
}

has_connected() {
  case " $CONNECTED_TYPES " in *" $1 "*) return 0 ;; esac
  return 1
}

# The CLI's browser wait exits 0 when it times out (cli#75), so a connect
# command's exit code cannot vouch that anything was connected. A fresh list
# lookup decides; when the lookup itself fails, the exit code is the only
# signal left and stands.
connect_landed() {
  load_connected_types
  [ "$CONNECTED_LOOKUP_OK" = "1" ] || return 0
  case "$1" in
    observability) observability_connected ;;
    *) has_connected "$1" ;;
  esac
}

observability_connected() {
  for OBS_TYPE in sentry datadog honeycomb axiom betterstack; do
    if has_connected "$OBS_TYPE"; then return 0; fi
  done
  return 1
}

code_agent_connected() {
  for CA_TYPE in devin cursor factory conductor; do
    if has_connected "$CA_TYPE"; then return 0; fi
  done
  return 1
}

# The code-agent picker's built-in offer (one line of why + a yes/no) shipped
# in CLI 0.2.17. `--category code-agent` alone predates it (v0.2.7–v0.2.16
# accept the flag but drop straight into a bare picker), so the gate is the
# reported version, never a flag probe. Any parse doubt fails closed: no
# offer beats an unexplained picker mid-install.
# The Slack connect leg's channel picker (which channels Polylane should join,
# offered right after the app connects and on a re-run while the bot is still in
# no channel) shipped in CLI 0.2.22. Older CLIs short-circuit an already-connected
# Slack with nothing to offer, so re-running the leg for them only prints a
# confirmation. Same parser and fail-closed rule as cli_has_code_agent_offer.
cli_has_slack_channel_picker() {
  SP_VERSION="$("$BIN" --version 2>/dev/null | awk '{print $2}')" || return 1
  SP_MAJOR="${SP_VERSION%%.*}"
  SP_REST="${SP_VERSION#*.}"
  SP_MINOR="${SP_REST%%.*}"
  SP_PATCH="${SP_REST#*.}"
  SP_PATCH="${SP_PATCH%%[!0-9]*}"
  case "$SP_MAJOR" in ''|*[!0-9]*) return 1 ;; esac
  case "$SP_MINOR" in ''|*[!0-9]*) return 1 ;; esac
  case "$SP_PATCH" in ''|*[!0-9]*) return 1 ;; esac
  [ "$SP_MAJOR" -gt 0 ] && return 0
  [ "$SP_MINOR" -gt 2 ] && return 0
  [ "$SP_MINOR" -eq 2 ] && [ "$SP_PATCH" -ge 22 ] && return 0
  return 1
}

cli_has_code_agent_offer() {
  CA_VERSION="$("$BIN" --version 2>/dev/null | awk '{print $2}')" || return 1
  CA_MAJOR="${CA_VERSION%%.*}"
  CA_REST="${CA_VERSION#*.}"
  CA_MINOR="${CA_REST%%.*}"
  CA_PATCH="${CA_REST#*.}"
  CA_PATCH="${CA_PATCH%%[!0-9]*}"
  case "$CA_MAJOR" in ''|*[!0-9]*) return 1 ;; esac
  case "$CA_MINOR" in ''|*[!0-9]*) return 1 ;; esac
  case "$CA_PATCH" in ''|*[!0-9]*) return 1 ;; esac
  [ "$CA_MAJOR" -gt 0 ] && return 0
  [ "$CA_MINOR" -gt 2 ] && return 0
  [ "$CA_MINOR" -eq 2 ] && [ "$CA_PATCH" -ge 17 ] && return 0
  return 1
}

# CLOUD_LOOKUP_OK mirrors CONNECTED_LOOKUP_OK for the cloud leg: a non-zero
# return from cloud_connected means "no accounts" only when the list call
# itself succeeded. The final gate requires a successful lookup before it can
# conclude that the workspace has no cloud account.
CLOUD_LOOKUP_OK=0
cloud_connected() {
  CLOUD_LOOKUP_OK=0
  signed_in || return 1
  CLOUD_TMP="$(new_tmp)"
  CLOUD_FOUND=1
  if "$BIN" cloud list --limit 1 --output json > "$CLOUD_TMP/accounts.json" 2>/dev/null; then
    if CLOUD_LIST_STATE="$(POLYLANE_JSON_FILE="$CLOUD_TMP/accounts.json" node 2>/dev/null <<'EOF'
const parsed = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
process.stdout.write(Array.isArray(parsed.items) && parsed.items.length > 0 ? "found" : "none");
EOF
)"; then
      case "$CLOUD_LIST_STATE" in
        found)
          CLOUD_LOOKUP_OK=1
          CLOUD_FOUND=0
          ;;
        none) CLOUD_LOOKUP_OK=1 ;;
      esac
    fi
  fi
  return "$CLOUD_FOUND"
}

# Verifies the post-pass invariant from fresh server state. A source is any
# active integration or cloud account. WORKSPACE_CONNECTION_STATE distinguishes
# a verified empty workspace from a lookup failure so the retry message is
# honest; neither state is allowed past the gate.
WORKSPACE_CONNECTION_STATE="unknown"
workspace_has_connection() {
  WORKSPACE_CONNECTION_STATE="unknown"
  load_connected_types
  INTEGRATION_LOOKUP_OK="$CONNECTED_LOOKUP_OK"
  if [ "$INTEGRATION_LOOKUP_OK" = "1" ] && [ -n "$CONNECTED_TYPES" ]; then
    WORKSPACE_CONNECTION_STATE="connected"
    return 0
  fi
  if cloud_connected; then
    WORKSPACE_CONNECTION_STATE="connected"
    return 0
  fi
  if [ "$INTEGRATION_LOOKUP_OK" = "1" ] && [ "$CLOUD_LOOKUP_OK" = "1" ]; then
    WORKSPACE_CONNECTION_STATE="none"
  fi
  return 1
}

# CONNECTED_ANY / GITHUB_CONNECTED track what connected DURING THIS RUN (the
# value lines below speak about work just kicked off, so reruns stay quiet).
# Already-connected integrations are skipped silently — no skip line, not
# even the spacer.
CONNECTED_ANY=0
GITHUB_CONNECTED=0
CLOUD_OK=0
connect_stack() {
  load_connected_types
  # Every leg records one outcome row (connect.<provider>.<outcome>):
  # skipped = already connected from an earlier run (never asked),
  # declined = asked and answered no, accepted = connected in this run,
  # failed = said yes but the connect command did not finish.
  # The cloud leg can add a second row after accepted: limit_declined = the
  # plan's cloud-account cap was reached and the CLI's upgrade offer was
  # declined or not completed; upgrade_pending = an upgrade was paid for but
  # has not applied yet.
  if has_connected github; then
    mark connect.github.skipped
  else
    info ""
    leg_begin
    if ask_yn "Connect GitHub?"; then
      if run_tty "$BIN" integration connect --type github && connect_landed github; then
        GITHUB_CONNECTED=1
        CONNECTED_ANY=1
        mark connect.github.accepted
      else
        mark connect.github.failed
        info "GitHub didn't finish. Retry any time: $BIN_NAME integration connect --type github"
      fi
    else
      mark connect.github.declined
    fi
  fi
  if has_connected slack; then
    # Already connected: a CLI with the channel picker re-runs the leg without
    # asking, and decides itself whether there is anything to offer (it exits 0
    # either way, so the exit code carries no answer). Older CLIs keep the skip.
    if cli_has_slack_channel_picker; then
      info ""
      leg_begin
      if run_tty "$BIN" integration connect --type slack; then
        mark connect.slack.skipped
      else
        mark connect.slack.failed
        info "Slack channels can be added any time: $BIN_NAME integration connect --type slack"
      fi
    else
      mark connect.slack.skipped
    fi
  else
    info ""
    leg_begin
    notice "Start investigations and receive incident and autofix updates in Slack. Invite Polylane separately to private channels."
    if ask_yn "Connect Slack?"; then
      if run_tty "$BIN" integration connect --type slack && connect_landed slack; then
        CONNECTED_ANY=1
        mark connect.slack.accepted
      else
        mark connect.slack.failed
        info "Slack didn't finish. Retry any time: $BIN_NAME integration connect --type slack"
      fi
    else
      mark connect.slack.declined
    fi
  fi
  if cloud_connected; then
    CLOUD_OK=1
    mark connect.cloud.skipped
  else
    info ""
    leg_begin
    if ask_yn "Connect a cloud provider (AWS, Cloudflare, Vercel, ...)?"; then
      # Most stacks span more than one cloud, and `cloud connect` picks the
      # provider from a single-select picker — so after each successful
      # connect, offer another. The CLI checks the plan's cloud-account cap
      # before each connect and, at the cap, offers the upgrade itself; its
      # exit code says how the connect ended:
      #   0 = connected (or upgraded, then connected)
      #   4 = at the cap and the upgrade was declined, canceled, or not
      #       completed in time; the CLI already said how to upgrade later
      #   7 = an upgrade was paid for but has not applied yet
      #   anything else = the connect did not finish
      # Any non-zero ends the loop; only a plain failure with nothing landed
      # gets the retry line, because a decline is a decision, not a failure.
      CLOUD_LEG_CONNECTED=0
      CLOUD_LEG_STOP=""
      while :; do
        run_tty "$BIN" cloud connect && CLOUD_RC=0 || CLOUD_RC=$?
        if [ "$CLOUD_RC" != "0" ]; then
          case "$CLOUD_RC" in
            4) CLOUD_LEG_STOP=limit_declined ;;
            7) CLOUD_LEG_STOP=upgrade_pending ;;
            *) CLOUD_LEG_STOP=failed ;;
          esac
          break
        fi
        CLOUD_LEG_CONNECTED=1
        info ""
        ask_ny "Connect another cloud account?" || break
      done
      # Same exit-0-on-timeout caveat as the integrations: the account list
      # has the final say when it can be read.
      if [ "$CLOUD_LEG_CONNECTED" = "1" ] && ! cloud_connected && [ "$CLOUD_LOOKUP_OK" = "1" ]; then
        CLOUD_LEG_CONNECTED=0
      fi
      if [ "$CLOUD_LEG_CONNECTED" = "1" ]; then
        CONNECTED_ANY=1
        CLOUD_OK=1
        mark connect.cloud.accepted
      fi
      case "$CLOUD_LEG_STOP" in
        limit_declined|upgrade_pending) mark "connect.cloud.$CLOUD_LEG_STOP" ;;
        *)
          # A plain failure, or every connect exited 0 without an account
          # landing (the list demoted it): nothing connected, offer the retry.
          if [ "$CLOUD_LEG_CONNECTED" != "1" ]; then
            mark connect.cloud.failed
            info "Connect any time later: $BIN_NAME cloud connect"
          fi
          ;;
      esac
    else
      mark connect.cloud.declined
    fi
  fi
  if observability_connected; then
    mark connect.observability.skipped
  else
    info ""
    leg_begin
    if ask_yn "Connect an observability tool (Datadog, Sentry, Honeycomb, ...)?"; then
      # Narrow the picker to observability when the CLI knows --category
      # (probed; older CLIs get the full picker).
      if "$BIN" integration connect --category observability --help >/dev/null 2>&1; then
        set -- --category observability
      else
        set --
      fi
      if run_tty "$BIN" integration connect "$@" && connect_landed observability; then
        CONNECTED_ANY=1
        mark connect.observability.accepted
      else
        mark connect.observability.failed
        info "Connect any time later: $BIN_NAME integration connect"
      fi
    else
      mark connect.observability.declined
    fi
  fi
  # Cloud coding agents (Devin, Cursor, Factory, Conductor). No ask_yn here:
  # a CLI new enough to pass the version gate opens this picker with its own
  # one-line explanation and yes/no (the offer), so the installer asking too
  # would just ask twice. Older CLIs skip the leg entirely. The CLI exits 0
  # on a decline as well as a connect, so a fresh list lookup — not the exit
  # code — decides whether anything was connected.
  if code_agent_connected; then
    mark connect.code_agent.skipped
  elif cli_has_code_agent_offer; then
    info ""
    leg_begin
    if run_tty "$BIN" integration connect --category code-agent; then
      load_connected_types
      if code_agent_connected; then
        CONNECTED_ANY=1
        mark connect.code_agent.accepted
      else
        mark connect.code_agent.declined
      fi
    else
      mark connect.code_agent.failed
      info "Connect any time later: $BIN_NAME integration connect --category code-agent"
    fi
  else
    # Pre-0.2.17 CLI: the leg has no offer to record an answer to.
    mark connect.code_agent.unavailable
  fi
}

# ---------------------------------------------------------------------------
# Sign-in (R1): required before workspace setup, using the existing auth flows.
# The run identifier is exported into `auth login`, but only a FRESH sign-in
# forwards it (?run= on the OAuth URLs, `run` in the signup body; since CLI
# 0.2.30 the API-key path binds it too). A machine that is already signed in
# never reaches a sign-in surface, so bind_run_to_session joins the run to
# the stored account instead.
# ---------------------------------------------------------------------------

# `polylane auth bind-run` owns the bind: it reads the stored credential,
# refreshes an expiring OAuth token, and POSTs the run id, exiting 0 with a note
# when there is nothing to bind. The command shipped in CLI 0.2.29, but that
# release's POST carried no JSON body and the API edge answered it with a bare
# 403 (nominal#1575), so the gate is 0.2.30 — the first release whose bind
# actually lands. Same parser and fail-closed rule as cli_has_code_agent_offer:
# an older CLI would print an unknown-command error (0.2.28-) or silently fail
# (0.2.29), so any parse doubt means "no" and the curl below does the work.
cli_has_bind_run() {
  BR_VERSION="$("$BIN" --version 2>/dev/null | awk '{print $2}')" || return 1
  BR_MAJOR="${BR_VERSION%%.*}"
  BR_REST="${BR_VERSION#*.}"
  BR_MINOR="${BR_REST%%.*}"
  BR_PATCH="${BR_REST#*.}"
  BR_PATCH="${BR_PATCH%%[!0-9]*}"
  case "$BR_MAJOR" in ''|*[!0-9]*) return 1 ;; esac
  case "$BR_MINOR" in ''|*[!0-9]*) return 1 ;; esac
  case "$BR_PATCH" in ''|*[!0-9]*) return 1 ;; esac
  [ "$BR_MAJOR" -gt 0 ] && return 0
  [ "$BR_MINOR" -gt 2 ] && return 0
  [ "$BR_MINOR" -eq 2 ] && [ "$BR_PATCH" -ge 30 ] && return 0
  return 1
}

# Joins this run to the already signed-in account. Same opt-out gating as
# emit_event, and never fatal: attribution can't block or fail an install.
# The CLI does the work when it can; the curl below is the fallback for CLIs
# that predate a working `auth bind-run` (it reads the raw stored credential, so it
# skips the token refresh the CLI would do).
bind_run_to_session() {
  [ "$DRY_RUN" = "1" ] && return 0
  if telemetry_opted_out; then return 0; fi
  [ -n "$RUN_ID" ] || return 0
  if cli_has_bind_run; then
    POLYLANE_ONBOARDING_RUN="$RUN_ID" "$BIN" auth bind-run --quiet >/dev/null 2>&1 || true
    return 0
  fi
  has curl || return 0
  BIND_TMP="$(new_tmp)" || return 0
  if ACCESS_TOKEN="$(json_field "$CONFIG_DIR/credentials.json" access_token)"; then
    printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" > "$BIND_TMP/headers"
  elif API_KEY="$(json_field "$CONFIG_DIR/config.json" api_key)"; then
    printf 'x-api-key: %s\n' "$API_KEY" > "$BIND_TMP/headers"
  else
    return 0
  fi
  curl -fsS --proto '=https' -m 5 --connect-timeout 3 -X POST \
    -H "Content-Type: application/json" -H @"$BIND_TMP/headers" --data '{}' \
    "https://$API_DOMAIN/v1/auth/onboarding_runs/$RUN_ID/bind" >/dev/null 2>&1 &
}

do_signin() {
  if signed_in; then
    ok "Already signed in"
    bind_run_to_session
    emit_event signin_completed
    return 0
  fi
  if ! has_tty; then
    emit_event signin_failed "" "non-interactive-no-tty"
    CURRENT_STEP=""
    printf '\033[31merror\033[0m (step: signin): %s\n' "sign-in needs a terminal, and none is attached" >&2
    printf 'Run the installer from an interactive shell, or sign in yourself and re-run:\n' >&2
    printf '  %s auth login        # pick a method: browser OAuth via GitHub or Google, email, device code, or API key\n' "$BIN_NAME" >&2
    printf '  %s auth login --no-browser   # device code, for machines without a browser\n' "$BIN_NAME" >&2
    printf '  curl -fsSL %s | bash\n' "$SETUP_URL" >&2
    exit 1
  fi
  info ""
  # Funnel note: signin_prompted now fires at the direct `auth login` invocation — no installer prompt is shown first.
  emit_event signin_prompted
  # No yes/no gate here: `auth login` opens with the CLI's own method picker
  # (browser OAuth via GitHub or Google, email, device code, API key), so a
  # second "sign in with your browser?" question would just ask twice.
  SIGNIN_OK=0
  if POLYLANE_ONBOARDING_RUN="$RUN_ID" run_tty "$BIN" auth login; then SIGNIN_OK=1; fi
  if [ "$SIGNIN_OK" = "1" ] && signed_in; then
    emit_event signin_completed
    return 0
  fi
  emit_event signin_failed "" "auth-command-failed-or-abandoned"
  CURRENT_STEP=""
  printf '\033[31merror\033[0m (step: signin): %s\n' "sign-in didn't complete" >&2
  printf 'Nothing is broken — re-run the installer to resume exactly here:\n' >&2
  printf '  curl -fsSL %s | bash\n' "$SETUP_URL" >&2
  printf 'Browser trouble? The device-code flow works everywhere:\n' >&2
  printf '  %s auth login --no-browser\n' "$BIN_NAME" >&2
  exit 1
}

# ---------------------------------------------------------------------------
# Workspace API key for the MCP registrations (R31): least-privilege
# (MCP_KEY_SCOPES), workspace-scoped, named for console linkage, and revocable
# from the console. The uninstaller revokes the current locally recorded key;
# scope-migration replacements retain older keys that may be in use elsewhere.
# This is a temporary compatibility bridge over ordinary /v1/api_keys. The MCP
# auth rewrite will stop fresh coding-agent setup from minting general REST keys
# and use native OAuth or a separate MCP-only broker credential instead.
# ---------------------------------------------------------------------------

mint_mcp_key() {
  ACCESS_TOKEN="$(json_field "$CONFIG_DIR/credentials.json" access_token)" || return 1
  WORKSPACE_ID="$(json_field "$CONFIG_DIR/config.json" workspace_id)" || return 1
  AUTH_TMP="$(new_tmp)"
  printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" > "$AUTH_TMP/headers"
  if ! POLYLANE_WORKSPACE_ID="$WORKSPACE_ID" POLYLANE_KEY_NAME="coding-agent $(machine_short_id)" \
    POLYLANE_KEY_SCOPES="$MCP_KEY_SCOPES" node > "$AUTH_TMP/request.json" 2>/dev/null <<'EOF'
process.stdout.write(
  JSON.stringify({
    workspaceId: process.env.POLYLANE_WORKSPACE_ID,
    name: process.env.POLYLANE_KEY_NAME.slice(0, 64),
    scopes: process.env.POLYLANE_KEY_SCOPES.split(" ").filter(Boolean),
  })
);
EOF
  then
    return 1
  fi
  curl -fsS --proto '=https' --connect-timeout 10 -X POST \
    -H "Content-Type: application/json" -H @"$AUTH_TMP/headers" \
    --data @"$AUTH_TMP/request.json" \
    "https://$API_DOMAIN/v1/api_keys" -o "$AUTH_TMP/response.json" 2>/dev/null || return 1

  # Capture a valid issued ID before validating the token or touching local
  # state. If any later boundary fails, rollback can identify and revoke only
  # this newly minted replacement. An absent/invalid ID is never guessed.
  if POLYLANE_RESPONSE="$AUTH_TMP/response.json" node > "$AUTH_TMP/issued-id" 2>/dev/null <<'EOF'
const res = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_RESPONSE, "utf-8"));
const id = res && res.success && res.result ? res.result.id : null;
if (typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) process.exit(1);
process.stdout.write(id);
EOF
  then
    MCP_KEY_ID="$(cat "$AUTH_TMP/issued-id")"
    MCP_KEY_MINTED=1
  fi

  if ! POLYLANE_RESPONSE="$AUTH_TMP/response.json" node > "$AUTH_TMP/token" 2>/dev/null <<'EOF'
const res = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_RESPONSE, "utf-8"));
if (
  !res.success ||
  !res.result ||
  typeof res.result.token !== "string" ||
  res.result.token === "" ||
  /[\r\n]/.test(res.result.token) ||
  typeof res.result.id !== "string" ||
  !/^[a-zA-Z0-9_-]+$/.test(res.result.id)
)
  process.exit(1);
process.stdout.write(res.result.token);
EOF
  then
    return 1
  fi
  MCP_KEY="$(cat "$AUTH_TMP/token")"
  [ -n "$MCP_KEY" ] && [ -n "$MCP_KEY_ID" ] || return 1

  # Persist the replacement atomically. For historical migration the durable
  # notice is committed in the same rename as the new key, so there is no state
  # where the old immutable ID has been overwritten and its guidance is lost.
  if ! POLYLANE_CONFIG_FILE="$CONFIG_DIR/config.json" \
    POLYLANE_MCP_KEY="$MCP_KEY" POLYLANE_MCP_KEY_ID="$MCP_KEY_ID" \
    POLYLANE_KEY_SCOPES="$MCP_KEY_SCOPES" \
    POLYLANE_RETAINED_KEY_ID="$RETAINED_MCP_KEY_ID" node 2>/dev/null <<'EOF'
const fs = require("node:fs");
const file = process.env.POLYLANE_CONFIG_FILE;
const tmp = file + ".replacement-" + process.pid;
let cfg;
try {
  cfg = JSON.parse(fs.readFileSync(file, "utf-8"));
  if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) process.exit(1);
  cfg.mcp_api_key = process.env.POLYLANE_MCP_KEY;
  cfg.mcp_api_key_id = process.env.POLYLANE_MCP_KEY_ID;
  // Recorded so reruns can distinguish this key from older scope sets.
  cfg.mcp_api_key_scopes = process.env.POLYLANE_KEY_SCOPES.split(" ").filter(Boolean);
  if (process.env.POLYLANE_RETAINED_KEY_ID)
    cfg.mcp_retained_key_notice_id = process.env.POLYLANE_RETAINED_KEY_ID;
  fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600, flag: "wx" });
  fs.chmodSync(tmp, 0o600);
  fs.renameSync(tmp, file);
} catch {
  try {
    fs.unlinkSync(tmp);
  } catch {}
  process.exit(1);
}
EOF
  then
    return 1
  fi
  return 0
}

# Rerun guard: an existing key is reused only when the scopes recorded for
# it are exactly MCP_KEY_SCOPES (order aside). That replaces the released key
# with its retired local-source write plus every earlier historical installer
# key. A user-supplied api_key is trusted as-is.
key_scopes_ok() {
  [ -f "$CONFIG_DIR/config.json" ] || return 1
  POLYLANE_JSON_FILE="$CONFIG_DIR/config.json" POLYLANE_KEY_SCOPES="$MCP_KEY_SCOPES" node 2>/dev/null <<'EOF'
const cfg = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
const stored = cfg && cfg.mcp_api_key_scopes;
const asSet = (list) => [...new Set(list)].sort().join(" ");
const wanted = asSet(process.env.POLYLANE_KEY_SCOPES.split(" ").filter(Boolean));
process.exit(Array.isArray(stored) && stored.every((s) => typeof s === "string") && asSet(stored) === wanted ? 0 : 1);
EOF
}

ambiguous_mcp_key() {
  warn "couldn't safely replace the historical installer API key: $1."
  info "No API key or managed MCP config was changed."
  return 1
}

# A historical replacement is permitted only after two independent checks:
# the exact local id resolves to this signed-in owner and workspace, and the
# exact local token appears in at least one recognized managed MCP client with
# no conflicting client token. Names and historical scopes only support those
# stronger correlations; they can never trigger replacement on their own.
historical_mcp_key_evidence_ok() {
  ACCESS_TOKEN="$(json_field "$CONFIG_DIR/credentials.json" access_token 2>/dev/null || true)"
  WORKSPACE_ID="$(json_field "$CONFIG_DIR/config.json" workspace_id 2>/dev/null || true)"
  RETAINED_MCP_KEY_ID="$(json_field "$CONFIG_DIR/config.json" mcp_api_key_id 2>/dev/null || true)"
  case "$WORKSPACE_ID" in ''|*[!a-zA-Z0-9_-]*) ambiguous_mcp_key "the signed-in workspace is missing or invalid"; return 1 ;; esac
  case "$RETAINED_MCP_KEY_ID" in ''|*[!a-zA-Z0-9_-]*) ambiguous_mcp_key "the exact local key ID is missing or invalid"; return 1 ;; esac
  [ -n "$ACCESS_TOKEN" ] || { ambiguous_mcp_key "the signed-in owner could not be verified"; return 1; }

  EVIDENCE_TMP="$(new_tmp)"
  printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" > "$EVIDENCE_TMP/headers"
  if ! curl -fsS --proto '=https' --connect-timeout 10 -H @"$EVIDENCE_TMP/headers" \
    "https://$API_DOMAIN/v1/auth/whoami" -o "$EVIDENCE_TMP/whoami.json" 2>/dev/null; then
    ambiguous_mcp_key "the signed-in owner could not be verified"
    return 1
  fi
  if ! curl -fsS --proto '=https' --connect-timeout 10 -H @"$EVIDENCE_TMP/headers" \
    "https://$API_DOMAIN/v1/api_keys/$WORKSPACE_ID/$RETAINED_MCP_KEY_ID" \
    -o "$EVIDENCE_TMP/key.json" 2>/dev/null; then
    ambiguous_mcp_key "the exact recorded key ID did not resolve to a server key"
    return 1
  fi

  if ! HOME="$HOME" POLYLANE_CONFIG_FILE="$CONFIG_DIR/config.json" \
    POLYLANE_WHOAMI_RESPONSE="$EVIDENCE_TMP/whoami.json" \
    POLYLANE_KEY_RESPONSE="$EVIDENCE_TMP/key.json" \
    POLYLANE_RETIRED_CURRENT="$MCP_KEY_SCOPES_RETIRED_CURRENT" \
    POLYLANE_HISTORICAL_BROAD="$MCP_KEY_SCOPES_HISTORICAL_BROAD" \
    POLYLANE_HISTORICAL_NARROW="$MCP_KEY_SCOPES_HISTORICAL_NARROW" \
    node > "$EVIDENCE_TMP/result" 2>/dev/null <<'EOF'
const fs = require("node:fs");
const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const fail = (reason) => {
  process.stdout.write(reason);
  process.exit(1);
};
const readJsonFile = (file) => JSON.parse(fs.readFileSync(file, "utf-8"));
let cfg;
let whoami;
let keyResponse;
try {
  cfg = readJsonFile(process.env.POLYLANE_CONFIG_FILE);
  whoami = readJsonFile(process.env.POLYLANE_WHOAMI_RESPONSE);
  keyResponse = readJsonFile(process.env.POLYLANE_KEY_RESPONSE);
} catch {
  fail("local or server identity evidence could not be read safely");
}
const owner = isObj(whoami) && whoami.success && isObj(whoami.result) ? whoami.result : null;
const serverKey = isObj(keyResponse) && keyResponse.success && isObj(keyResponse.result) ? keyResponse.result : null;
if (!isObj(cfg) || typeof cfg.mcp_api_key !== "string" || !cfg.mcp_api_key || /[\r\n]/.test(cfg.mcp_api_key))
  fail("the recorded local token is missing or invalid");
if (typeof cfg.mcp_api_key_id !== "string" || !cfg.mcp_api_key_id)
  fail("the exact local key ID is missing or invalid");
if (!owner || typeof owner.id !== "string" || !owner.id)
  fail("the signed-in owner could not be verified");
if (!serverKey || serverKey.id !== cfg.mcp_api_key_id)
  fail("the server record does not match the exact recorded key ID");
if (serverKey.workspaceId !== cfg.workspace_id)
  fail("the server key workspace does not match the signed-in workspace");
if (serverKey.ownerId !== owner.id)
  fail("the server key owner does not match the signed-in owner");

const asSet = (values) =>
  Array.isArray(values) && values.every((value) => typeof value === "string")
    ? [...new Set(values)].sort().join(" ")
    : null;
const localScopes = asSet(cfg.mcp_api_key_scopes);
const serverScopes = asSet(serverKey.scopes);
const historicalScopes = new Set([
  asSet(process.env.POLYLANE_RETIRED_CURRENT.split(" ").filter(Boolean)),
  asSet(process.env.POLYLANE_HISTORICAL_BROAD.split(" ").filter(Boolean)),
  asSet(process.env.POLYLANE_HISTORICAL_NARROW.split(" ").filter(Boolean)),
]);
if (
  typeof serverKey.name !== "string" ||
  !serverKey.name.startsWith("coding-agent ") ||
  !historicalScopes.has(localScopes) ||
  serverScopes !== localScopes
)
  fail("the server name and historical scopes do not consistently identify a supported installer cohort");
process.stdout.write("ok");
EOF
  then
    EVIDENCE_REASON="$(cat "$EVIDENCE_TMP/result" 2>/dev/null || true)"
    [ -n "$EVIDENCE_REASON" ] || EVIDENCE_REASON="the local and server evidence could not be correlated"
    ambiguous_mcp_key "$EVIDENCE_REASON"
    return 1
  fi
  REPLACED_MCP_KEY="$(json_field "$CONFIG_DIR/config.json" mcp_api_key 2>/dev/null || true)"
  if ! POLYLANE_MCP_CONFIG_MODE=inspect POLYLANE_OLD_MCP_KEY="$REPLACED_MCP_KEY" \
    managed_mcp_config_program > "$EVIDENCE_TMP/result" 2>/dev/null; then
    EVIDENCE_REASON="$(cat "$EVIDENCE_TMP/result" 2>/dev/null || true)"
    [ -n "$EVIDENCE_REASON" ] || EVIDENCE_REASON="managed MCP client evidence could not be read safely"
    ambiguous_mcp_key "$EVIDENCE_REASON"
    return 1
  fi
  return 0
}

prepare_mcp_config_backup() {
  BACKUP_TMP="$(new_tmp)"
  if ! cp -p "$CONFIG_DIR/config.json" "$BACKUP_TMP/config.json" 2>/dev/null ||
    ! cmp -s "$CONFIG_DIR/config.json" "$BACKUP_TMP/config.json" ||
    ! POLYLANE_JSON_FILE="$BACKUP_TMP/config.json" node 2>/dev/null <<'EOF'
const cfg = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
process.exit(cfg && typeof cfg === "object" && !Array.isArray(cfg) ? 0 : 1);
EOF
  then
    warn "couldn't create a verified rollback backup; no replacement API key was minted."
    info "No API key or managed MCP config was changed."
    return 1
  fi
  MCP_CONFIG_BACKUP="$BACKUP_TMP/config.json"
}

prepare_managed_mcp_config_backup() {
  MANAGED_BACKUP_TMP="$(new_tmp)"
  mkdir "$MANAGED_BACKUP_TMP/files" || return 1
  if ! POLYLANE_MCP_CONFIG_MODE=backup \
    POLYLANE_MCP_CONFIG_BACKUP="$MANAGED_BACKUP_TMP/files" \
    managed_mcp_config_program > "$MANAGED_BACKUP_TMP/result" 2>/dev/null; then
    warn "couldn't create a verified managed-config rollback backup; no replacement API key was minted."
    info "No API key or managed MCP config was changed."
    return 1
  fi
  MCP_MANAGED_CONFIG_BACKUP="$MANAGED_BACKUP_TMP/files"
}

# A failed mint or client migration restores local state and revokes only the
# newly minted, unusable replacement. Historical keys are never sent to DELETE.
rollback_mcp_key_replacement() {
  [ "${MCP_REPLACEMENT_ARMED:-0}" = "1" ] || return 0
  # Disarm first so an error or signal during rollback cannot recurse through
  # on_exit. The backup remains available until this function returns.
  MCP_REPLACEMENT_ARMED=0
  MANAGED_RESTORE_OK=1
  if [ -n "$MCP_MANAGED_CONFIG_BACKUP" ]; then
    if ! POLYLANE_MCP_CONFIG_MODE=restore \
      POLYLANE_MCP_CONFIG_BACKUP="$MCP_MANAGED_CONFIG_BACKUP" \
      managed_mcp_config_program >/dev/null 2>&1; then
      MANAGED_RESTORE_OK=0
    fi
  fi
  RESTORE_OK=0
  if [ -n "$MCP_CONFIG_BACKUP" ] && [ -f "$MCP_CONFIG_BACKUP" ]; then
    if cmp -s "$MCP_CONFIG_BACKUP" "$CONFIG_DIR/config.json" 2>/dev/null; then
      RESTORE_OK=1
    else
      RESTORE_FILE="$CONFIG_DIR/.config.rollback.$$"
      rm -f "$RESTORE_FILE" 2>/dev/null || true
      if cp -p "$MCP_CONFIG_BACKUP" "$RESTORE_FILE" 2>/dev/null &&
        mv -f "$RESTORE_FILE" "$CONFIG_DIR/config.json" 2>/dev/null; then
        RESTORE_OK=1
      else
        rm -f "$RESTORE_FILE" 2>/dev/null || true
      fi
    fi
  fi
  # These outer snapshots are the authoritative rollback boundary. Recompute
  # eligibility instead of preserving a transient in-process rollback failure
  # from authorize_agents when both authoritative restores succeeded.
  if [ "$RESTORE_OK" = "1" ] && [ "$MANAGED_RESTORE_OK" = "1" ]; then
    MCP_REPLACEMENT_CAN_REVOKE=1
  else
    MCP_REPLACEMENT_CAN_REVOKE=0
  fi

  if [ "$MCP_KEY_MINTED" = "1" ] && [ -n "$MCP_KEY_ID" ]; then
    if [ "$MCP_REPLACEMENT_CAN_REVOKE" = "1" ]; then
      ACCESS_TOKEN="$(json_field "$CONFIG_DIR/credentials.json" access_token 2>/dev/null || true)"
      WORKSPACE_ID="$(json_field "$CONFIG_DIR/config.json" workspace_id 2>/dev/null || true)"
      if [ -n "$ACCESS_TOKEN" ] && [ -n "$WORKSPACE_ID" ]; then
        REVOKE_TMP="$(new_tmp)"
        printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" > "$REVOKE_TMP/headers"
        curl -fsS --proto '=https' --connect-timeout 10 -X DELETE -H @"$REVOKE_TMP/headers" \
          "https://$API_DOMAIN/v1/api_keys/$WORKSPACE_ID/$MCP_KEY_ID" >/dev/null 2>&1 ||
          warn "couldn't revoke the unusable replacement API key ($MCP_KEY_ID); revoke it from Settings > API Keys"
      else
        warn "couldn't revoke the unusable replacement API key ($MCP_KEY_ID); revoke it from Settings > API Keys"
      fi
    else
      warn "kept replacement API key $MCP_KEY_ID active because local or managed config state could not be rolled back"
    fi
  fi
  MCP_KEY="$REPLACED_MCP_KEY"
  MCP_KEY_MINTED=0
  return 0
}

resolve_mcp_key() {
  MCP_REPLACEMENT_ARMED=0
  MCP_KEY_MINTED=0
  MCP_KEY_ID=""
  REPLACED_MCP_KEY=""
  RETAINED_MCP_KEY_ID=""
  MCP_CONFIG_BACKUP=""
  MCP_MANAGED_CONFIG_BACKUP=""
  MCP_REPLACEMENT_CAN_REVOKE=1
  MCP_KEY="$(json_field "$CONFIG_DIR/config.json" api_key)" && return 0
  if key_scopes_ok; then
    MCP_KEY="$(json_field "$CONFIG_DIR/config.json" mcp_api_key)" && return 0
  fi

  # Fresh installs have no remembered installer credential and mint directly.
  # Any partial or historical state must pass the full correlation check first.
  if [ -f "$CONFIG_DIR/config.json" ] && POLYLANE_JSON_FILE="$CONFIG_DIR/config.json" node 2>/dev/null <<'EOF'
const cfg = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
const fields = ["mcp_api_key", "mcp_api_key_id", "mcp_api_key_scopes"];
process.exit(cfg && typeof cfg === "object" && fields.some((field) => Object.prototype.hasOwnProperty.call(cfg, field)) ? 0 : 1);
EOF
  then
    historical_mcp_key_evidence_ok || return 1
  fi

  REPLACED_MCP_KEY="$(json_field "$CONFIG_DIR/config.json" mcp_api_key 2>/dev/null || true)"
  RETAINED_MCP_KEY_ID="$(json_field "$CONFIG_DIR/config.json" mcp_api_key_id 2>/dev/null || true)"
  case "$RETAINED_MCP_KEY_ID" in *[!a-zA-Z0-9_-]*) RETAINED_MCP_KEY_ID="" ;; esac

  # A byte-identical, parseable snapshot is mandatory before the transaction is
  # armed. From this point through managed-client commit, EXIT/INT/TERM and all
  # explicit failures converge on rollback_mcp_key_replacement.
  prepare_mcp_config_backup || return 1
  if [ -n "$REPLACED_MCP_KEY" ]; then
    prepare_managed_mcp_config_backup || return 1
  fi
  MCP_REPLACEMENT_ARMED=1
  if ! mint_mcp_key; then
    rollback_mcp_key_replacement
    return 1
  fi
  return 0
}

clear_retained_mcp_key_notice() {
  POLYLANE_CONFIG_FILE="$CONFIG_DIR/config.json" \
    POLYLANE_RETAINED_KEY_ID="$RETAINED_MCP_KEY_ID" node 2>/dev/null <<'EOF'
const fs = require("node:fs");
const file = process.env.POLYLANE_CONFIG_FILE;
const tmp = file + ".notice-" + process.pid;
try {
  const cfg = JSON.parse(fs.readFileSync(file, "utf-8"));
  if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) process.exit(1);
  // Never clear a newer/different notice observed between read and report.
  if (cfg.mcp_retained_key_notice_id !== process.env.POLYLANE_RETAINED_KEY_ID) process.exit(0);
  delete cfg.mcp_retained_key_notice_id;
  fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600, flag: "wx" });
  fs.chmodSync(tmp, 0o600);
  fs.renameSync(tmp, file);
} catch {
  try {
    fs.unlinkSync(tmp);
  } catch {}
  process.exit(1);
}
EOF
}

report_retained_mcp_key() {
  if [ -z "$RETAINED_MCP_KEY_ID" ]; then
    RETAINED_MCP_KEY_ID="$(json_field "$CONFIG_DIR/config.json" mcp_retained_key_notice_id 2>/dev/null || true)"
  fi
  case "$RETAINED_MCP_KEY_ID" in ''|*[!a-zA-Z0-9_-]*) return 0 ;; esac
  notice "Previous installer API key retained (ID: $RETAINED_MCP_KEY_ID)."
  info "It stays active because it may be used by another host, config, or CI system."
  info "Revoke it in Settings > API Keys only after at least seven quiet days with no traffic attributed to that key"
  info "and after you have explicitly confirmed that nothing else uses it."
  # Clear only after every guidance line has been written. If clearing fails or
  # the process is interrupted first, the next rerun prints the notice again.
  clear_retained_mcp_key_notice || warn "couldn't acknowledge retained-key guidance; it will be shown again on the next run"
}

# One shared parser owns both historical-token inspection and credential
# updates, so the safety gate cannot drift from the client formats it protects.
managed_mcp_config_program() {
  node <<'EOF'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const home = os.homedir();
const key = process.env.POLYLANE_MCP_KEY;
const oldKey = process.env.POLYLANE_OLD_MCP_KEY || "";
const mode = process.env.POLYLANE_MCP_CONFIG_MODE || "update";
const backupDir = process.env.POLYLANE_MCP_CONFIG_BACKUP || "";
if (
  (mode === "update" && !key) ||
  (mode === "inspect" && !oldKey) ||
  ((mode === "backup" || mode === "restore") && !backupDir)
)
  process.exit(1);
const updated = [];
const warnings = [];
const originals = new Map();
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);

function readJson(file) {
  try {
    const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
    return isObj(parsed) ? parsed : null;
  } catch {
    return null;
  }
}

// Every file that takes the key becomes owner-only. writeFileSync keeps an
// existing file's mode, and a config the agent or an older CLI created may
// sit at the umask default (world-readable), so the mode is pinned after
// each write. A chmod that fails is surfaced as a warning: the key is in the
// file by then, and silence would leave it readable. Originals stay in memory
// until every historical-key occurrence is gone, so migration is atomic.
function rememberOriginal(file) {
  if (originals.has(file)) return;
  const stat = fs.statSync(file);
  originals.set(file, { text: fs.readFileSync(file), mode: stat.mode & 0o777 });
}

function writeSecret(file, text) {
  rememberOriginal(file);
  fs.writeFileSync(file, text);
  try {
    fs.chmodSync(file, 0o600);
  } catch (err) {
    warnings.push("could not make " + file + " owner-only (" + ((err && err.code) || "chmod failed") + "); it now holds the workspace key, so run: chmod 600 " + JSON.stringify(file));
  }
}
const writeJson = (file, obj) => writeSecret(file, JSON.stringify(obj, null, 2) + "\n");

function parentOf(root, keys) {
  let node = root;
  for (const k of keys.slice(0, -1)) {
    node = isObj(node[k]) ? node[k] : null;
    if (!node) return null;
  }
  return isObj(node[keys[keys.length - 1]]) ? node : null;
}

function patchHeaders(id, file, keys) {
  const root = readJson(file);
  const parent = root && parentOf(root, keys);
  if (!parent) return;
  const leaf = keys[keys.length - 1];
  const entry = parent[leaf];
  const headers = isObj(entry.headers) ? entry.headers : {};
  if (headers["x-api-key"] === key) return;
  parent[leaf] = { ...entry, headers: { ...headers, "x-api-key": key } };
  writeJson(file, root);
  updated.push(id);
}

// opencode config is JSONC (comments, trailing commas) and lives in either
// opencode.jsonc or opencode.json. A JSON.stringify rewrite would clobber
// comments, so the JSONC path patches the key in by textual edit instead;
// the helpers below scan with string-awareness and every remaining character
// keeps its original offset.
function skipString(s, i) {
  i++;
  while (i < s.length) {
    if (s[i] === "\\") i += 2;
    else if (s[i] === '"') return i + 1;
    else i++;
  }
  return i;
}

function skipWs(s, i) {
  while (i < s.length && /\s/.test(s[i])) i++;
  return i;
}

function stripJsonc(text) {
  const chars = text.split("");
  if (chars[0] === "\uFEFF") chars[0] = " ";
  for (let i = 0; i < chars.length; ) {
    if (chars[i] === '"') {
      i = skipString(text, i);
    } else if (chars[i] === "/" && chars[i + 1] === "/") {
      while (i < chars.length && chars[i] !== "\n") chars[i++] = " ";
    } else if (chars[i] === "/" && chars[i + 1] === "*") {
      chars[i] = chars[i + 1] = " ";
      i += 2;
      while (i < chars.length && !(chars[i] === "*" && chars[i + 1] === "/")) {
        if (chars[i] !== "\n") chars[i] = " ";
        i++;
      }
      if (i < chars.length) {
        chars[i] = chars[i + 1] = " ";
        i += 2;
      }
    } else {
      i++;
    }
  }
  const blanked = chars.join("");
  for (let i = 0; i < chars.length; ) {
    if (chars[i] === '"') {
      i = skipString(blanked, i);
    } else if (chars[i] === ",") {
      const j = skipWs(blanked, i + 1);
      if (blanked[j] === "}" || blanked[j] === "]") chars[i] = " ";
      i++;
    } else {
      i++;
    }
  }
  return chars.join("");
}

function skipValue(s, i) {
  if (s[i] === '"') return skipString(s, i);
  if (s[i] === "{" || s[i] === "[") {
    let depth = 0;
    while (i < s.length) {
      if (s[i] === '"') {
        i = skipString(s, i);
        continue;
      }
      if (s[i] === "{" || s[i] === "[") depth++;
      else if (s[i] === "}" || s[i] === "]") {
        depth--;
        if (depth === 0) return i + 1;
      }
      i++;
    }
    return i;
  }
  while (i < s.length && !/[\s,}\]]/.test(s[i])) i++;
  return i;
}

// Index of the `{` opening the object reached by `keys` ([] = root), or -1.
function objectOpenIndex(s, keys) {
  let i = skipWs(s, 0);
  if (s[i] !== "{") return -1;
  for (const key of keys) {
    let j = skipWs(s, i + 1);
    let valueAt = -1;
    while (j < s.length && s[j] !== "}") {
      if (s[j] !== '"') return -1;
      const keyEnd = skipString(s, j);
      let name;
      try {
        name = JSON.parse(s.slice(j, keyEnd));
      } catch {
        return -1;
      }
      j = skipWs(s, keyEnd);
      if (s[j] !== ":") return -1;
      j = skipWs(s, j + 1);
      if (name === key) {
        valueAt = j;
        break;
      }
      j = skipWs(s, skipValue(s, j));
      if (s[j] === ",") j = skipWs(s, j + 1);
    }
    if (valueAt < 0 || s[valueAt] !== "{") return -1;
    i = valueAt;
  }
  return i;
}

// Range of the string value of member `name` in the object at `keys`.
function memberValueRange(s, keys, name) {
  const openIdx = objectOpenIndex(s, keys);
  if (openIdx < 0) return null;
  let j = skipWs(s, openIdx + 1);
  while (j < s.length && s[j] !== "}") {
    if (s[j] !== '"') return null;
    const keyEnd = skipString(s, j);
    let member;
    try {
      member = JSON.parse(s.slice(j, keyEnd));
    } catch {
      return null;
    }
    j = skipWs(s, keyEnd);
    if (s[j] !== ":") return null;
    j = skipWs(s, j + 1);
    const end = skipValue(s, j);
    if (member === name) return s[j] === '"' ? { start: j, end } : null;
    j = skipWs(s, end);
    if (s[j] === ",") j = skipWs(s, j + 1);
  }
  return null;
}

function patchOpencode() {
  const dir = path.join(home, ".config", "opencode");
  let file = null;
  for (const name of ["opencode.jsonc", "opencode.json"]) {
    const candidate = path.join(dir, name);
    if (fs.existsSync(candidate)) {
      file = candidate;
      break;
    }
  }
  if (!file) return;
  // Anything short of a clean edit is reported, never swallowed: the entry
  // has to end up authenticated in the file that exists.
  const warn = () => warnings.push("couldn't update " + file + " — fix its JSON and re-run this installer to authenticate the opencode MCP entry");
  let text;
  try {
    text = fs.readFileSync(file, "utf-8");
  } catch {
    warn();
    return;
  }
  let strict = null;
  try {
    strict = JSON.parse(text);
  } catch {}
  if (isObj(strict)) {
    const parent = parentOf(strict, ["mcp", "polylane"]);
    if (!parent) return;
    const entry = parent.polylane;
    const headers = isObj(entry.headers) ? entry.headers : {};
    if (headers["x-api-key"] === key) return;
    parent.polylane = { ...entry, headers: { ...headers, "x-api-key": key } };
    writeJson(file, strict);
    updated.push("opencode");
    return;
  }
  const stripped = stripJsonc(text);
  let root;
  try {
    root = JSON.parse(stripped);
  } catch {
    warn();
    return;
  }
  if (!isObj(root)) {
    warn();
    return;
  }
  const parent = parentOf(root, ["mcp", "polylane"]);
  if (!parent) return;
  const entry = parent.polylane;
  const headers = isObj(entry.headers) ? entry.headers : {};
  if (headers["x-api-key"] === key) return;
  let next = null;
  if (typeof headers["x-api-key"] === "string") {
    const range = memberValueRange(stripped, ["mcp", "polylane", "headers"], "x-api-key");
    if (range) next = text.slice(0, range.start) + JSON.stringify(key) + text.slice(range.end);
  } else {
    const keys = isObj(entry.headers) ? ["mcp", "polylane", "headers"] : ["mcp", "polylane"];
    const openIdx = objectOpenIndex(stripped, keys);
    if (openIdx >= 0) {
      const member =
        keys.length === 3
          ? '"x-api-key": ' + JSON.stringify(key)
          : '"headers": { "x-api-key": ' + JSON.stringify(key) + " }";
      const unit = (/\n([ \t]+)\S/.exec(text) || [])[1] || "  ";
      const indent = unit.repeat(keys.length + 1);
      const empty = stripped[skipWs(stripped, openIdx + 1)] === "}";
      const insertion = empty
        ? "\n" + indent + member + "\n" + unit.repeat(keys.length)
        : "\n" + indent + member + ",";
      next = text.slice(0, openIdx + 1) + insertion + text.slice(openIdx + 1);
    }
  }
  let probe = null;
  if (next !== null) {
    try {
      probe = JSON.parse(stripJsonc(next));
    } catch {}
  }
  const mcp = isObj(probe) && isObj(probe.mcp) ? probe.mcp : null;
  const patched = mcp && isObj(mcp.polylane) && isObj(mcp.polylane.headers) ? mcp.polylane.headers : null;
  if (!patched || patched["x-api-key"] !== key) {
    warn();
    return;
  }
  writeSecret(file, next);
  updated.push("opencode");
}

function patchZed(file) {
  const root = readJson(file);
  const parent = root && parentOf(root, ["context_servers", "polylane"]);
  if (!parent) return;
  const entry = parent.polylane;
  if (!Array.isArray(entry.args)) return;
  const headerArg = "x-api-key:" + key;
  const args = [...entry.args];
  const i = args.findIndex((a) => typeof a === "string" && a.startsWith("x-api-key:"));
  if (i >= 0) {
    if (args[i] === headerArg) return;
    args[i] = headerArg;
  } else {
    args.push("--header", headerArg);
  }
  parent.polylane = { ...entry, args };
  writeJson(file, root);
  updated.push("zed");
}

function patchCodex(file) {
  let content;
  try {
    content = fs.readFileSync(file, "utf-8");
  } catch {
    return;
  }
  const lines = content.split("\n");
  const start = lines.findIndex((l) => l.trim() === "[mcp_servers.polylane]");
  if (start < 0) return;
  let end = lines.length;
  for (let i = start + 1; i < lines.length; i++) {
    if (lines[i].trim().startsWith("[")) {
      end = i;
      break;
    }
  }
  const authLine = 'http_headers = { "x-api-key" = "' + key + '" }';
  const known = /^http_headers\s*=\s*\{\s*"x-api-key"\s*=\s*"[^"]*"\s*\}\s*$/;
  let insert = start + 1;
  for (let i = start + 1; i < end; i++) {
    const t = lines[i].trim();
    if (t.startsWith("http_headers")) {
      if (t === authLine || !known.test(t)) return;
      lines[i] = authLine;
      writeSecret(file, lines.join("\n"));
      updated.push("codex");
      return;
    }
    if (t !== "") insert = i + 1;
  }
  lines.splice(insert, 0, authLine);
  writeSecret(file, lines.join("\n"));
  updated.push("codex");
}

function jsonCredentials(file, keys, jsonc = false) {
  if (!fs.existsSync(file)) return [];
  const text = fs.readFileSync(file, "utf-8");
  const root = JSON.parse(jsonc ? stripJsonc(text) : text);
  const parent = isObj(root) ? parentOf(root, keys) : null;
  if (!parent) return [];
  const entry = parent[keys[keys.length - 1]];
  if (entry.headers === undefined) return [];
  if (!isObj(entry.headers)) throw new Error("invalid headers");
  const token = entry.headers["x-api-key"];
  if (token === undefined) return [];
  if (typeof token !== "string") throw new Error("invalid token");
  return [token];
}

function zedCredentials(file) {
  if (!fs.existsSync(file)) return [];
  const root = JSON.parse(fs.readFileSync(file, "utf-8"));
  const parent = isObj(root) ? parentOf(root, ["context_servers", "polylane"]) : null;
  if (!parent) return [];
  const entry = parent.polylane;
  if (!Array.isArray(entry.args)) throw new Error("invalid args");
  return entry.args
    .filter((arg) => typeof arg === "string" && arg.startsWith("x-api-key:"))
    .map((arg) => arg.slice("x-api-key:".length));
}

function codexCredentials(file) {
  if (!fs.existsSync(file)) return [];
  const lines = fs.readFileSync(file, "utf-8").split("\n");
  const start = lines.findIndex((line) => line.trim() === "[mcp_servers.polylane]");
  if (start < 0) return [];
  let end = lines.length;
  for (let i = start + 1; i < lines.length; i++) {
    if (lines[i].trim().startsWith("[")) {
      end = i;
      break;
    }
  }
  const tokens = [];
  for (const line of lines.slice(start + 1, end)) {
    if (!line.trim().startsWith("http_headers")) continue;
    const matches = [...line.matchAll(/["']x-api-key["']\s*=\s*"([^"]*)"/g)];
    if (line.includes("x-api-key") && matches.length === 0) throw new Error("invalid token");
    for (const match of matches) tokens.push(match[1]);
  }
  return tokens;
}

const vscodeUserDir =
  process.platform === "darwin"
    ? path.join(home, "Library", "Application Support", "Code", "User")
    : path.join(home, ".config", "Code", "User");

const managedFiles = [
  path.join(home, ".claude.json"),
  path.join(home, ".cursor", "mcp.json"),
  path.join(vscodeUserDir, "mcp.json"),
  path.join(home, ".config", "opencode", "opencode.jsonc"),
  path.join(home, ".config", "opencode", "opencode.json"),
  path.join(home, ".codeium", "windsurf", "mcp_config.json"),
  path.join(home, ".pi", "agent", "mcp.json"),
  path.join(home, ".warp", ".mcp.json"),
  path.join(vscodeUserDir, "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
  path.join(home, ".cline", "data", "settings", "cline_mcp_settings.json"),
  path.join(vscodeUserDir, "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
  path.join(home, ".gemini", "settings.json"),
  path.join(home, ".config", "zed", "settings.json"),
  path.join(home, ".codex", "config.toml"),
];

// Historical migration snapshots every existing managed file before the POST.
// The shell keeps this durable backup until commit, so a signal that kills this
// process midway through updates cannot strand an already-written client.
if (mode === "backup") {
  const entries = [];
  try {
    for (const [index, file] of managedFiles.entries()) {
      if (!fs.existsSync(file)) continue;
      const bytes = fs.readFileSync(file);
      const modeBits = fs.statSync(file).mode & 0o777;
      const name = String(index) + ".bak";
      const backup = path.join(backupDir, name);
      fs.writeFileSync(backup, bytes, { mode: 0o600, flag: "wx" });
      fs.chmodSync(backup, 0o600);
      if (!fs.readFileSync(backup).equals(bytes)) throw new Error("backup mismatch");
      entries.push({ file, backup: name, mode: modeBits });
    }
    const manifest = path.join(backupDir, "manifest.json");
    fs.writeFileSync(manifest, JSON.stringify(entries), { mode: 0o600, flag: "wx" });
    fs.chmodSync(manifest, 0o600);
    if (!Array.isArray(JSON.parse(fs.readFileSync(manifest, "utf-8")))) throw new Error("invalid manifest");
    process.stdout.write("ok");
    process.exit(0);
  } catch {
    process.exit(1);
  }
}

if (mode === "restore") {
  let failed = false;
  try {
    const entries = JSON.parse(fs.readFileSync(path.join(backupDir, "manifest.json"), "utf-8"));
    const allowed = new Set(managedFiles);
    if (!Array.isArray(entries)) throw new Error("invalid manifest");
    for (const entry of [...entries].reverse()) {
      try {
        if (
          !entry ||
          !allowed.has(entry.file) ||
          typeof entry.backup !== "string" ||
          !/^\d+\.bak$/.test(entry.backup) ||
          !Number.isInteger(entry.mode)
        )
          throw new Error("invalid entry");
        const bytes = fs.readFileSync(path.join(backupDir, entry.backup));
        fs.writeFileSync(entry.file, bytes);
        fs.chmodSync(entry.file, entry.mode);
        if (!fs.readFileSync(entry.file).equals(bytes) || (fs.statSync(entry.file).mode & 0o777) !== entry.mode)
          throw new Error("restore mismatch");
      } catch {
        failed = true;
      }
    }
  } catch {
    failed = true;
  }
  process.exit(failed ? 1 : 0);
}

const jobs = [
  () => patchHeaders("claude", managedFiles[0], ["mcpServers", "polylane"]),
  () => patchHeaders("cursor", managedFiles[1], ["mcpServers", "polylane"]),
  () => patchHeaders("vscode", managedFiles[2], ["servers", "polylane"]),
  () => patchOpencode(),
  () => patchHeaders("windsurf", managedFiles[5], ["mcpServers", "polylane"]),
  () => patchHeaders("pi", managedFiles[6], ["mcpServers", "polylane"]),
  () => patchHeaders("warp", managedFiles[7], ["mcpServers", "polylane"]),
  () => patchHeaders("cline", managedFiles[8], ["mcpServers", "polylane"]),
  () => patchHeaders("cline-cli", managedFiles[9], ["mcpServers", "polylane"]),
  () => patchHeaders("roo", managedFiles[10], ["mcpServers", "polylane"]),
  () => patchHeaders("gemini", managedFiles[11], ["mcpServers", "polylane"]),
  // goose's YAML config is left bare; it authenticates via OAuth on first use.
  () => patchZed(managedFiles[12]),
  () => patchCodex(managedFiles[13]),
];

if (mode === "inspect") {
  const inspections = [
    () => jsonCredentials(managedFiles[0], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[1], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[2], ["servers", "polylane"]),
    () => jsonCredentials(managedFiles[3], ["mcp", "polylane"], true),
    () => jsonCredentials(managedFiles[4], ["mcp", "polylane"], true),
    () => jsonCredentials(managedFiles[5], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[6], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[7], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[8], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[9], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[10], ["mcpServers", "polylane"]),
    () => jsonCredentials(managedFiles[11], ["mcpServers", "polylane"]),
    () => zedCredentials(managedFiles[12]),
    () => codexCredentials(managedFiles[13]),
  ];
  let observed = [];
  try {
    for (const inspect of inspections) observed = observed.concat(inspect());
  } catch {
    process.stdout.write("a recognized managed MCP configuration could not be read safely");
    process.exit(1);
  }
  if (observed.some((token) => token !== oldKey)) {
    process.stdout.write("managed MCP clients disagree about the recorded token");
    process.exit(1);
  }
  if (!observed.some((token) => token === oldKey)) {
    process.stdout.write("no recognized managed MCP client contains the recorded token");
    process.exit(1);
  }
  process.stdout.write("ok");
  process.exit(0);
}

for (const job of jobs) {
  try {
    job();
  } catch {}
}

// A historical token must disappear from every format this installer manages.
// If one file cannot be patched, put every earlier write (and mode) back before
// the shell restores local state and revokes only the unusable replacement.
let migrationFailed = false;
if (oldKey) {
  migrationFailed = managedFiles.some((file) => {
    try {
      return fs.readFileSync(file, "utf-8").includes(oldKey);
    } catch {
      return false;
    }
  });
}
if (migrationFailed) {
  let rollbackFailed = false;
  for (const [file, original] of [...originals].reverse()) {
    try {
      fs.writeFileSync(file, original.text);
      fs.chmodSync(file, original.mode);
    } catch {
      rollbackFailed = true;
    }
  }
  updated.length = 0;
  warnings.push(
    rollbackFailed
      ? "couldn't replace the previous key in every managed MCP config, and at least one config could not be rolled back"
      : "couldn't replace the previous key in every managed MCP config; all managed config changes were rolled back",
  );
}
// Line 1 is the updated list; any further lines are warnings for the shell
// to surface — a config we know needs the credential but couldn't take it
// must never fail silently.
process.stdout.write(updated.join(", ") + "\n");
for (const warning of warnings) process.stdout.write(warning + "\n");
if (migrationFailed) process.exitCode = 1;
EOF
}

# Adds the key only to `polylane` entries that already exist (created by
# `polylane setup` above); bare entries still work via the agent's own OAuth
# prompt. Exactly one server is touched: the authed `polylane` MCP.
authorize_agents() {
  [ -n "$MCP_KEY" ] || return 1
  AGENTS_TMP="$(new_tmp)"
  if ! POLYLANE_MCP_KEY="$MCP_KEY" POLYLANE_OLD_MCP_KEY="$REPLACED_MCP_KEY" \
    POLYLANE_MCP_CONFIG_MODE=update managed_mcp_config_program > "$AGENTS_TMP/updated" 2>/dev/null
  then
    if grep -qF "at least one config could not be rolled back" "$AGENTS_TMP/updated" 2>/dev/null; then
      MCP_REPLACEMENT_CAN_REVOKE=0
    fi
    tail -n +2 "$AGENTS_TMP/updated" 2>/dev/null | while IFS= read -r WARNING; do
      [ -z "$WARNING" ] || printf '\033[33mwarning\033[0m: %s\n' "$WARNING"
    done
    return 1
  fi
  UPDATED="$(head -n 1 "$AGENTS_TMP/updated")"
  tail -n +2 "$AGENTS_TMP/updated" | while IFS= read -r WARNING; do
    [ -z "$WARNING" ] || printf '\033[33mwarning\033[0m: %s\n' "$WARNING"
  done
  [ -z "$UPDATED" ] || ok "MCP authenticated for $UPDATED"
}

# ---------------------------------------------------------------------------
# Topology handoff: resolve the signed-in workspace's slug so the final line
# can deep-link straight to the page where discovery results land. Best-effort
# only — any failure falls back to the bare console URL and never fails the install.
# ---------------------------------------------------------------------------

# Prints the workspace slug on stdout using the signed-in session
# (GET /v1/workspaces/{id} -> result.slug). Returns non-zero on any failure so
# the caller falls back to the console root.
workspace_slug() {
  ACCESS_TOKEN="$(json_field "$CONFIG_DIR/credentials.json" access_token)" || return 1
  WORKSPACE_ID="$(json_field "$CONFIG_DIR/config.json" workspace_id)" || return 1
  case "$WORKSPACE_ID" in ''|*[!a-zA-Z0-9_-]*) return 1 ;; esac
  SLUG_TMP="$(new_tmp)"
  printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" > "$SLUG_TMP/headers"
  curl -fsS --proto '=https' -m 10 --connect-timeout 5 -H @"$SLUG_TMP/headers" \
    "https://$API_DOMAIN/v1/workspaces/$WORKSPACE_ID" \
    -o "$SLUG_TMP/response.json" 2>/dev/null || return 1
  POLYLANE_RESPONSE="$SLUG_TMP/response.json" node > "$SLUG_TMP/slug" 2>/dev/null <<'EOF' || : > "$SLUG_TMP/slug"
const res = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_RESPONSE, "utf-8"));
const slug = res.success && res.result ? res.result.slug : "";
// Same slug shape the API enforces; anything else never reaches a URL.
if (typeof slug !== "string" || !/^[a-z0-9_]+(?:-[a-z0-9_]+)*$/.test(slug)) process.exit(1);
process.stdout.write(slug);
EOF
  RESOLVED_SLUG="$(cat "$SLUG_TMP/slug" 2>/dev/null || true)"
  [ -n "$RESOLVED_SLUG" ] || return 1
  printf '%s' "$RESOLVED_SLUG"
}

# Best-effort browser open (the sign-in browser flow lives inside the CLI, so
# the script needs its own). Quiet on failure: the URL is already printed.
open_url() {
  case "$(uname -s)" in
    Darwin) has open && open "$1" >/dev/null 2>&1 ;;
    *) has xdg-open && xdg-open "$1" >/dev/null 2>&1 ;;
  esac
}

# ---------------------------------------------------------------------------
# Agent wiring (same machinery as 0.1.x: `polylane setup` + agent skills).
# ---------------------------------------------------------------------------

run_setup() {
  info ""
  info "Wiring $BIN_NAME into your coding agents..."
  # --quiet hides the per-agent detail lines; setup failures still exit
  # non-zero and are reported below. Probe first: a CLI that doesn't know
  # the flag exits non-zero on it, and then gets the full output as before.
  if "$BIN" setup --quiet --help >/dev/null 2>&1; then
    set -- --quiet
  else
    set --
  fi
  if [ -t 0 ]; then
    "$BIN" setup "$@"
  elif [ -r /dev/tty ] && [ -t 1 ]; then
    "$BIN" setup "$@" </dev/tty
  else
    "$BIN" setup "$@"
  fi
}

# The coding agents on this machine as skills.sh ids, space-separated. The CLI
# owns all of it — detection (the same one `polylane setup` just wired) and the
# polylane→skills.sh id table (`SKILLS_SH_IDS` in its agent registry, checked
# against the skills.sh version pinned by SKILLS_CLI); this script only
# forwards the answer. A CLI too old for the flag (a POLYLANE_VERSION pin)
# rejects it and prints nothing, so the skills step is skipped, not guessed.
skills_agent_ids() {
  "$BIN" setup --list-detected --ids skills-sh --output text 2>/dev/null | tr '\n' ' ' | sed 's/ *$//'
}

# skills.sh CLI first, tarball fallback below. Best-effort: a skills failure
# never fails the install. skills.sh is told WHICH agents get skills: with -y
# and nothing auto-detected it installs to every agent it knows (~90 dot-dirs
# in a fresh HOME, which `polylane setup` then mistook for installed agents —
# #186), so pass the CLI's detected agents explicitly and skip the step
# entirely when there are none. -a consumes every following non-flag
# token, so it goes last.
install_skills() {
  SKILLS_AGENTS="$(skills_agent_ids)"
  if [ -z "$SKILLS_AGENTS" ]; then
    mark agent_setup.skills.skipped
    return 0
  fi
  leg_begin
  # shellcheck disable=SC2086 # word-split on purpose: one argv token per id
  if has npx && npx --yes "$SKILLS_CLI" add "$SKILLS_REPO" -s '*' -g -y -a $SKILLS_AGENTS </dev/null >/dev/null 2>&1; then
    ok "installed agent skills for $SKILLS_AGENTS (via skills.sh)"
    mark agent_setup.skills.installed
    return 0
  fi
  install_skills_tarball
  mark agent_setup.skills.fallback
}

install_skills_tarball() {
  has tar || return 0
  SKILLS_TMP="$(new_tmp)"
  curl -fsSL --proto '=https' --retry 3 --connect-timeout 10 "$SKILLS_URL" 2>/dev/null | tar -xzf - -C "$SKILLS_TMP" 2>/dev/null || return 0
  set -- "$SKILLS_TMP"/*/skills
  SKILLS_SRC="$1"
  [ -d "$SKILLS_SRC" ] || return 0

  SKILL_COUNT=0
  for d in "$SKILLS_SRC"/*/; do
    [ -f "${d}SKILL.md" ] && SKILL_COUNT=$((SKILL_COUNT + 1))
  done

  SKILL_AGENTS=""
  for pair in \
    "claude=$HOME/.claude" \
    "cursor=$HOME/.cursor" \
    "opencode=$HOME/.config/opencode" \
    "codex=$HOME/.codex" \
    "pi=$HOME/.pi/agent" \
    "warp=$HOME/.warp"; do
    agent="${pair%%=*}"
    base="${pair#*=}"
    case "$agent" in
      claude) [ -d "$base" ] || [ -f "$HOME/.claude.json" ] || continue ;;
      pi) [ -d "$HOME/.pi" ] || continue ;;
      *) [ -d "$base" ] || continue ;;
    esac
    for d in "$SKILLS_SRC"/*/; do
      [ -f "${d}SKILL.md" ] || continue
      mkdir -p "$base/skills/$(basename "$d")"
      cp -R "${d}." "$base/skills/$(basename "$d")/"
    done
    SKILL_AGENTS="${SKILL_AGENTS:+$SKILL_AGENTS, }$agent"
  done
  [ -z "$SKILL_AGENTS" ] || ok "installed $SKILL_COUNT agent skills for $SKILL_AGENTS"
}

# The first question is asked FOR the user, exactly once per workspace. Three
# states: (1) this machine already asked -> remind and link (marker in
# ~/.polylane/config.json); (2) someone else on the workspace already asked ->
# say so and link theirs (found by scanning the workspace's earliest threads
# for the fix-first ask); (3) nobody has -> fire the ask, store the marker,
# offer to open. Any failure falls back to the ask-anything pointer.
store_first_thread() {
  POLYLANE_CONFIG_FILE="$CONFIG_DIR/config.json" POLYLANE_THREAD_URL="$1" node 2>/dev/null <<'NODE' || true
const fs = require("node:fs");
const file = process.env.POLYLANE_CONFIG_FILE;
let cfg = {};
try {
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) cfg = parsed;
} catch {}
cfg.first_ask_thread_url = process.env.POLYLANE_THREAD_URL;
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
fs.chmodSync(file, 0o600);
NODE
}

# The workspace-wide check keys on the ask's stable phrase ("fix first") in
# the earliest matching thread, so a teammate's install finds the original.
workspace_first_thread() {
  WFT_TMP="$(new_tmp)"
  WFT_URL=""
  if "$BIN" thread list --output json --quiet > "$WFT_TMP/threads.json" 2>/dev/null; then
    WFT_URL="$(POLYLANE_JSON_FILE="$WFT_TMP/threads.json" node 2>/dev/null <<'NODE'
const parsed = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
const items = Array.isArray(parsed.items) ? parsed.items : [];
const match = items
  .filter((t) => t && typeof t._html_url === "string" && t._html_url.startsWith("https://"))
  .filter((t) => /fix first/i.test([t.title, t.name, t.summary].filter(Boolean).join(" ")))
  .sort((a, b) => String(a.created ?? "").localeCompare(String(b.created ?? "")))[0];
if (!match) process.exit(1);
process.stdout.write(match._html_url);
NODE
)" || WFT_URL=""
  fi
  [ -n "$WFT_URL" ] && printf '%s' "$WFT_URL"
}

# ---------------------------------------------------------------------------
# Main flow. Sign-in-first: nothing workspace-shaped happens before auth.
#
# Everything above is a function definition or a side-effect-free variable
# assignment (no I/O, no network, no filesystem writes); the call to main on
# the LAST line of the script is the only statement that does work. A
# curl | bash stream that dies mid-transfer therefore executes nothing that
# matters: either main's definition is incomplete (syntax error, no code run)
# or the call itself is missing (nothing left to run).
# ---------------------------------------------------------------------------

main() {
  windows_shell_guard

  for arg in "$@"; do
    case "$arg" in
      --no-setup) NO_SETUP=1 ;;
      --dry-run) DRY_RUN=1 ;;
      *) printf '\033[31merror\033[0m: %s\n' "unknown argument: $arg (supported: --dry-run, --no-setup)" >&2; exit 1 ;;
    esac
  done

  validate_prefix
  validate_console_domain

  has curl || { CURRENT_STEP=""; printf '\033[31merror\033[0m: curl is required\n' >&2; exit 1; }

  if [ "$DRY_RUN" = "1" ]; then
    print_dry_run_plan
    exit 0
  fi

  setup_tmp_root
  mint_run_id
  emit_started

  # --- Step: cli_install -----------------------------------------------------
  step_begin cli_install
  if node_ok; then
    install_from_release
  elif has brew; then
    info "Node.js 20+ not found; Homebrew will install it as a dependency."
    install_with_brew || die "Homebrew install failed. Fix the brew error above (or install Node.js 20+ from https://nodejs.org), then re-run:
  curl -fsSL $SETUP_URL | bash"
  else
    die "Node.js 20+ is required to run $BIN_NAME.
Install it from https://nodejs.org (or install Homebrew, https://brew.sh), then re-run:
  curl -fsSL $SETUP_URL | bash"
  fi

  if [ -x "$PREFIX_DIR/$BIN_NAME" ]; then
    # Always drive the rest of the flow with the freshly installed binary,
    # even when a stale polylane (npm global, brew, an old prefix) resolves
    # first on PATH.
    BIN="$PREFIX_DIR/$BIN_NAME"
  else
    # Homebrew path: brew owns the binary's location.
    has "$BIN_NAME" || die "installed, but '$BIN_NAME' is not on your PATH. Open a new shell, then run: $BIN_NAME --version"
    BIN="$(command -v "$BIN_NAME")"
  fi
  # Shadow check: if what PATH resolves is not this install, say so — new
  # shells would quietly run the stale binary while everything below used the
  # fresh one.
  RESOLVED_BIN="$(command -v "$BIN_NAME" 2>/dev/null || true)"
  if [ "$BIN" = "$PREFIX_DIR/$BIN_NAME" ] && [ -n "$RESOLVED_BIN" ] && ! is_ours "$RESOLVED_BIN"; then
    warn "a different $BIN_NAME at $RESOLVED_BIN shadows the one just installed — new shells will run that one."
    info "Remove it (npm rm -g @coreplane/polylane / brew uninstall polylane) or put $PREFIX_DIR earlier in PATH. This install continues with the new binary."
  fi
  # The rerun-skip branch already proved the binary runs (it reported the
  # target version); everything else must prove it now — a bundle that can't
  # even print its version would fail every later step confusingly.
  if [ "$DOWNLOAD_SKIPPED" != "1" ]; then
    CLI_VERSION_OUT="$("$BIN" --version 2>&1)" || die "$BIN_NAME was installed but won't run: $CLI_VERSION_OUT
Check node --version (20+ required), then re-run:
  curl -fsSL $SETUP_URL | bash"
    ok "$CLI_VERSION_OUT"
  fi
  # Telemetry disclosure at a calm moment (right after the version line), then
  # the ack: with POLYLANE_TELEMETRY_NOTICE_ACK exported, every CLI invocation
  # below inherits it and the CLI marks its first-run notice as shown instead
  # of interjecting it mid-sign-in. The env var name is the contract with the
  # CLI — do not rename it.
  # The "telemetry is on" line would be false for users who already opted out
  # (and telemetry_opted_out has already silenced the installer's own funnel
  # for them — see mint_run_id). A persisted `polylane telemetry disable`
  # can't be checked cheaply from here, so that case still sees the line. The
  # ack export below stays unconditional either way — it only marks the
  # notice as handled.
  if ! telemetry_opted_out; then
    notice "Anonymous usage telemetry is on. polylane telemetry disable to opt out."
  fi
  export POLYLANE_TELEMETRY_NOTICE_ACK=1
  # The installer owns the onboarding journey (connections and topology
  # link), so suppress the CLI's own next-step hints for every invocation
  # below. Guidance only — data, status lines, errors, and prompts are
  # unaffected. The env var name is the contract with the CLI (>= 0.2.19);
  # older CLIs ignore it. Do not rename it.
  export POLYLANE_HINTS=0
  record_ref
  step_done

  # --- Step: agent_setup -----------------------------------------------------
  SETUP_OK=0
  if [ "$NO_SETUP" = "1" ]; then
    mark agent_setup.skipped
    printf '\nAgent setup skipped. Run \033[1mpolylane setup\033[0m to wire %s into your coding agents.\n' "$BIN_NAME"
  else
    step_begin agent_setup
    if run_setup; then
      SETUP_OK=1
      install_skills
      step_done
    else
      emit_event step_failed agent_setup "polylane-setup-exited-nonzero"
      CURRENT_STEP=""
      info "Agent setup did not complete. Run it later with: $BIN_NAME setup"
    fi
  fi

  # --- Step: signin (R1: before workspace setup) -----------------------------
  # Sign-in emits its own signin_* funnel events (no step_completed), but the
  # step is still named so a failure before or inside it is attributed: the
  # Node gate below used to die with no step, which the funnel recorded as
  # nothing at all (indistinguishable from a run that never got here).
  CURRENT_STEP=signin
  node_ok || die "Node.js 20+ is required for sign-in and MCP authentication. Install it, then re-run:
  curl -fsSL $SETUP_URL | bash"
  do_signin
  CURRENT_STEP=""

  # Landing already resolved inside the CLI's auth flow (existing membership,
  # invite, domain auto-join, verify-email, workspace-full all happen there).
  # `auth login` already printed "Signed in as <email>", and the final handoff
  # prints the workspace topology URL, so we keep the terminal quiet here rather
  # than dumping the full `auth status` table (scopes, tokens, expiry) mid-flow.

  # --- Step: credential_mint (R31) --------------------------------------------
  step_begin credential_mint
  resolve_mcp_key || die "couldn't mint the workspace API key for the MCP. Check that sign-in picked a workspace ($BIN_NAME auth status), then re-run:
  curl -fsSL $SETUP_URL | bash"
  if ! authorize_agents; then
    rollback_mcp_key_replacement
    die "couldn't write the MCP credential into every managed agent config. Local state and managed configs were rolled back; re-run the installer after fixing the warning above."
  fi
  # Managed clients and local state now agree. Commit the replacement before
  # printing the durable notice immediately; an interruption before notice
  # acknowledgement leaves its old immutable ID in config for the next rerun.
  MCP_REPLACEMENT_ARMED=0
  report_retained_mcp_key
  step_done

  printf '\n'
  ok "Signed in — your coding agents are connected to Polylane."
  if [ "$SETUP_OK" != "1" ]; then
    # The key exists but `polylane setup` didn't create the MCP entries it
    # authenticates: point at the recovery path instead of pretending.
    info "Agent configs weren't (fully) wired: run $BIN_NAME setup, then re-run this installer to authenticate them."
  fi

  # --- Step: connect (capture connections right after sign-in) ----------------
  # A real source is mandatory. Fresh server lookups, not connect command exit
  # codes, decide whether the pass landed; declines and unverifiable lookups
  # both repeat the questions instead of falling through to a local path.
  step_begin connect
  while :; do
    connect_stack
    if workspace_has_connection; then
      break
    fi
    printf '\n'
    if [ "$WORKSPACE_CONNECTION_STATE" = "none" ]; then
      info "You must connect at least one source before continuing. Let's try again."
    else
      info "Couldn't verify a connected source. Let's try again."
    fi
  done
  step_done

  if [ "$CONNECTED_ANY" = "1" ]; then
    printf '\n'
    ok "Polylane is already identifying resources and issues across what you connected."
    if [ "$GITHUB_CONNECTED" = "1" ]; then
      info "It's also opening a first pull request in your most active repository."
    fi
  fi

  # --- Done --------------------------------------------------------------------
  printf '\n'

  # PATH export hint first (only when the shell can't find the binary yet), so
  # the actionable ending below stays contiguous.
  if [ "$NEED_PATH_EXPORT" = "1" ] && [ "$PATH_PERSISTED" = "1" ]; then
    path_hint
  elif [ "$NEED_PATH_EXPORT" = "1" ]; then
    printf 'Writing %s failed, so in this shell first run: %s\n\n' "$PATH_RC" "$PATH_LINE"
  fi

  TOPOLOGY_URL=""
  if WORKSPACE_SLUG="$(workspace_slug)"; then
    TOPOLOGY_URL="$CONSOLE_URL/$WORKSPACE_SLUG/topology"
  fi

  info "Uninstall any time: curl -fsSL https://polylane.com/uninstall | sh"

  # The very last act is opening the workspace: by now background discovery
  # has had time to land its first results, so the browser opens onto something
  # worth seeing.
  if [ -n "$TOPOLOGY_URL" ]; then
    printf '\nYour workspace: \033[1m%s\033[0m\n' "$TOPOLOGY_URL"
    leg_begin
    if ask_yn "See what Polylane has identified so far?"; then
      open_url "$TOPOLOGY_URL" || true
      mark browser.opened
    else
      mark browser.declined
    fi
  else
    printf '\nYour workspace: \033[1m%s\033[0m\n' "$CONSOLE_URL"
  fi

  # Closing statement: the install is over but Polylane isn't — point at the
  # next value instead of ending on a bare prompt answer.
  printf '\n'
  if [ "$CLOUD_OK" = "1" ]; then
    info "Polylane keeps working in the background: new issues land in your workspace as they're found."
  else
    info "Polylane keeps working in the background — and it can do a lot more with a cloud account connected: $BIN_NAME cloud connect"
  fi

  FIRST_THREAD_URL=""
  FIRST_THREAD_STATE=""
  if REMEMBERED_THREAD="$(json_field "$CONFIG_DIR/config.json" first_ask_thread_url)"; then
    FIRST_THREAD_URL="$REMEMBERED_THREAD"
    FIRST_THREAD_STATE="remembered"
  else
    if EXISTING_THREAD="$(workspace_first_thread)"; then
      FIRST_THREAD_URL="$EXISTING_THREAD"
      FIRST_THREAD_STATE="team"
      store_first_thread "$FIRST_THREAD_URL"
    else
      ASK_TMP="$(new_tmp)"
      if "$BIN" thread ask "I just connected my stack to Polylane and discovery is still running. As findings land, work out what I should fix first and why." --no-wait --output json --quiet > "$ASK_TMP/thread.json" 2>/dev/null; then
        FIRST_THREAD_URL="$(POLYLANE_JSON_FILE="$ASK_TMP/thread.json" node 2>/dev/null <<'NODE'
const parsed = JSON.parse(require("node:fs").readFileSync(process.env.POLYLANE_JSON_FILE, "utf-8"));
// `thread ask --output json` prints `url` since CLI 0.2.4; older CLIs printed
// the raw API thread, whose link is `_html_url`.
const url = parsed.url ?? parsed._html_url;
if (typeof url !== "string" || !url.startsWith("https://")) process.exit(1);
process.stdout.write(url);
NODE
)" || FIRST_THREAD_URL=""
      fi
      if [ -n "$FIRST_THREAD_URL" ]; then
        FIRST_THREAD_STATE="asked"
        store_first_thread "$FIRST_THREAD_URL"
      fi
    fi
  fi
  case "$FIRST_THREAD_STATE" in
    asked) mark thread.asked ;;
    team) mark thread.team ;;
    remembered) mark thread.remembered ;;
    *) mark thread.none ;;
  esac
  case "$FIRST_THREAD_STATE" in
    asked)
      info "We asked Polylane for you: \"what should I fix first?\" — it's working on it now, and the answer sharpens as discovery lands."
      printf 'The thread: \033[1m%s\033[0m\n' "$FIRST_THREAD_URL"
      if ask_yn "Open the thread in your browser?"; then
        open_url "$FIRST_THREAD_URL" || true
      fi
      ;;
    team)
      info "Your team already asked Polylane what to fix first — here's that thread:"
      printf '  \033[1m%s\033[0m\n' "$FIRST_THREAD_URL"
      ;;
    remembered)
      info "Remember: we already asked Polylane what you should fix first — pick it back up:"
      printf '  \033[1m%s\033[0m\n' "$FIRST_THREAD_URL"
      ;;
    *)
      printf 'Ask anything: \033[1m%s thread ask "what should I fix first?"\033[0m — and invite your team from Settings in the console.\n' "$BIN_NAME"
      ;;
  esac

  # Terminal row: the run reached the end of the script. durationMs is the
  # whole run since the identifier was minted, so the funnel can separate
  # "finished" from "last step we happened to see".
  if [ -n "${RUN_T0:-}" ]; then
    mark install.completed $((($(date +%s) - RUN_T0) * 1000))
  else
    mark install.completed
  fi
}

main "$@"
