#!/usr/bin/env sh
# install.sh — fetch Contentstack Studio's LLM-driven skills into your IDE.
#
# Install (asks which environments to wire up):
#   curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh
#
# The skills are written ONCE into .studio/skills/ and every environment you
# tick is symlinked at that one copy — so `.claude/skills`, `.agents/skills`,
# `.cursor/skills` and the rest all read the same files and update together.
#
# Non-interactive (CI, dotfiles, no TTY) — name the environments up front:
#   curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh -s -- --ide=claude,cursor
#   curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh -s -- --all
#
# Extra destination for any other agent:
#   curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh -s -- --dir=path/to/skills
#
# Move the real copy somewhere else (default .studio/skills):
#   curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh -s -- --canonical=.contentstack/studio-skills
#
# Override source (for testing against a different host, branch, or CDN):
#   STUDIO_SKILLS_BASE_URL=https://studio-documentation.contentstackapps.com \
#   STUDIO_SKILLS_PROMPTS_PATH=prompts \
#     curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh
#
# Password-protected docs host (staging / pre-release): the installer asks for a
# username and password when the host returns 401. To skip the prompt:
#   STUDIO_SKILLS_AUTH=user:password \
#     curl -fsSL -u user:password https://studio-documentation.contentstackapps.com/install.sh | sh
#
# Install from a local checkout (no network — used by Contentstack collaborators):
#   STUDIO_SKILLS_LOCAL_DIR=/path/to/composable-studio-docs/docs/prompts \
#     sh /path/to/composable-studio-docs/install.sh --ide=claude
#
set -eu

# Held in its own variable first: inside `${VAR:-{{X}}}` the expansion's first
# closing brace binds to the placeholder's, leaking a literal `}}` into the URL
# whenever STUDIO_SKILLS_BASE_URL is set on an unsubstituted copy of this script.
BASE_URL_DEFAULT="https://studio-documentation.contentstackapps.com"
BASE_URL="${STUDIO_SKILLS_BASE_URL:-$BASE_URL_DEFAULT}"
PROMPTS_PATH="${STUDIO_SKILLS_PROMPTS_PATH:-prompts}"
LOCAL_DIR="${STUDIO_SKILLS_LOCAL_DIR:-}"

SKILLS_MANIFEST_PATH="${STUDIO_SKILLS_MANIFEST_PATH:-skills/manifest.txt}"
SKILLS=""

# The skill list, baked in when the docs are built. The installer therefore has
# NO hard dependency on any other file existing: not a manifest, not an index
# page. The hosted manifest is still consulted FIRST, so an install.sh a user
# cached months ago still picks up skills added since — but a missing or
# unreachable manifest is a soft miss that falls through to this list, never an
# aborted install.
SKILLS_EMBEDDED='adapt-collection-component
analyze-project-fit
authenticate-cma
author-composition-bindings
author-composition-encoding
author-composition-entry
author-composition-exposed-props
author-composition-migration
author-composition-nested-repeaters
author-composition-pitfalls
author-composition-practice
author-composition-repeater-recipes
author-composition-sections
author-composition-templates
author-composition-thumbnails
author-composition-via-api
author-without-code
build-connected-template
build-freeform-template
build-page-builder-template
build-repeating-section
build-section
build-section-component-choice
build-section-pitfalls
byoc-end-to-end
choose-connected-vs-freeform
classify-existing-component
complete-the-build
compose-marketing-section
configure-csr-vs-ssr
configure-slot-defaults
configure-studio
convert-project-to-studio
decompose-blocks-page
decompose-design
decompose-jsx-to-atomics
decompose-site
deploy-studio-site
design-component-library
design-section-from-jsx
discover-sections
discover-sections-from-ct
drive-studio-canvas-ui
embed-composition
enable-visual-experience
expose-section-props
figma-generate-components
gate-third-party-scripts
import-content
import-design-tokens
install-contentstack-mcp
install-live-preview
install-playwright-mcp
install-studio
match-existing-pattern
migrate-ct-schema
migrate-page-to-studio
pin-entry-to-freeform
pin-query-to-freeform
plan-studio-architecture
provision-studio-project
provision-studio-stack
register-breakpoints
register-component
register-component-cslp
register-component-pitfalls
register-component-prop-contracts
register-component-render-contracts
register-json-rte
render-with-own-data
resolve-reference-depth
resolve-reference-depth-pitfalls
setup-local-https-canvas
setup-section-preview
setup-template-preview-routes
start-here-zero-knowledge
studio
studio-tour
troubleshoot
troubleshoot-canvas
troubleshoot-composition-resolution
troubleshoot-data-binding
troubleshoot-ssr-rendering
understand-authoring-headlessly
understand-auto-binding
understand-bring-your-own-data
understand-canvas-url
understand-canvas-vs-component
understand-contentstack-mcp
understand-installation
understand-linked-schemas
understand-reference-depth
understand-section-slots
understand-sections
understand-templates
upgrade-studio-sdk
use-compositions-api
use-condition-block
use-repeater
use-section-slot
verify-setup
verify-visual-parity
wire-component-default-data
wire-external-data
wire-slot-data
wire-studio-state
wire-variant-alias'
case "$SKILLS_EMBEDDED" in
  # Unsubstituted placeholder — running straight from a checkout, not a build.
  *"{{"*) SKILLS_EMBEDDED="" ;;
esac

# HTTP client: curl or wget, whichever exists. Nothing else is required — no
# jq, no python, no node, no git.
DOWNLOADER=""
if   command -v curl >/dev/null 2>&1; then DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then DOWNLOADER="wget"
fi

# ---------------------------------------------------------------------------
# HTTP basic auth. Staging and pre-release docs hosts sit behind it, so the
# installer asks for a username and password when the host demands them.
#
# Credentials are passed to the client through a 0600 temp file (a netrc for
# curl, a wgetrc for wget) — NEVER on the command line, where `ps` would show
# them to every user on the machine, and never inside the URL, where they would
# end up in shell history and server logs. The file is deleted on exit.
# ---------------------------------------------------------------------------
AUTH_USER="${STUDIO_SKILLS_USER:-}"
AUTH_PASS="${STUDIO_SKILLS_PASSWORD:-}"
# Combined form for CI: STUDIO_SKILLS_AUTH=user:password
if [ -z "$AUTH_USER" ] && [ -n "${STUDIO_SKILLS_AUTH:-}" ]; then
  AUTH_USER="${STUDIO_SKILLS_AUTH%%:*}"
  AUTH_PASS="${STUDIO_SKILLS_AUTH#*:}"
fi
AUTH_FILE=""

cleanup() {
  [ -n "$AUTH_FILE" ] && rm -f "$AUTH_FILE"
  return 0
}
trap 'cleanup' EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM HUP

# Hostname only — no scheme, port, path, or userinfo. netrc matches on host.
auth_host() {
  printf '%s' "$BASE_URL" | sed -e 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's#^.*@##' -e 's#:[0-9]*$##'
}

write_auth_file() {
  if [ -z "$AUTH_FILE" ]; then
    AUTH_FILE="$(mktemp "${TMPDIR:-/tmp}/studio-skills-auth.XXXXXX" 2>/dev/null || echo "")"
    if [ -z "$AUTH_FILE" ]; then
      AUTH_FILE="${TMPDIR:-/tmp}/studio-skills-auth.$$"
      ( umask 077; : > "$AUTH_FILE" )
    fi
  fi
  chmod 600 "$AUTH_FILE" 2>/dev/null || true
  case "$DOWNLOADER" in
    curl) printf 'machine %s login %s password %s\n' "$(auth_host)" "$AUTH_USER" "$AUTH_PASS" > "$AUTH_FILE" ;;
    wget) printf 'user=%s\npassword=%s\n' "$AUTH_USER" "$AUTH_PASS" > "$AUTH_FILE" ;;
  esac
  return 0
}

# --netrc-optional / no WGETRC override: an existing ~/.netrc entry for the host
# is honoured, so a developer who already has one is never prompted.
fetch_to() {
  case "$DOWNLOADER" in
    curl)
      if [ -n "$AUTH_FILE" ]; then curl -fsSL --netrc-file "$AUTH_FILE" "$1" -o "$2"
      else                         curl -fsSL --netrc-optional "$1" -o "$2"; fi ;;
    wget)
      if [ -n "$AUTH_FILE" ]; then WGETRC="$AUTH_FILE" wget -q -O "$2" "$1"
      else                         wget -q -O "$2" "$1"; fi ;;
    *) return 1 ;;
  esac
}

fetch_stdout() {
  case "$DOWNLOADER" in
    curl)
      if [ -n "$AUTH_FILE" ]; then curl -fsSL --netrc-file "$AUTH_FILE" "$1"
      else                         curl -fsSL --netrc-optional "$1"; fi ;;
    wget)
      if [ -n "$AUTH_FILE" ]; then WGETRC="$AUTH_FILE" wget -q -O - "$1"
      else                         wget -q -O - "$1"; fi ;;
    *) return 1 ;;
  esac
}

# HTTP status only — "000" when the host could not be reached at all.
http_status() {
  case "$DOWNLOADER" in
    curl)
      if [ -n "$AUTH_FILE" ]; then
        curl -sS -o /dev/null -w '%{http_code}' --netrc-file "$AUTH_FILE" "$1" 2>/dev/null || echo "000"
      else
        curl -sS -o /dev/null -w '%{http_code}' --netrc-optional "$1" 2>/dev/null || echo "000"
      fi ;;
    wget)
      if [ -n "$AUTH_FILE" ]; then _hs=$(WGETRC="$AUTH_FILE" wget --spider -S "$1" 2>&1 || true)
      else                         _hs=$(wget --spider -S "$1" 2>&1 || true); fi
      # Anchor on the status line: a bare /HTTP\// also matches the Server
      # header ("Server: SimpleHTTP/0.6 …"), which silently became the "status".
      # Last match wins, so a redirect chain reports its destination.
      printf '%s\n' "$_hs" \
        | awk '/^[ \t]*HTTP\/[0-9.]+[ \t]+[0-9][0-9][0-9]/ { c = $2 } END { print (c == "" ? "000" : c) }' ;;
    *) echo "000" ;;
  esac
}

prompt_for_credentials() {
  if [ -n "$AUTH_USER" ] && [ -n "$AUTH_PASS" ]; then return 0; fi
  if ! have_tty; then
    echo "$BASE_URL is password-protected (HTTP basic auth) and there is no TTY to ask on." >&2
    echo "Supply the credentials non-interactively, either:" >&2
    echo "  STUDIO_SKILLS_USER=<user> STUDIO_SKILLS_PASSWORD=<password> sh install.sh" >&2
    echo "  STUDIO_SKILLS_AUTH=<user>:<password> sh install.sh" >&2
    echo "or add a 'machine $(auth_host) login … password …' line to ~/.netrc." >&2
    exit 1
  fi
  {
    echo ""
    echo "$BASE_URL is password-protected (HTTP basic auth)."
  } > /dev/tty
  printf 'Username: ' > /dev/tty
  IFS= read -r AUTH_USER < /dev/tty
  # Echo off BEFORE the prompt is printed, not after: anything typed or pasted
  # in the window between the two would otherwise be echoed in the clear. The
  # terminal is restored even if the user interrupts at the prompt.
  _stty_saved=$(stty -g < /dev/tty 2>/dev/null || echo "")
  _echo_off="no"
  if [ -n "$_stty_saved" ]; then
    trap 'stty "$_stty_saved" < /dev/tty 2>/dev/null; cleanup; exit 130' INT
    if stty -echo < /dev/tty 2>/dev/null; then _echo_off="yes"; fi
  fi
  if [ "$_echo_off" = "yes" ]; then
    printf 'Password: ' > /dev/tty
  else
    # Say so rather than letting the user assume it is hidden.
    printf 'Password (WARNING: will be visible — terminal echo could not be disabled): ' > /dev/tty
  fi
  IFS= read -r AUTH_PASS < /dev/tty
  if [ -n "$_stty_saved" ]; then
    stty "$_stty_saved" < /dev/tty 2>/dev/null || true
    trap 'cleanup; exit 130' INT
  fi
  printf '\n' > /dev/tty
  return 0
}

# Settle auth BEFORE anything is fetched. A 401 on the manifest is otherwise
# indistinguishable from "no manifest here", so the install would fall through
# to the built-in list and then fail on all 84 skill downloads instead of
# saying the one useful thing: this host wants a password.
ensure_remote_access() {
  _st=$(http_status "$BASE_URL/$SKILLS_MANIFEST_PATH")
  case "$_st" in 401|403|407) ;; *) return 0 ;; esac

  # Environment or ~/.netrc credentials may already be enough.
  if [ -n "$AUTH_USER" ] && [ -n "$AUTH_PASS" ]; then
    write_auth_file
    _st=$(http_status "$BASE_URL/$SKILLS_MANIFEST_PATH")
    case "$_st" in
      401|403|407)
        echo "Credentials from the environment were rejected by $BASE_URL (HTTP $_st)." >&2
        AUTH_USER=""; AUTH_PASS=""
        ;;
      *) return 0 ;;
    esac
  fi

  _tries=0
  while [ "$_tries" -lt 3 ]; do
    _tries=$((_tries + 1))
    prompt_for_credentials
    write_auth_file
    _st=$(http_status "$BASE_URL/$SKILLS_MANIFEST_PATH")
    case "$_st" in
      401|403|407)
        AUTH_USER=""; AUTH_PASS=""
        [ "$_tries" -lt 3 ] && echo "  ✗ rejected (HTTP $_st) — try again." > /dev/tty
        ;;
      *) return 0 ;;
    esac
  done
  echo "Could not authenticate to $BASE_URL after 3 attempts." >&2
  exit 1
}

# A `~` reaches us literal: the shell expands it only at the start of a word, so
# `--dir=~/.claude/skills` arrives as-is and would create a directory actually
# named `~` under the current project. Expand it ourselves.
expand_tilde() {
  case "$1" in
    "~")   printf '%s' "$HOME" ;;
    "~/"*) printf '%s/%s' "$HOME" "${1#\~/}" ;;
    *)     printf '%s' "$1" ;;
  esac
}

# The one real copy. Every selected environment becomes a symlink at this dir,
# so there is a single place to update and no per-IDE drift.
CANON="${STUDIO_SKILLS_CANONICAL_DIR:-.studio/skills}"

EXTRA_DIR=""
EXTRA_LAYOUT="md"
SELECTED=""
ASSUME_YES="no"

# ---------------------------------------------------------------------------
# Environment table. Adding an environment is one row in each of these four.
# `layout` is the on-disk shape the tool reads: nested = <skill>/SKILL.md,
# md/mdc = flat <skill>.<ext>. See docs/adr/0003-*.md.
# ---------------------------------------------------------------------------
ENV_IDS="claude codex gemini cursor windsurf cline continue"

env_label() {
  case "$1" in
    claude)   echo "Claude Code" ;;
    codex)    echo "Codex CLI"   ;;
    gemini)   echo "Gemini CLI"  ;;
    cursor)   echo "Cursor"      ;;
    windsurf) echo "Windsurf"    ;;
    cline)    echo "Cline"       ;;
    continue) echo "Continue"    ;;
    *)        echo "$1"          ;;
  esac
}

# `.agents/skills` is the cross-agent Agent Skills convention, not a Codex-only
# path: Codex, Gemini CLI, Cursor, Amp, opencode and Goose all read it, so this
# one row covers every one of them. Codex is the label because it is the tool
# whose own documentation names that directory first.
env_dir() {
  case "$1" in
    claude)   echo ".claude/skills"    ;;
    codex)    echo ".agents/skills"    ;;
    gemini)   echo ".gemini/skills"    ;;
    cursor)   echo ".cursor/skills"    ;;
    windsurf) echo ".windsurf/rules"   ;;
    cline)    echo ".clinerules"       ;;
    continue) echo ".continue/prompts" ;;
    *)        echo ""                  ;;
  esac
}

# Claude Code, Codex, Gemini CLI and Cursor discover skills by scanning
# subdirectories for SKILL.md, so a flat <skill>.md is never loaded there.
# Windsurf, Cline and Continue read flat .md instead.
# Cursor moved to nested skills: `.cursor/skills/<name>/SKILL.md` is our exact
# file shape, so Cursor surfaces each skill by name and description the way
# Claude Code does. The old `.cursor/rules/*.mdc` placement could not do that —
# a rule with neither `alwaysApply`, `description`, nor `globs` in its
# frontmatter is "Apply Manually" only, and our specialists carry `name:` alone.
env_layout() {
  case "$1" in
    claude|codex|gemini|cursor) echo "nested" ;;
    *)                          echo "md"     ;;
  esac
}

env_detected() {
  case "$1" in
    claude)   [ -d ".claude"     ] ;;
    # A bare AGENTS.md is too common across tools to imply Codex; .codex/ and
    # .agents/ are the specific signals.
    codex)    [ -d ".codex" ] || [ -d ".agents" ] ;;
    gemini)   [ -d ".gemini"     ] ;;
    cursor)   [ -d ".cursor"     ] ;;
    windsurf) [ -d ".windsurf"   ] ;;
    cline)    [ -d ".clinerules" ] ;;
    continue) [ -d ".continue"   ] ;;
    *)        return 1             ;;
  esac
}

# ---------------------------------------------------------------------------
# Selection set — a space-delimited string, POSIX sh has no arrays.
# ---------------------------------------------------------------------------
is_selected() {
  case " $SELECTED " in *" $1 "*) return 0 ;; esac
  return 1
}

select_env() {
  is_selected "$1" || SELECTED="$SELECTED $1"
}

deselect_env() {
  new=""
  for e in $SELECTED; do
    [ "$e" = "$1" ] || new="$new $e"
  done
  SELECTED="$new"
}

toggle_env() {
  if is_selected "$1"; then deselect_env "$1"; else select_env "$1"; fi
}

env_by_index() {
  i=0
  for e in $ENV_IDS; do
    i=$((i + 1))
    if [ "$i" = "$1" ]; then echo "$e"; return 0; fi
  done
  return 1
}

for arg in "$@"; do
  case "$arg" in
    --ide=*)
      for want in $(echo "${arg#--ide=}" | tr ',' ' '); do
        if [ -n "$(env_dir "$want")" ]; then
          select_env "$want"
        else
          echo "Unknown IDE: $want. Supported: $ENV_IDS (or use --dir=<path>)." >&2
          exit 1
        fi
      done
      ASSUME_YES="yes"
      ;;
    --all)
      SELECTED="$ENV_IDS"
      ASSUME_YES="yes"
      ;;
    --dir=*)
      EXTRA_DIR="${arg#--dir=}"
      ASSUME_YES="yes"
      ;;
    --layout=*)
      EXTRA_LAYOUT="${arg#--layout=}"
      case "$EXTRA_LAYOUT" in
        nested|md|mdc) ;;
        *) echo "Unknown --layout: $EXTRA_LAYOUT. Use nested, md, or mdc." >&2; exit 1 ;;
      esac
      ;;
    --canonical=*) CANON="${arg#--canonical=}" ;;
    -y|--yes)      ASSUME_YES="yes" ;;
    -h|--help)
      cat <<EOF
Usage: install.sh [--ide=<a,b>] [--all] [--dir=<path> [--layout=<shape>]]
                  [--canonical=<path>] [--yes]

Run with no flags to be asked which environments to wire up.

Environments: claude | codex | gemini | cursor | windsurf | cline | continue

  codex writes .agents/skills — the shared Agent Skills directory that Codex,
  Gemini CLI, Cursor, Amp, opencode and Goose all read.

  --ide=claude,cursor   Skip the prompt and wire these up
  --all                 Wire up every supported environment
  --dir=<path>          Also link an arbitrary directory (any other agent)
  --layout=<shape>      Shape for --dir: nested (<skill>/SKILL.md), md, mdc
                        (default: md)
  --canonical=<path>    Where the real files go (default: .studio/skills)
  --yes                 Never prompt; use detected environments

The real files are written once to --canonical; each environment is a symlink
at that directory, so all of them update together.

Override source:
  STUDIO_SKILLS_BASE_URL         (default: https://studio-documentation.contentstackapps.com)
  STUDIO_SKILLS_PROMPTS_PATH     (default: prompts)
  STUDIO_SKILLS_CANONICAL_DIR    (default: .studio/skills)
  STUDIO_SKILLS_LOCAL_DIR        (path to a local prompts/ dir — skips HTTP)

Password-protected docs host (HTTP basic auth). Asked for interactively when
the host returns 401; supply up front to skip the prompt:
  STUDIO_SKILLS_USER, STUDIO_SKILLS_PASSWORD
  STUDIO_SKILLS_AUTH=<user>:<password>
An existing ~/.netrc entry for the host is used automatically.
EOF
      exit 0
      ;;
    *)
      echo "Unknown argument: $arg (try --help)." >&2
      exit 1
      ;;
  esac
done

# After parsing, so a leading `~/` is expanded whichever way the path arrived —
# `--canonical=`/`--dir=` or STUDIO_SKILLS_CANONICAL_DIR.
CANON=$(expand_tilde "$CANON")
EXTRA_DIR=$(expand_tilde "$EXTRA_DIR")

# ---------------------------------------------------------------------------
# Interactive selection.
#
# Under `curl … | sh` stdin IS the script, so `read` must come from /dev/tty or
# it silently eats the rest of the installer. No TTY (CI, Docker build) falls
# back to whatever is detected on disk.
# ---------------------------------------------------------------------------
# `[ -r /dev/tty ]` only reads the device node's permission bits and passes in
# CI/containers where OPENING it fails with "Device not configured". Actually
# open it for read and write, in a subshell so no descriptor leaks.
have_tty() {
  ( exec 3< /dev/tty && exec 4> /dev/tty ) 2>/dev/null
}

preselect_detected() {
  for e in $ENV_IDS; do
    env_detected "$e" && select_env "$e"
  done
  # An undetected last environment leaves status 1, and `set -e` kills the
  # caller on a FUNCTION that returns non-zero (unlike an inline && list).
  return 0
}

ENV_COUNT=0
for _e in $ENV_IDS; do ENV_COUNT=$((ENV_COUNT + 1)); done

# ── Arrow-key checkbox ─────────────────────────────────────────────────────
# Raw terminal mode so arrows and space register on their own keypress, no
# Enter. Needs `stty` raw support plus `dd`/`od`; where any of that is missing
# (dumb terminals, some CI ptys) the numbered prompt below takes over, so the
# installer never becomes unusable for the sake of a nicer menu.
ARROW_UI="no"
if [ -n "$(command -v dd 2>/dev/null)" ] && [ -n "$(command -v od 2>/dev/null)" ]; then
  ARROW_UI="maybe"
fi

MENU_LINES=0
CURSOR=1

# One keypress → a word. Arrows arrive as ESC [ A / ESC [ B, so an ESC is
# followed by a short non-blocking read: a lone Escape must not hang the menu.
read_key() {
  _b=$(dd bs=1 count=1 2>/dev/null < /dev/tty | od -An -tu1 | tr -d ' \n')
  case "$_b" in
    "")      echo "eof"   ;;
    10|13)   echo "enter" ;;
    32)      echo "space" ;;
    113|81)  echo "quit"  ;;   # q Q
    97|65)   echo "all"   ;;   # a A
    110|78)  echo "none"  ;;   # n N
    107)     echo "up"    ;;   # k
    106)     echo "down"  ;;   # j
    27)
      stty min 0 time 1 < /dev/tty 2>/dev/null || true
      _seq=$(dd bs=1 count=2 2>/dev/null < /dev/tty | od -An -tu1 | tr -s ' ' ' ' | sed -e 's/^ //' -e 's/ $//' | tr '\n' ' ' | tr -s ' ' | sed 's/ $//')
      stty min 1 time 0 < /dev/tty 2>/dev/null || true
      case "$_seq" in
        "91 65") echo "up"   ;;
        "91 66") echo "down" ;;
        *)       echo "other" ;;
      esac ;;
    *) echo "other" ;;
  esac
}

# Redraw in place: jump back over the block printed last time, so the menu
# updates rather than scrolling a new copy per keystroke.
render_menu_arrows() {
  {
    [ "$MENU_LINES" -gt 0 ] && printf '\033[%dA' "$MENU_LINES"
    printf '\033[K\n'
    printf '\033[KWhich environments should read the Studio skills?\n'
    printf '\033[K\n'
    i=0
    for e in $ENV_IDS; do
      i=$((i + 1))
      mark=" "
      is_selected "$e" && mark="x"
      note=""
      env_detected "$e" && note="  (detected)"
      if [ "$i" = "$CURSOR" ]; then pointer="❯"; else pointer=" "; fi
      printf '\033[K %s [%s] %-12s %-19s%s\n' "$pointer" "$mark" "$(env_label "$e")" "$(env_dir "$e")" "$note"
    done
    printf '\033[K\n'
    printf '\033[K  ↑/↓ move · space toggle · a all · n none · enter install · q quit\n'
  } > /dev/tty
  MENU_LINES=$((ENV_COUNT + 5))
}

prompt_for_envs_arrows() {
  _saved_menu=$(stty -g < /dev/tty 2>/dev/null || echo "")
  [ -n "$_saved_menu" ] || return 1
  stty -icanon -echo min 1 time 0 < /dev/tty 2>/dev/null || return 1

  # Restore the terminal — and the cursor — on any exit path, or the user is
  # left in a shell with no echo after a Ctrl-C.
  trap 'printf "\033[?25h" > /dev/tty 2>/dev/null; stty "$_saved_menu" < /dev/tty 2>/dev/null; cleanup; exit 130' INT
  trap 'printf "\033[?25h" > /dev/tty 2>/dev/null; stty "$_saved_menu" < /dev/tty 2>/dev/null; cleanup' EXIT
  printf '\033[?25l' > /dev/tty 2>/dev/null || true

  MENU_LINES=0
  CURSOR=1
  _result=0
  while :; do
    render_menu_arrows
    case "$(read_key)" in
      up)
        if [ "$CURSOR" -gt 1 ]; then CURSOR=$((CURSOR - 1)); else CURSOR="$ENV_COUNT"; fi ;;
      down)
        if [ "$CURSOR" -lt "$ENV_COUNT" ]; then CURSOR=$((CURSOR + 1)); else CURSOR=1; fi ;;
      space)
        toggle_env "$(env_by_index "$CURSOR")" ;;
      all)  SELECTED="$ENV_IDS" ;;
      none) SELECTED="" ;;
      enter)
        if [ -n "$(echo "$SELECTED" | tr -d ' ')" ]; then break; fi ;;
      quit) _result=2; break ;;
      eof)  _result=1; break ;;
    esac
  done

  printf '\033[?25h' > /dev/tty 2>/dev/null || true
  stty "$_saved_menu" < /dev/tty 2>/dev/null || true
  trap 'cleanup' EXIT
  trap 'cleanup; exit 130' INT

  if [ "$_result" = "2" ]; then
    echo "Aborted — nothing installed." >&2
    exit 1
  fi
  return "$_result"
}

render_menu() {
  {
    echo ""
    echo "Which environments should read the Studio skills?"
    echo ""
    i=0
    for e in $ENV_IDS; do
      i=$((i + 1))
      mark=" "
      is_selected "$e" && mark="x"
      note=""
      env_detected "$e" && note="  (detected)"
      printf '  [%s] %d) %-12s %-19s%s\n' "$mark" "$i" "$(env_label "$e")" "$(env_dir "$e")" "$note"
    done
    echo ""
  } > /dev/tty
}

prompt_for_envs() {
  preselect_detected
  if [ "$ARROW_UI" = "maybe" ] && prompt_for_envs_arrows; then
    return 0
  fi
  # Raw mode unavailable, or the key reader hit EOF — fall back to typing numbers.
  prompt_for_envs_numbered
}

prompt_for_envs_numbered() {
  while :; do
    render_menu
    printf 'Toggle by number ("1 3"), a=all, n=none, Enter=install ticked, q=quit: ' > /dev/tty
    IFS= read -r reply < /dev/tty || reply=""
    case "$reply" in
      "")
        if [ -z "$(echo "$SELECTED" | tr -d ' ')" ]; then
          echo "Nothing ticked — pick at least one, or q to quit." > /dev/tty
        else
          break
        fi
        ;;
      q|Q) echo "Aborted — nothing installed." >&2; exit 1 ;;
      a|A) SELECTED="$ENV_IDS" ;;
      n|N) SELECTED="" ;;
      *)
        for tok in $(echo "$reply" | tr ',' ' '); do
          e=$(env_by_index "$tok" 2>/dev/null || echo "")
          if [ -n "$e" ]; then
            toggle_env "$e"
          else
            echo "Not a choice: $tok" > /dev/tty
          fi
        done
        ;;
    esac
  done
}

# Reachability and credentials come BEFORE the environment prompt. Asking a user
# to tick five checkboxes and only then telling them the host wants a password
# they do not have wastes the only work they did.
if [ -z "$LOCAL_DIR" ]; then
  if [ -z "$DOWNLOADER" ]; then
    echo "Neither curl nor wget is installed, so nothing can be fetched." >&2
    echo "Install either one, or point at a checkout with STUDIO_SKILLS_LOCAL_DIR." >&2
    exit 1
  fi
  ensure_remote_access
fi

if [ -z "$(echo "$SELECTED" | tr -d ' ')" ] && [ -z "$EXTRA_DIR" ]; then
  if [ "$ASSUME_YES" = "no" ] && have_tty; then
    prompt_for_envs
  else
    preselect_detected
    if [ -z "$(echo "$SELECTED" | tr -d ' ')" ]; then
      echo "No environment selected and none detected (no TTY to ask on)." >&2
      echo "Re-run with --ide=<$(echo "$ENV_IDS" | tr ' ' ',')>, --all, or --dir=<path>." >&2
      exit 1
    fi
    echo "Detected: $(for e in $SELECTED; do printf '%s ' "$(env_label "$e")"; done)"
  fi
fi

# ---------------------------------------------------------------------------
# Populate the canonical copy — nested <skill>/SKILL.md, the richest layout,
# which every flat environment can be projected out of by symlink.
# ---------------------------------------------------------------------------
resolve_skills_local() {
  manifest="$LOCAL_DIR/../$SKILLS_MANIFEST_PATH"
  if [ -f "$manifest" ]; then
    SKILLS="$(cat "$manifest")"
  else
    # index.md is the landing page and skills-index.md is the router's lookup
    # table — neither is a skill, and installing them creates bogus entries.
    SKILLS="$(cd "$LOCAL_DIR" && ls *.md 2>/dev/null | grep -v '^index\.md$' | grep -v '^skills-index\.md$' | sed 's/\.md$//')"
  fi
  # The checkout's own listing is ground truth here; the baked-in list is only
  # a backstop for a directory we could not read.
  [ -n "$SKILLS" ] || SKILLS="$SKILLS_EMBEDDED"
}

resolve_skills_remote() {
  manifest_url="$BASE_URL/$SKILLS_MANIFEST_PATH"
  SKILLS="$(fetch_stdout "$manifest_url" 2>/dev/null || echo "")"
  [ -n "$SKILLS" ] && return 0

  if [ -n "$SKILLS_EMBEDDED" ]; then
    SKILLS="$SKILLS_EMBEDDED"
    echo "  (no manifest at $manifest_url — using this installer's built-in list)"
    return 0
  fi

  # Only reachable by running an UNBUILT install.sh with no network: no baked-in
  # list, no manifest. Point at the checkout that must already be on disk.
  echo "No skill list available: $manifest_url is unreachable and this copy of" >&2
  echo "install.sh has no built-in list (it was run from a checkout, not a build)." >&2
  echo "Install from the checkout instead:" >&2
  echo "  STUDIO_SKILLS_LOCAL_DIR=<checkout>/docs/prompts sh install.sh" >&2
  exit 1
}

mkdir -p "$CANON"

ok=0
fail=0
total=0

if [ -n "$LOCAL_DIR" ]; then
  # Local mode — copy from a checkout. No network.
  if [ ! -d "$LOCAL_DIR" ]; then
    echo "STUDIO_SKILLS_LOCAL_DIR is set but not a directory: $LOCAL_DIR" >&2
    exit 1
  fi
  resolve_skills_local
  echo "Installing Studio skills from $LOCAL_DIR → $CANON"
  for skill in $SKILLS; do
    total=$((total + 1))
    src="$LOCAL_DIR/$skill.md"
    dest="$CANON/$skill/SKILL.md"
    mkdir -p "$CANON/$skill"
    if [ -f "$src" ] && cp "$src" "$dest"; then
      ok=$((ok + 1))
    else
      echo "  ✗ missing $skill.md in $LOCAL_DIR" >&2
      fail=$((fail + 1))
    fi
  done
else
  # Remote mode — reachability and credentials are already settled above.
  resolve_skills_remote
  echo "Installing Studio skills from $BASE_URL/$PROMPTS_PATH → $CANON"
  for skill in $SKILLS; do
    total=$((total + 1))
    url="$BASE_URL/$PROMPTS_PATH/$skill.md"
    dest="$CANON/$skill/SKILL.md"
    mkdir -p "$CANON/$skill"
    if fetch_to "$url" "$dest"; then
      ok=$((ok + 1))
    else
      echo "  ✗ failed to fetch $skill" >&2
      # Both clients leave a 0-byte file on failure; without removing it the
      # directory survives rmdir and the skill looks installed but is empty.
      rm -f "$dest"
      rmdir "$CANON/$skill" 2>/dev/null || true
      fail=$((fail + 1))
    fi
  done
fi

# Ship skills-index.md where the `studio` router actually looks for it. The router
# reads it on every Studio turn to route to the right specialist; without it the
# routing collapses and only the router skill is reachable.
#
# The router resolves the index relative to its OWN file, trying ./skills-index.md
# then ../studio/skills-index.md. In the nested canonical layout the router lives
# at $CANON/studio/SKILL.md, so both of those are $CANON/studio/skills-index.md.
index_dest="$CANON/studio/skills-index.md"
mkdir -p "$CANON/studio"
# Earlier installs listed skills-index in the manifest, producing a bogus skill
# that Claude Code loads as one, plus a flat copy at the wrong path. Remove both.
[ -d "$CANON/skills-index" ] && rm -rf "$CANON/skills-index" && echo "  removed bogus skill: skills-index/"
[ -f "$CANON/skills-index.md" ] && rm -f "$CANON/skills-index.md" && echo "  removed legacy file: skills-index.md"

if [ -n "$LOCAL_DIR" ]; then
  index_src="$LOCAL_DIR/../skills-index.md"
  [ -f "$index_src" ] || index_src="$LOCAL_DIR/skills-index.md"
  if [ -f "$index_src" ] && cp "$index_src" "$index_dest"; then
    echo "  ✓ installed skills-index.md"
  else
    echo "  ⚠ skills-index.md not found in $LOCAL_DIR — router will have nothing to route with" >&2
  fi
else
  index_url="$BASE_URL/$PROMPTS_PATH/skills-index.md"
  curl_ok=$(fetch_to "$index_url" "$index_dest" && echo yes || echo no)
  if [ "$curl_ok" = "yes" ]; then
    echo "  ✓ installed skills-index.md"
  else
    echo "  ⚠ failed to fetch skills-index.md from $index_url — router will have nothing to route with" >&2
  fi
fi

# AGENTS.md lives INSIDE the skills directory, never at the workspace root —
# the root one is the user's own file and is not ours to write. Generated rather
# than fetched so the pack is self-describing even on an older docs deploy.
cat > "$CANON/AGENTS.md" <<AGENTS_MD
# Contentstack Studio skills

Installed by \`install.sh\` from ${LOCAL_DIR:-$BASE_URL}. ${ok} skills.

**Do not hand-edit anything in this directory** — re-running the installer
overwrites it. Every environment wired up (\`.claude/skills\`, \`.agents/skills\`, …)
is a symlink at this one copy, so a change here shows up in all of them.

## How to use these

Start at \`studio/SKILL.md\` — the always-on router. It reads
\`studio/skills-index.md\` on every Studio turn and pulls the one specialist skill
that matches the task. Route through it rather than guessing a skill name.

Each \`<skill>/SKILL.md\` is a self-contained instruction set with frontmatter
declaring when it applies.

## Refresh

    curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh
AGENTS_MD
echo "  ✓ wrote AGENTS.md"

# ---------------------------------------------------------------------------
# Fan out: symlink every selected environment at the canonical copy.
# ---------------------------------------------------------------------------

# Segments in a relative path, e.g. ".cursor/skills" → 2.
path_depth() {
  printf '%s' "$1" | tr '/' '\n' | grep -v '^$' | grep -cv '^\.$' || true
}

# Symlink target for a link that lives inside directory $1 and points at the
# workspace-relative path $2. Relative where possible so the workspace stays
# portable; absolute when either side is.
link_target() {
  case "$2" in /*) printf '%s' "$2"; return 0 ;; esac
  case "$1" in
    /*) printf '%s/%s' "$PWD" "$2"; return 0 ;;
  esac
  d=$(path_depth "$1")
  prefix=""
  i=0
  while [ "$i" -lt "$d" ]; do
    prefix="../$prefix"
    i=$((i + 1))
  done
  printf '%s%s' "$prefix" "$2"
}

# Replace one link path. Only ever clears a symlink, or a directory whose sole
# content is SKILL.md (this installer's own older output) — anything else the
# user authored is left alone and reported.
replaced=0

# Symlinks are not universal: Windows shells without Developer Mode and
# FAT/exFAT volumes refuse them. Probed once, degrading to copies — the same
# files land, they just stop sharing one source of truth.
LINK_MODE="symlink"

probe_link_mode() {
  _probe="$CANON/.studio-symlink-probe"
  rm -f "$_probe" 2>/dev/null || true
  if ln -s "AGENTS.md" "$_probe" 2>/dev/null && [ -e "$_probe" ]; then
    rm -f "$_probe"
  else
    rm -f "$_probe" 2>/dev/null || true
    LINK_MODE="copy"
    echo ""
    echo "  ⚠ This filesystem does not support symlinks — installing copies instead."
    echo "    Everything still works; re-run the installer to refresh every environment."
  fi
  return 0
}

# $2 is the real, workspace-relative source. The relative link target is
# derived from the link's own parent directory, so callers cannot get the
# ../ depth wrong.
place_link() {
  _path="$1"
  _src="$2"
  if [ -L "$_path" ]; then
    rm -f "$_path"
  elif [ -d "$_path" ]; then
    # A folder holding nothing but SKILL.md is either a previous install of
    # ours or a same-named skill of the user's — indistinguishable without a
    # receipt. Studio's copy wins (as it always has), but say so.
    if [ "$(ls -A "$_path" 2>/dev/null)" = "SKILL.md" ]; then
      rm -rf "$_path"
      replaced=$((replaced + 1))
    else
      echo "  ⚠ kept your own $_path (not a Studio skill) — skipped" >&2
      return 1
    fi
  elif [ -e "$_path" ]; then
    rm -f "$_path"
    replaced=$((replaced + 1))
  fi
  if [ "$LINK_MODE" = "symlink" ]; then
    ln -s "$(link_target "$(dirname "$_path")" "$_src")" "$_path"
  else
    cp -R "$_src" "$_path"
  fi
}

# A previous version of this installer wrote flat <skill>.md/.mdc files. Under a
# nested layout those are never loaded, so they linger as stale clutter that
# reads exactly like a working skill. Only ever removes this script's own output
# shape, never a folder.
# A previous version installed Cursor as .cursor/rules/<skill>.mdc. Those links
# still resolve, so nothing looks wrong — they just sit in a directory Cursor
# now reads skills from elsewhere. Remove only our own output there.
prune_legacy_cursor_rules() {
  [ -d ".cursor/rules" ] || return 0
  _removed=0
  for skill in $SKILLS; do
    _stale=".cursor/rules/$skill.mdc"
    if [ -L "$_stale" ] || [ -f "$_stale" ]; then
      rm -f "$_stale"
      _removed=$((_removed + 1))
    fi
  done
  for _extra in skills-index.md AGENTS.md; do
    _stale=".cursor/rules/$_extra"
    [ -L "$_stale" ] && rm -f "$_stale" && _removed=$((_removed + 1))
  done
  if [ "$_removed" -gt 0 ]; then
    echo "    migrated $_removed file(s) out of .cursor/rules (Cursor reads .cursor/skills now)"
    rmdir ".cursor/rules" 2>/dev/null || true
  fi
  return 0
}

prune_legacy_flat() {
  for _ext in md mdc; do
    _legacy="$1/$2.$_ext"
    if [ -f "$_legacy" ] || [ -L "$_legacy" ]; then
      rm -f "$_legacy"
      echo "    removed stale flat file: $2.$_ext"
    fi
  done
}

# Per-skill links into an existing directory, preserving whatever else is in it.
link_each_skill() {
  _dir="$1"
  _layout="$2"
  mkdir -p "$_dir"
  _n=0
  for skill in $SKILLS; do
    [ -d "$CANON/$skill" ] || continue
    case "$_layout" in
      nested)
        _lp="$_dir/$skill"
        _src="$CANON/$skill"
        prune_legacy_flat "$_dir" "$skill"
        ;;
      *)
        _lp="$_dir/$skill.$_layout"
        _src="$CANON/$skill/SKILL.md"
        ;;
    esac
    if place_link "$_lp" "$_src"; then _n=$((_n + 1)); fi
  done

  # Flat layouts have no studio/ folder to carry the index, so link it beside
  # the skills where the router's ./skills-index.md probe finds it.
  if [ "$_layout" != "nested" ] && [ -f "$index_dest" ]; then
    place_link "$_dir/skills-index.md" "$index_dest" || true
  fi
  place_link "$_dir/AGENTS.md" "$CANON/AGENTS.md" || true

  if [ "$LINK_MODE" = "symlink" ]; then
    echo "  ✓ $_dir → $CANON ($_n skills, per-skill symlinks)"
  else
    echo "  ✓ $_dir ($_n skills, copied from $CANON)"
  fi
  if [ "$replaced" -gt 0 ]; then
    echo "    replaced $replaced existing file(s) — on a name clash Studio's copy wins"
    replaced=0
  fi
}

# A whole-directory symlink is the cleanest result, but only when there is
# nothing of the user's to lose: no dir, an empty one, or our own link.
dir_is_ours() {
  [ ! -e "$1" ] && return 0
  [ -L "$1" ] && return 0
  [ -d "$1" ] && [ -z "$(ls -A "$1" 2>/dev/null)" ] && return 0
  return 1
}

link_environment() {
  _dir="$1"
  _layout="$2"

  # Same shape as the canonical copy → one symlink covers skills, the router's
  # index, and AGENTS.md in a single hop.
  if [ "$_layout" = "nested" ] && dir_is_ours "$_dir"; then
    _parent=$(dirname "$_dir")
    [ "$_parent" = "." ] || mkdir -p "$_parent"
    [ -L "$_dir" ] && rm -f "$_dir"
    [ -d "$_dir" ] && rmdir "$_dir" 2>/dev/null
    # link_target wants the directory the link SITS IN, which for a whole-dir
    # link is the parent, not $_dir itself.
    if [ "$LINK_MODE" = "symlink" ]; then
      ln -s "$(link_target "$_parent" "$CANON")" "$_dir"
      echo "  ✓ $_dir → $CANON (directory symlink)"
    else
      cp -R "$CANON" "$_dir"
      echo "  ✓ $_dir (copied from $CANON)"
    fi
    return 0
  fi

  link_each_skill "$_dir" "$_layout"
}

if [ "$fail" -lt "$total" ]; then
  probe_link_mode
  echo ""
  if [ "$LINK_MODE" = "symlink" ]; then
    echo "Linking environments:"
  else
    echo "Installing into each environment:"
  fi
  for e in $SELECTED; do
    d=$(env_dir "$e")
    # A dir symlink would make the canonical copy its own parent.
    if [ "$d" = "$CANON" ]; then
      echo "  ✓ $(env_label "$e") already reads $CANON directly"
      continue
    fi
    [ "$e" = "cursor" ] && prune_legacy_cursor_rules
    link_environment "$d" "$(env_layout "$e")"
  done
  if [ -n "$EXTRA_DIR" ]; then
    if [ "$EXTRA_DIR" = "$CANON" ]; then
      echo "  ✓ $EXTRA_DIR is the canonical copy"
    else
      link_environment "$EXTRA_DIR" "$EXTRA_LAYOUT"
    fi
  fi
fi

echo ""
echo "Installed $ok/$total skills into $CANON."
if [ "$fail" -gt 0 ]; then
  echo "$fail skill(s) failed — check the URL and your network." >&2
  exit 1
fi
if [ "$LINK_MODE" = "symlink" ]; then
  echo "Re-run the same command to update every linked environment at once."
else
  echo "Re-run the same command to refresh every environment."
fi
