#!/usr/bin/env bash # # Bootstrap installer for the compose-preview skill bundles. # # Installs every skill bundle in github.com/yschimke/skills, plus the # compose-preview CLI (sourced from github.com/yschimke/compose-ai-tools # releases), into the shared cross-agent skills directory # (`~/.agents/skills/` by default). Gemini reads `~/.agents/skills/` directly; # Claude Code and Codex only read their own per-host dirs, so we symlink the # canonical bundle in for each detected host: # # /compose-preview/ (renderer + CLI) # |-- SKILL.md (from skill tarball) # |-- references/... (from skill tarball) # |-- cli/compose-preview-/bin/compose-preview (from CLI tarball) # `-- bin/compose-preview -> ../cli/.../compose-preview # # // (content-only bundles; # |-- SKILL.md see COMPANION_SKILLS) # `-- references/... # # ~/.claude/skills/compose-preview -> /compose-preview # ~/.claude/skills/ -> … (if claude detected) # ~/.codex/skills/... (if codex detected) # # No symlink is created under ~/.gemini/: Gemini scans both `~/.agents/skills/` # and `~/.gemini/skills/`, so an extra entry there would surface as a # "Skill conflict detected" warning (issue #1005). # # resolves in order: # 1. $SKILL_DIR/.. when explicitly set # 2. $AGENTS_SKILLS_ROOT (default: ~/.agents/skills) # # On upgrade we sweep any stale compose-preview symlinks earlier versions # wrote under ~/.gemini/skills/ or ~/.gemini/antigravity/skills/ (regular # directories and files are left alone). # # Also symlinks ~/.local/bin/compose-preview so the CLI is on PATH without # the consumer having to know the skill-bundle layout. Idempotent: rerunning # with the same version is a no-op; passing no VERSION resolves the latest # release and replaces whatever is installed. # # Usage: # scripts/install.sh # install latest release # scripts/install.sh 0.3.2 # install a specific version # VERSION=0.3.2 scripts/install.sh # same, via env # scripts/install.sh --cli-only # skip skill-bundle install; use # # when the skill content is # # already on disk via a plugin / # # marketplace install. The # # bundled bin/compose-preview # # stub passes this on first run. # scripts/install.sh --android-sdk # also install the Android SDK # # (cmdline-tools + platforms;android-36 # # + platform-tools + build-tools;36.0.0, # # plus platforms;android-37.0 best-effort) # scripts/install.sh --jdk 17,21 # install JDK 17 and 21 (first = active) # JDKS=17,21 scripts/install.sh # same, via env # # Override locations: # SKILL_DIR=~/.claude/skills/compose-preview scripts/install.sh # full path # AGENTS_SKILLS_ROOT=~/.claude/skills scripts/install.sh # parent dir # PREFIX=$HOME/.local scripts/install.sh # for the ~/.local/bin symlink # REPO=yschimke/compose-ai-tools scripts/install.sh # CLI release source # SKILLS_REPO=yschimke/skills scripts/install.sh # skill content source # SKILLS_REF=main scripts/install.sh # skill content ref # ANDROID_HOME=$HOME/Android/Sdk scripts/install.sh --android-sdk # INSTALL_ANDROID_SDK=1 scripts/install.sh # same as --android-sdk # ANDROID_SDK_PACKAGES='platforms;android-35 platform-tools build-tools;35.0.0' # # required sdkmanager packages # ANDROID_SDK_EXTRA_PACKAGES='' scripts/install.sh --android-sdk # # best-effort extras (empty = none) # ANDROID_CMDLINE_TOOLS_URL=... # pin the cmdline-tools zip # # ANDROID_HOME default: # - $ANDROID_HOME if set # - /opt/android-sdk when running as root or in a cloud sandbox # - $HOME/Library/Android/sdk on macOS # - $HOME/Android/Sdk otherwise (Android Studio's Linux default) # # Requires: bash, curl, tar, sha256sum (or shasum), and Java 17+ on PATH at # run time (not install time). The --android-sdk path additionally needs # unzip and write access to $ANDROID_HOME (sudo when not root). # # Cloud-sandbox mode (auto-detected for Claude/Codex): # - Claude: $CLAUDE_ENV_FILE or $CLAUDE_CODE_SESSION_ID # - Codex: $CODEX_SANDBOX or $CODEX_SESSION_ID # # Claude-specific env-file behavior: # - JDK selection, in order: the JDK already on PATH when its major matches # the project's daemon toolchain; else one already on disk under # /usr/lib/jvm/ or /opt/jdk (tarball installs such as # compose-ai-tools' scripts/setup-cloud-jdk.sh land there); else # apt-install openjdk--jdk-headless. If that last step fails but # some Java 17+ is on PATH, the run continues with a warning instead of # aborting — the CLI, plugin, and renderer AARs are compiled to JDK 17 # bytecode and run fine on any newer JDK, and the rest of the install # (notably --android-sdk) doesn't depend on the daemon toolchain at all. # - Skips api.github.com lookups (they 403 on shared sandbox IPs due to # unauthenticated rate limiting) and resolves versions via the public # github.com HTML redirect instead. Sha256 verification is best-effort. # - Appends JAVA_HOME, ANDROID_HOME, and PATH to $CLAUDE_ENV_FILE so # subsequent tool invocations see them. # - If $https_proxy / $http_proxy is set, translates it into # JAVA_TOOL_OPTIONS (-Dhttps.proxyHost / -Dhttp.proxyHost) and writes # that to $CLAUDE_ENV_FILE too. The JVM's HttpURLConnection ignores the # shell proxy env vars, so the Gradle wrapper download otherwise fails # with UnknownHostException (anthropics/claude-code#16222). # - --android-sdk plays well with the cloud's filesystem snapshot cache: the # SDK is written to disk once during the cloud environment's Setup script # and reused for every later session. Note that sdkmanager downloads from # dl.google.com, which is NOT on the default Trusted network allowlist; # the environment must use Custom access with dl.google.com added (or # Full). # Force on/off explicitly with CLAUDE_CLOUD=1 / CLAUDE_CLOUD=0. set -euo pipefail REPO="${REPO:-yschimke/compose-ai-tools}" SKILLS_REPO="${SKILLS_REPO:-yschimke/skills}" SKILLS_REF="${SKILLS_REF:-main}" SKILL_DIR="${SKILL_DIR:-}" # Every skill in yschimke/skills except `compose-preview`, which is special: # it owns $SKILL_DIR because the CLI is unpacked inside it. The companions are # plain content bundles installed as siblings. Keep this list in sync with # `skills/` in that repo — a name missing here installs no bundle, and users # who took the curl path silently get fewer skills than the plugin path. COMPANION_SKILLS=( compose-preview-review compose-preview-ci compose-preview-design-board compose-design-catalog figma-catalog-import design-parity-review ) PREFIX="${PREFIX:-$HOME/.local}" INSTALL_ANDROID_SDK="${INSTALL_ANDROID_SDK:-0}" JDKS_REQUESTED="${JDKS:-}" ANDROID_HOME_INPUT="${ANDROID_HOME:-}" CLI_ONLY="${CLI_ONLY:-0}" # Argument parsing — flags first, then positional VERSION. Flags can appear in # any order. Unknown flags are an error so typos don't get silently swallowed. # --yes/--upgrade are accepted (and ignored) for backwards compatibility with # old README snippets and pipelines; the consent gate they used to drive was # removed (it was impractical to thread --yes through every agent invocation). positional=() while [[ $# -gt 0 ]]; do case "$1" in --android-sdk) INSTALL_ANDROID_SDK=1; shift ;; --cli-only) CLI_ONLY=1; shift ;; --jdk|--jdks) [[ $# -ge 2 ]] || { echo "error: $1 requires a value" >&2; exit 1; } JDKS_REQUESTED="$2"; shift 2 ;; --jdk=*|--jdks=*) JDKS_REQUESTED="${1#*=}"; shift ;; --yes|-y|--upgrade) shift ;; --) shift; positional+=("$@"); break ;; -*) echo "error: unknown flag: $1" >&2; exit 1 ;; *) positional+=("$1"); shift ;; esac done set -- "${positional[@]+"${positional[@]}"}" VERSION="${1:-${VERSION:-}}" BIN_DIR="$PREFIX/bin" # Cloud sandbox auto-detection (Claude/Codex) ------------------------------- if [[ -n "${CODEX_SANDBOX:-}" || -n "${CODEX_SESSION_ID:-}" ]]; then AGENT_CLOUD_HOST="codex" elif [[ -n "${CLAUDE_ENV_FILE:-}" || -n "${CLAUDE_CODE_SESSION_ID:-}" ]]; then AGENT_CLOUD_HOST="claude" else AGENT_CLOUD_HOST="" fi if [[ -z "${CLAUDE_CLOUD:-}" ]]; then CLAUDE_CLOUD=$([[ -n "$AGENT_CLOUD_HOST" ]] && echo 1 || echo 0) fi # Skill install root — the canonical bundle lives in the shared cross-agent # dir `~/.agents/skills/`. Gemini CLI scans `~/.agents/skills/` directly, so # nothing extra is needed for Gemini. Claude Code and Codex (which only scan # their own per-host dirs) get a symlink each from `link_skills_for_hosts_*` # below — that way one physical install serves every agent without any of # them seeing the bundle in two scan paths at once (issue #1005). Override # with `SKILL_DIR=...` (full path) or `AGENTS_SKILLS_ROOT=...` (parent dir). AGENTS_SKILLS_ROOT="${AGENTS_SKILLS_ROOT:-$HOME/.agents/skills}" if [[ -z "$SKILL_DIR" ]]; then SKILL_DIR="$AGENTS_SKILLS_ROOT/compose-preview" fi # Android SDK location — honor an explicit ANDROID_HOME from the caller; else # pick a writable default that doesn't require sudo for the common case. A # system-wide /opt path makes sense for cloud sandboxes (running as root, with # the filesystem snapshotted across sessions) but is hostile to local users. if [[ -n "$ANDROID_HOME_INPUT" ]]; then ANDROID_HOME="$ANDROID_HOME_INPUT" elif [[ $EUID -eq 0 || "$CLAUDE_CLOUD" == 1 ]]; then ANDROID_HOME="/opt/android-sdk" elif [[ "$(uname -s)" == "Darwin" ]]; then ANDROID_HOME="$HOME/Library/Android/sdk" else ANDROID_HOME="$HOME/Android/Sdk" fi die() { echo "error: $*" >&2; exit 1; } # Progress goes to stderr, not stdout: several helpers below return a value by # printing it (`JDK_HOME="$(install_openjdk_major 17)"`), and a `log` line on # stdout would be captured as part of that value. log() { echo "==> $*" >&2; } require() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1" } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else die "neither sha256sum nor shasum available" fi } require curl require tar # ---- Per-host symlinks + legacy gemini-mirror cleanup --------------------- # # Claude Code and Codex only scan their own per-host skills dir, so we # symlink the canonical bundle into each one when detected. Gemini reads # `~/.agents/skills/` directly, so it gets NO symlink — adding one would # put the same skill into both `~/.agents/skills/` and `~/.gemini/skills/` # and Gemini would emit "Skill conflict detected" (issue #1005). Older # versions of this script created exactly that gemini mirror; on upgrade # we sweep it (and the legacy antigravity subdir) for any compose-preview / # compose-preview-review symlinks we recognise as ours. Non-symlinks are # left alone so any user-managed content is preserved. # # Skill dirs (override via env): # - Codex: ${CODEX_HOME:-$HOME/.codex}/skills # https://developers.openai.com/codex/skills have_claude() { [[ -d "$HOME/.claude" ]] || command -v claude >/dev/null 2>&1 } have_codex() { [[ -d "${CODEX_HOME:-$HOME/.codex}" ]] || command -v codex >/dev/null 2>&1 } link_skill_into_dir() { local host="$1" dir="$2" src="$3" local name; name="$(basename "$src")" local dst="$dir/$name" if [[ -L "$dst" ]]; then local current; current="$(readlink "$dst" 2>/dev/null || true)" if [[ "$current" == "$src" ]]; then return 0 fi log "updating $host skill link: $dst -> $src (was $current)" ln -sfn "$src" "$dst" return 0 fi if [[ -e "$dst" ]]; then log "warning: $host skills dir contains a non-symlink at $dst; leaving it alone" return 0 fi log "linking $host skill: $dst -> $src" ln -s "$src" "$dst" } cleanup_legacy_gemini_skill_links() { # Sweep gemini-side mirrors written by older versions of this script. # Gemini scans both ~/.gemini/skills/ and ~/.agents/skills/, so any # symlink we add under ~/.gemini/ now would duplicate the entry. local roots=( "$HOME/.gemini/skills" "$HOME/.gemini/antigravity/skills" ) local root name for root in "${roots[@]}"; do [[ -d "$root" ]] || continue [[ "$root" == "$AGENTS_SKILLS_ROOT" ]] && continue for name in compose-preview "${COMPANION_SKILLS[@]}"; do local entry="$root/$name" [[ -L "$entry" ]] || continue local target; target="$(readlink "$entry" 2>/dev/null || true)" case "$target" in *compose-preview*|*compose-design-catalog*|*figma-catalog-import*|*design-parity-review*) log "removing legacy gemini skill link: $entry (was -> $target)" rm -f "$entry" ;; esac done done } link_skills_for_detected_hosts() { # Run after the canonical bundles exist; otherwise the symlinks would dangle. [[ -d "$SKILL_DIR" ]] || return 0 cleanup_legacy_gemini_skill_links local owning_root; owning_root="$(dirname "$SKILL_DIR")" local host_dir name companion for host_dir in "claude:$HOME/.claude/skills" "codex:${CODEX_HOME:-$HOME/.codex}/skills"; do local host="${host_dir%%:*}" dir="${host_dir#*:}" case "$host" in claude) have_claude || continue ;; codex) have_codex || continue ;; esac [[ "$dir" == "$owning_root" ]] && continue mkdir -p "$dir" link_skill_into_dir "$host" "$dir" "$SKILL_DIR" for name in "${COMPANION_SKILLS[@]}"; do companion="$owning_root/$name" [[ -d "$companion" ]] && link_skill_into_dir "$host" "$dir" "$companion" done done } # ---- Cloud: ensure required JDK(s) are available -------------------------- # # Resolve the project's required daemon toolchain from # gradle/gradle-daemon-jvm.properties when available; default to 17 as a safe # floor. The active JAVA_HOME points at the required major; additional majors # requested via --jdk get apt-installed alongside it (Gradle's toolchain # auto-detection then finds them under /usr/lib/jvm/), which is how a project # on JDK 17 can also expose 21 for `-Pjdk-version=21` smoke runs. REQUIRED_JAVA_MAJOR="17" DAEMON_JVM_PROPS="" if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" candidate="$script_dir/../gradle/gradle-daemon-jvm.properties" [[ -f "$candidate" ]] && DAEMON_JVM_PROPS="$candidate" fi if [[ -z "$DAEMON_JVM_PROPS" ]]; then candidate="$PWD/gradle/gradle-daemon-jvm.properties" [[ -f "$candidate" ]] && DAEMON_JVM_PROPS="$candidate" fi if [[ -z "$DAEMON_JVM_PROPS" ]] && command -v git >/dev/null 2>&1; then git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -n "$git_root" ]]; then candidate="$git_root/gradle/gradle-daemon-jvm.properties" [[ -f "$candidate" ]] && DAEMON_JVM_PROPS="$candidate" fi fi if [[ -n "$DAEMON_JVM_PROPS" ]]; then required_from_props="$(awk -F= '/^toolchainVersion=/{print $2; exit}' "$DAEMON_JVM_PROPS" || true)" if [[ -n "$required_from_props" && "$required_from_props" =~ ^[0-9]+$ ]]; then REQUIRED_JAVA_MAJOR="$required_from_props" fi fi # Build the list of JDK majors to install. Default: just the required major # (which itself defaults to 17). `--jdk 17,21` or `JDKS=17,21` requests # additional majors; the active one is always REQUIRED_JAVA_MAJOR. declare -a JDK_MAJORS=() if [[ -n "$JDKS_REQUESTED" ]]; then IFS=',' read -r -a _requested <<<"$JDKS_REQUESTED" for m in "${_requested[@]}"; do m="${m// /}" [[ -n "$m" ]] || continue [[ "$m" =~ ^[0-9]+$ ]] || die "invalid --jdk value: '$m' (expected integer major version)" JDK_MAJORS+=("$m") done fi seen_required=0 for m in "${JDK_MAJORS[@]:+${JDK_MAJORS[@]}}"; do [[ "$m" == "$REQUIRED_JAVA_MAJOR" ]] && seen_required=1 && break done if [[ "$seen_required" == 0 ]]; then JDK_MAJORS=("$REQUIRED_JAVA_MAJOR" ${JDK_MAJORS[@]:+"${JDK_MAJORS[@]}"}) fi # Report the feature-release major of the JDK rooted at $1, or fail if it # isn't a JDK. Prefers the `release` file (no subprocess, and immune to the # noise JAVA_TOOL_OPTIONS prints on some sandboxes) and falls back to asking # the binary itself. jdk_major_of() { local home="$1" version="" major="" [[ -n "$home" && -x "$home/bin/java" ]] || return 1 if [[ -r "$home/release" ]]; then version="$(awk -F'"' '/^JAVA_VERSION=/{print $2; exit}' "$home/release" 2>/dev/null || true)" fi if [[ -z "$version" ]]; then version="$("$home/bin/java" -version 2>&1 | awk -F'"' '/version "/{print $2; exit}')" fi [[ -n "$version" ]] || return 1 major="${version%%.*}" # Legacy 1.8-style strings carry the major in the second component. [[ "$major" == "1" ]] && major="$(printf '%s' "$version" | awk -F. '{print $2}')" [[ "$major" =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "$major" } # Find an already-installed JDK of the requested major, wherever it landed. # # This used to check only the Debian apt layout # (/usr/lib/jvm/java--openjdk-amd64), so a Temurin tarball unpacked at # /opt/jdk17 and symlinked to /usr/lib/jvm/temurin-17 — exactly what # compose-ai-tools' scripts/setup-cloud-jdk.sh puts on cloud sandboxes — was # invisible, and the installer went to apt for a JDK the box already had. When # the apt index was stale that apt call 404'd and aborted the whole run, # including the --android-sdk work that has nothing to do with the JDK # (compose-ai-tools#3695). # # Every candidate is verified by reading its actual version, so a loose glob # match (`java-11.0.17` for major 17) can't produce a wrong answer. find_installed_jdk() { local major="$1" candidate for candidate in \ "${JAVA_HOME:-}" \ "/usr/lib/jvm/java-${major}-openjdk-amd64" \ "/opt/jdk${major}" \ /usr/lib/jvm/*"${major}"* \ /opt/jdk-"${major}"* \ /Library/Java/JavaVirtualMachines/*"${major}"*/Contents/Home; do [[ -n "$candidate" && -d "$candidate" ]] || continue [[ "$(jdk_major_of "$candidate" || true)" == "$major" ]] || continue printf '%s\n' "$candidate" return 0 done return 1 } install_openjdk_major() { local major="$1" local existing="" if existing="$(find_installed_jdk "$major")"; then log "JDK $major already present at $existing" printf '%s\n' "$existing" return 0 fi local jdk_home="/usr/lib/jvm/java-${major}-openjdk-amd64" if ! command -v apt-get >/dev/null 2>&1; then log "warning: no JDK $major found on disk and apt-get unavailable; skipping" return 1 fi local sudo="" if [[ $EUID -ne 0 ]]; then command -v sudo >/dev/null 2>&1 || { log "warning: need root or sudo to apt-install openjdk-${major}-jdk-headless; skipping"; return 1; } sudo="sudo" fi log "apt-installing openjdk-${major}-jdk-headless" if ! $sudo apt-get install -y -qq "openjdk-${major}-jdk-headless"; then # A stale apt index pins point releases the Ubuntu archive has already # rotated out, so the .deb 404s ("Failed to fetch ... 404 Not Found"). # Refresh the index once and retry before giving up. log "apt-get install failed; refreshing the package index and retrying" $sudo apt-get update -qq || true $sudo apt-get install -y -qq "openjdk-${major}-jdk-headless" \ || { log "warning: apt-get failed for openjdk-${major}-jdk-headless; skipping"; return 1; } fi if existing="$(find_installed_jdk "$major")"; then printf '%s\n' "$existing" return 0 fi log "warning: openjdk-${major}-jdk-headless installed but $jdk_home/bin/java missing" return 1 } if [[ "$CLAUDE_CLOUD" == 1 || -n "$JDKS_REQUESTED" ]]; then # Active JDK: prefer the existing one on PATH if its major matches the # required version (avoids a redundant apt round-trip on cloud images that # already ship the right JDK), otherwise install it. detected_major="" if command -v java >/dev/null 2>&1; then # `java -version` prints `openjdk version "21.0.10" ...` to stderr. # Legacy JDK 8 reports `1.8.x`, which parses to major=1. detected_major="$(java -version 2>&1 | head -1 | awk -F'"' '{print $2}' | awk -F. '{print $1}')" fi if [[ -n "$detected_major" && "$detected_major" =~ ^[0-9]+$ && "$detected_major" -eq "$REQUIRED_JAVA_MAJOR" ]]; then log "using existing JDK $detected_major on PATH as the active toolchain" else if [[ -n "$detected_major" && "$detected_major" =~ ^[0-9]+$ ]]; then log "detected JDK $detected_major but project requires JDK $REQUIRED_JAVA_MAJOR; selecting required JDK" fi if JDK_HOME="$(install_openjdk_major "$REQUIRED_JAVA_MAJOR")"; then export JAVA_HOME="$JDK_HOME" export PATH="$JAVA_HOME/bin:$PATH" elif [[ -n "$detected_major" && "$detected_major" =~ ^[0-9]+$ && "$detected_major" -ge 17 ]]; then # Don't sink the whole run over the daemon toolchain. Everything this # script installs (the CLI, and the Android SDK below) works on any # Java 17+, and the remaining work — notably --android-sdk — is what the # caller is usually here for. Loud warning, non-zero-cost to ignore, but # not fatal (compose-ai-tools#3695). log "warning: could not provision JDK $REQUIRED_JAVA_MAJOR; continuing on the JDK $detected_major already on PATH." log "warning: a Gradle build pinned to toolchain $REQUIRED_JAVA_MAJOR will still need one on disk (see scripts/setup-cloud-jdk.sh in compose-ai-tools)." else die "could not install required JDK $REQUIRED_JAVA_MAJOR (and no Java 17+ is on PATH to fall back to)" fi fi # Additional JDKs (anything in JDK_MAJORS besides the active major). Gradle's # toolchain auto-detection scans /usr/lib/jvm/ so we don't need to export # extra env vars. for m in "${JDK_MAJORS[@]}"; do [[ "$m" == "$REQUIRED_JAVA_MAJOR" ]] && continue install_openjdk_major "$m" >/dev/null || true done fi # ---- Optional: install Android SDK --------------------------------------- # # Mirrors the manual procedure in docs/AGENTS.md ("Bringing up a fresh # sandbox"). Idempotent — every requested package maps 1:1 onto a directory # under $ANDROID_HOME, so a re-run (and the warm-cache path on Claude Cloud) # installs only what is actually missing and skips out entirely when nothing # is. # # Network note: sdkmanager pulls from dl.google.com, which is not on the # Claude Cloud Trusted allowlist by default (developer.android.com is, but # that's the docs domain). The reachability probe below fails fast with a # clear remediation hint when the host is blocked. # Packages every install needs. Overridable so a consumer on a different # compileSdk isn't stuck with ours. ANDROID_SDK_PACKAGES="${ANDROID_SDK_PACKAGES:-platforms;android-36 platform-tools build-tools;36.0.0}" # Best-effort extras, installed in a second sdkmanager call so a name this SDK # repository doesn't know can't fail the whole install. `platforms;android-37.0` # is here because modules on the alpha Compose/Wear artifacts build at # compileSdk 37 — and note the package is `android-37.0`, NOT `android-37`, # which does not exist and fails the invocation it appears in. ANDROID_SDK_EXTRA_PACKAGES="${ANDROID_SDK_EXTRA_PACKAGES-platforms;android-37.0}" # Can we write $ANDROID_HOME without escalating? Walk up to the nearest # existing ancestor — the leaf usually doesn't exist yet on a fresh install. android_home_writable() { local dir="$ANDROID_HOME" while [[ -n "$dir" && "$dir" != "/" && ! -e "$dir" ]]; do dir="$(dirname "$dir")" done [[ -w "$dir" ]] } install_android_sdk() { local pkg local -a required=() extras=() missing=() missing_extras=() read -r -a required <<<"$ANDROID_SDK_PACKAGES" read -r -a extras <<<"$ANDROID_SDK_EXTRA_PACKAGES" # `platforms;android-36` lives at `$ANDROID_HOME/platforms/android-36`. for pkg in ${required[@]+"${required[@]}"}; do [[ -d "$ANDROID_HOME/${pkg//;//}" ]] || missing+=("$pkg") done for pkg in ${extras[@]+"${extras[@]}"}; do [[ -d "$ANDROID_HOME/${pkg//;//}" ]] || missing_extras+=("$pkg") done local sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" if [[ ${#missing[@]} -eq 0 && ${#missing_extras[@]} -eq 0 && -x "$sdkmanager" ]]; then log "android sdk already complete at $ANDROID_HOME; skipping" return 0 fi require curl require unzip local sudo="" # Only escalate when the target genuinely isn't writable. The non-root # default ($HOME/Android/Sdk) needs no sudo, and demanding it there turned a # working install into a hard failure on machines without sudo. if ! android_home_writable; then command -v sudo >/dev/null 2>&1 \ || die "$ANDROID_HOME is not writable and sudo is unavailable; set ANDROID_HOME to a writable path" sudo="sudo" fi local cmdline_zip_url="${ANDROID_CMDLINE_TOOLS_URL:-}" if [[ -z "$cmdline_zip_url" ]]; then local cmdline_os="linux" [[ "$(uname -s)" == "Darwin" ]] && cmdline_os="mac" cmdline_zip_url="https://dl.google.com/android/repository/commandlinetools-${cmdline_os}-13114758_latest.zip" fi if ! curl -fsI -o /dev/null --max-time 10 "$cmdline_zip_url" 2>/dev/null; then if [[ ${#missing[@]} -eq 0 ]]; then # Everything the caller actually requires is already installed; only the # best-effort extras are absent. An unreachable CDN must not turn that # into a failed install. log "warning: cannot reach dl.google.com; skipping optional packages (${missing_extras[*]})" return 0 fi die "cannot reach dl.google.com (Android SDK CDN). On Claude Code on the web, set the environment's network access to Custom and add 'dl.google.com' (the default Trusted list only includes developer.android.com, which doesn't serve the SDK)." fi if [[ -x "$sdkmanager" ]]; then log "android command-line tools already present at $ANDROID_HOME/cmdline-tools/latest" else log "installing Android command-line tools to $ANDROID_HOME" local tmp tmp="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '$tmp'" RETURN local zip="$tmp/cmdline-tools.zip" local extract="$tmp/cmdline-tools-extract" curl -fsSL -o "$zip" "$cmdline_zip_url" \ || die "failed to download Android command-line tools" mkdir -p "$extract" unzip -q "$zip" -d "$extract" $sudo mkdir -p "$ANDROID_HOME/cmdline-tools" $sudo rm -rf "$ANDROID_HOME/cmdline-tools/latest" $sudo mv "$extract/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" fi # Pre-write the license-hash files instead of piping `yes` into # `sdkmanager --licenses`. The pipe approach exits 141 (SIGPIPE) under # `set -o pipefail` whenever sdkmanager closes stdin before `yes` is done # writing. These hashes are the same ones android-actions/setup-android # writes on GitHub Actions; sdkmanager treats a license as accepted as # soon as the hash is on disk. log "accepting Android SDK licenses" $sudo mkdir -p "$ANDROID_HOME/licenses" $sudo tee "$ANDROID_HOME/licenses/android-sdk-license" >/dev/null <<'LIC' 8933bad161af4178b1185d1a37fbf41ea5269c55 d56f5187479451eabf01fb78af6dfcb131a6481e 24333f8a63b6825ea9c5514f83c2829b004d1fee LIC $sudo tee "$ANDROID_HOME/licenses/android-sdk-preview-license" >/dev/null <<'LIC' 84831b9409646a918e30573bab4c9c91346d8abd LIC $sudo tee "$ANDROID_HOME/licenses/android-sdk-arm-dbt-license" >/dev/null <<'LIC' 859f317696f67ef3d7f30a50a5560e7834b43903 LIC $sudo tee "$ANDROID_HOME/licenses/google-gdk-license" >/dev/null <<'LIC' 33b6a2b64607f11b759f320ef9dff4ae5c47d97a LIC $sudo tee "$ANDROID_HOME/licenses/intel-android-extra-license" >/dev/null <<'LIC' d975f751698a77b662f1254ddbeed3901e976f5a LIC $sudo tee "$ANDROID_HOME/licenses/mips-android-sysimage-license" >/dev/null <<'LIC' e9acab5b5fbb560a72cfaecce8946896ff6aab9d LIC if [[ ${#missing[@]} -gt 0 ]]; then log "installing Android packages: ${missing[*]}" $sudo "$sdkmanager" ${missing[@]+"${missing[@]}"} >/dev/null \ || die "sdkmanager failed to install: ${missing[*]}" fi if [[ ${#missing_extras[@]} -gt 0 ]]; then log "installing optional Android packages: ${missing_extras[*]}" $sudo "$sdkmanager" ${missing_extras[@]+"${missing_extras[@]}"} >/dev/null \ || log "warning: could not install optional packages (${missing_extras[*]}); continuing without them" fi log "android sdk installed at $ANDROID_HOME" } # Record the SDK location in the Gradle project's `local.properties` so a build # run without ANDROID_HOME exported still resolves it (AGP reads `sdk.dir` # first). The file is gitignored in every Gradle project we care about; an # existing `sdk.dir` is never overwritten. write_local_properties() { local root="" candidate for candidate in "$PWD" "$(git rev-parse --show-toplevel 2>/dev/null || true)"; do [[ -n "$candidate" ]] || continue if [[ -f "$candidate/settings.gradle.kts" || -f "$candidate/settings.gradle" ]]; then root="$candidate" break fi done [[ -n "$root" ]] || return 0 local file="$root/local.properties" if [[ -f "$file" ]] && grep -q '^[[:space:]]*sdk\.dir[[:space:]]*=' "$file"; then return 0 fi if [[ -e "$file" && ! -w "$file" ]] || [[ ! -e "$file" && ! -w "$root" ]]; then log "warning: cannot write $file; export ANDROID_HOME=$ANDROID_HOME instead" return 0 fi # Don't glue onto a final line that has no newline of its own. if [[ -s "$file" && -n "$(tail -c1 "$file")" ]]; then printf '\n' >>"$file" fi printf 'sdk.dir=%s\n' "$ANDROID_HOME" >>"$file" log "recorded sdk.dir=$ANDROID_HOME in $file" } if [[ "$INSTALL_ANDROID_SDK" == 1 ]]; then install_android_sdk export ANDROID_HOME export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" write_local_properties fi # ---- Resolve version ------------------------------------------------------ # # Detect what's already installed BEFORE talking to github.com so a # re-invocation that wants the existing version can short-circuit without # touching the network at all. CLI_VERSION_FILE="$SKILL_DIR/.cli-version" INSTALLED_VERSION="$(cat "$CLI_VERSION_FILE" 2>/dev/null || true)" # Releases from this version onward carry a readiness asset uploaded only after # a clean Gradle build has resolved the CLI's auto-injected plugin classpath from # public Maven Central. Older releases predate that workflow, so retain the # direct POM probes for backwards-compatible installs. READINESS_MARKER_MIN_VERSION="0.19.33" MAX_RELEASE_CANDIDATES=5 semver_at_least() { local version="$1" minimum="$2" local v_major v_minor v_patch m_major m_minor m_patch IFS=. read -r v_major v_minor v_patch <<<"$version" IFS=. read -r m_major m_minor m_patch <<<"$minimum" (( v_major > m_major )) \ || (( v_major == m_major && v_minor > m_minor )) \ || (( v_major == m_major && v_minor == m_minor && v_patch >= m_patch )) } # Is this URL actually fetchable? Probed with a one-byte ranged GET, not a HEAD. # # A HEAD looks cheaper and is wrong here. GitHub redirects a release asset to a # presigned objects.githubusercontent.com URL, and AWS SigV4 presigned URLs are # method-bound: GitHub signs them for GET, so the HEAD comes back 401 while the # asset downloads perfectly well. # # curl -sIL -o /dev/null -w '%{http_code}' -> 401 # curl -sL -r 0-0 -o /dev/null -w '%{http_code}' -> 206 # # Since release_is_ready() gates every candidate on this, the HEAD made *every* # release look unready — auto-resolve then walked the whole feed and reported # "no usable CLI release found", blaming the release pipeline for a bug in the # probe. url_is_downloadable() { curl -fsL -r 0-0 --connect-timeout 3 --max-time 8 -o /dev/null "$1" 2>/dev/null } # CLI-shaped release versions (X.Y.Z), newest first, deduped. # # Two sources because neither is reliable everywhere. The atom feed is # preferred — it is not the rate-limited api.github.com, which matters on shared # CI IPs. But in an *agent* sandbox the polarity flips: the Claude Code proxy # allows repository-scoped GitHub API paths and refuses everything else, so the # feed comes back 403 — # "This GitHub API path is not available: sessions are bound to their # configured repositories. Use repository-scoped endpoints" # — while /repos///releases answers 200. This used to `die` on that # 403, which meant the installer could not resolve a version at all in the one # environment it exists to serve. # # Both sources list drafts, whose assets are not downloadable yet; # release_is_ready() is what filters those, so the two stay interchangeable. # Empty output (rc 1) means neither source could be listed. candidate_versions() { local feed api if feed="$(curl -fsSL "https://github.com/$REPO/releases.atom" 2>/dev/null)"; then printf '%s\n' "$feed" \ | grep -oE 'releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' \ | sed 's#.*/tag/v##' \ | awk '!seen[$0]++' return 0 fi log "releases.atom unreachable (blocked or offline); trying the repo-scoped GitHub API" api="$(curl -fsSL -H 'Accept: application/vnd.github+json' \ "https://api.github.com/repos/$REPO/releases?per_page=$((MAX_RELEASE_CANDIDATES * 4))" \ 2>/dev/null)" || return 1 printf '%s\n' "$api" \ | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"v[0-9]+\.[0-9]+\.[0-9]+"' \ | sed 's#.*"v##; s#"$##' \ | awk '!seen[$0]++' } release_is_ready() { local version="$1" [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 local cli_asset="compose-preview-${version}.tar.gz" local cli_url="https://github.com/$REPO/releases/download/v${version}/${cli_asset}" url_is_downloadable "$cli_url" || return 1 if semver_at_least "$version" "$READINESS_MARKER_MIN_VERSION"; then local marker="compose-preview-maven-ready-${version}.json" local marker_url="https://github.com/$REPO/releases/download/v${version}/${marker}" url_is_downloadable "$marker_url" return fi local plugin_marker_url="https://repo.maven.apache.org/maven2/ee/schimke/composeai/preview/ee.schimke.composeai.preview.gradle.plugin/${version}/ee.schimke.composeai.preview.gradle.plugin-${version}.pom" local plugin_impl_url="https://repo.maven.apache.org/maven2/ee/schimke/composeai/compose-preview-plugin/${version}/compose-preview-plugin-${version}.pom" url_is_downloadable "$plugin_marker_url" && url_is_downloadable "$plugin_impl_url" } if [[ -z "$VERSION" ]]; then log "resolving latest release of $REPO" # Resolve the newest CLI release from the public releases.atom feed. # # NOT /releases/latest: this is a release-please monorepo where the CLI ships # on v tags and the mobile/wear apps ship on clients-v tags. # Whichever publishes last owns GitHub's single "latest" pointer, so # /releases/latest can resolve to a clients-v* tag -- which has no "/v" to # strip and produced "could not parse version from .../tag/clients-v0.2.0". # # The atom feed lists every release newest-first and, like the HTML redirect, # isn't the rate-limited api.github.com. GitHub includes *draft* releases in # this public feed, though, before their assets are publicly downloadable. # Selecting the first matching tag therefore creates a long 404 window while # the release workflow builds and uploads the CLI (issue #3287). # # Walk CLI-shaped tags newest-first and select the first usable release. New # releases carry a readiness asset produced only after a clean Gradle resolution # from public Maven Central; draft/incomplete/not-yet-propagated candidates lack # that marker and fall back to the previous usable version. Bound the scan so a # GitHub or Central outage fails promptly instead of probing # every historical feed entry. # A "releases/tag/clients-v..." href does not match "releases/tag/v...", so # component releases are skipped for free. # # ...and when the feed is unreachable, fall back to the repo-scoped API rather # than dying. The comment above avoids api.github.com because it is # rate-limited on shared sandbox IPs — but in an *agent* sandbox the polarity # flips: the Claude Code proxy allows repository-scoped GitHub API paths and # refuses everything else, so `releases.atom` comes back 403 with # "This GitHub API path is not available: sessions are bound to their # configured repositories. Use repository-scoped endpoints" # while /repos///releases answers 200. Neither source is reliable # everywhere; between them one usually works. candidates_checked=0 while IFS= read -r candidate; do [[ -n "$candidate" ]] || continue candidates_checked=$((candidates_checked + 1)) if release_is_ready "$candidate"; then VERSION="$candidate" break fi log "release v${candidate} is not ready for CLI + Maven use; trying the previous release" (( candidates_checked >= MAX_RELEASE_CANDIDATES )) && break done < <(candidate_versions) if [[ -z "$VERSION" && "$candidates_checked" == 0 ]]; then die "could not list releases of $REPO (github.com/$REPO/releases.atom and api.github.com both unreachable). Pass an explicit version, e.g. install.sh ." fi [[ -n "$VERSION" ]] \ || die "no usable CLI release found in the first $MAX_RELEASE_CANDIDATES release candidates" else log "verifying requested release v$VERSION is ready for CLI + Maven use" release_is_ready "$VERSION" \ || die "release v$VERSION is not ready yet (CLI archive or Maven readiness missing)" fi CLI_ASSET="compose-preview-${VERSION}.tar.gz" CLI_URL="https://github.com/$REPO/releases/download/v${VERSION}/${CLI_ASSET}" CLI_DEST="$SKILL_DIR/cli" LAUNCHER="$CLI_DEST/compose-preview-${VERSION}/bin/compose-preview" SKILL_LAUNCHER="$SKILL_DIR/bin/compose-preview" # Companion skills install as siblings of $SKILL_DIR (see COMPANION_SKILLS). # They ship separately from compose-preview so an agent loading one of them # doesn't pull in the others' content. TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT proxy_java_tool_options() { # Translate $https_proxy / $http_proxy into JVM -D flags. The JVM's # HttpURLConnection (used by the Gradle wrapper) ignores the shell proxy # env vars, so without this the wrapper fails on # `services.gradle.org` (anthropics/claude-code#16222). Prints an empty # string when no proxy URL is set or it lacks an explicit port. local url="${https_proxy:-${HTTPS_PROXY:-${http_proxy:-${HTTP_PROXY:-}}}}" [[ -n "$url" ]] || return 0 local hostport="${url#*://}" # strip scheme hostport="${hostport%%/*}" # strip path hostport="${hostport##*@}" # strip optional user:pass@ local host="${hostport%:*}" local port="${hostport##*:}" [[ "$host" != "$hostport" ]] || return 0 # no ':' -> no port, skip printf -- '-Dhttps.proxyHost=%s -Dhttps.proxyPort=%s -Dhttp.proxyHost=%s -Dhttp.proxyPort=%s' \ "$host" "$port" "$host" "$port" } maybe_write_env_file() { local env_file="" if [[ -z "${AGENT_CLOUD_HOST:-}" ]]; then return 0 fi case "${AGENT_CLOUD_HOST:-}" in claude) env_file="${CLAUDE_ENV_FILE:-}" ;; codex) env_file="${CODEX_ENV_FILE:-${CODEX_HOME:-$HOME/.codex}/.env}" ;; *) return 0 ;; esac if [[ -n "$env_file" && -w "$(dirname "$env_file")" ]]; then local jto sdk_path="" jto="$(proxy_java_tool_options)" if [[ "$INSTALL_ANDROID_SDK" == 1 ]]; then sdk_path="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:" fi { [[ -n "${JAVA_HOME:-}" ]] && echo "JAVA_HOME=$JAVA_HOME" [[ "$INSTALL_ANDROID_SDK" == 1 ]] && echo "ANDROID_HOME=$ANDROID_HOME" echo "PATH=$BIN_DIR:${JAVA_HOME:+$JAVA_HOME/bin:}${sdk_path}\$PATH" [[ -n "$jto" ]] && echo "JAVA_TOOL_OPTIONS=$jto" } >> "$env_file" log "wrote env vars to $env_file" fi } # Resolve the upstream skills repo commit SHA via `git ls-remote`. Works # unauthenticated, isn't rate-limited the way api.github.com is on shared # sandbox IPs, and lets us short-circuit re-runs when the upstream tip is # unchanged. Returns empty on failure; callers treat that as "unknown" and # fall through to a full extract. resolve_skills_sha() { command -v git >/dev/null 2>&1 || return 1 git ls-remote "https://github.com/$SKILLS_REPO" "refs/heads/$SKILLS_REF" 2>/dev/null \ | awk 'NR==1{print $1}' } # Install both skill bundles from a single tarball of yschimke/skills. # Skill content lives in a separate repo from the CLI, so we fetch once and # extract each `skills//` subtree into its target dir. Marker files # record the upstream SHA; re-runs at the same SHA are no-ops. Stale files # from previous installs are removed only for top-level entries the new # bundle carries, so `cli/` and `bin/` (added later) are left alone. install_skills_bundle() { local sha="" sha="$(resolve_skills_sha || true)" # Short-circuit only when *every* bundle is already at the resolved SHA. A # newly added companion has no marker, so adding one to COMPANION_SKILLS # re-downloads once and then goes quiet again. local skills_root; skills_root="$(dirname "$SKILL_DIR")" local up_to_date=1 name dir if [[ -n "$sha" ]]; then for dir in "$SKILL_DIR" "${COMPANION_SKILLS[@]/#/$skills_root/}"; do [[ "$(cat "$dir/.skill-version" 2>/dev/null || true)" == "$sha" ]] || { up_to_date=0; break; } done else up_to_date=0 fi if [[ "$up_to_date" == 1 ]]; then log "skill bundles already at $SKILLS_REPO@${sha:0:7} — skipping download" return 0 fi local url="https://codeload.github.com/$SKILLS_REPO/tar.gz/$SKILLS_REF" local tmpfile="$TMP/skills.tar.gz" local extract="$TMP/skills-extract" log "downloading skill bundles from $SKILLS_REPO@$SKILLS_REF" if ! curl -fL --progress-bar -o "$tmpfile" "$url" 2>/dev/null; then log "warning: could not download $url — skipping skill bundles" return 1 fi rm -rf "$extract" mkdir -p "$extract" tar -xzf "$tmpfile" -C "$extract" # The archive root looks like `skills-/` (github's tarball wrapper). local root root="$(find "$extract" -mindepth 1 -maxdepth 1 -type d | head -1)" [[ -d "$root" ]] || { log "warning: skills tarball was empty"; return 1; } _extract_one_skill() { local name="$1" dir="$2" local src="$root/skills/$name" if [[ ! -d "$src" ]]; then log "warning: skill '$name' not found in $SKILLS_REPO@$SKILLS_REF; skipping" return 1 fi mkdir -p "$dir" log "refreshing skill bundle $name in $dir" local entry # `find -printf` is GNU-only; BSD/macOS find rejects it. Print full paths # and strip the directory prefix in the shell instead. while IFS= read -r entry; do entry="${entry##*/}" [[ -n "$entry" && "$entry" != "." && "$entry" != ".." ]] || continue rm -rf "$dir/$entry" done < <(find "$src" -mindepth 1 -maxdepth 1 | sort -u) cp -R "$src/." "$dir/" printf '%s\n' "${sha:-unknown}" > "$dir/.skill-version" } _extract_one_skill "compose-preview" "$SKILL_DIR" || true local companion for companion in "${COMPANION_SKILLS[@]}"; do _extract_one_skill "$companion" "$skills_root/$companion" || true done } # ---- Same-version short-circuit ------------------------------------------ # Refreshes any symlinks the caller might have blown away and refreshes the # skill bundles from upstream (cheap — install_skills_bundle is a no-op when # the upstream SHA matches), but never re-downloads the CLI tarball. if [[ "$INSTALLED_VERSION" == "$VERSION" && -x "$LAUNCHER" ]]; then log "compose-preview CLI $VERSION already installed" [[ "$CLI_ONLY" == 1 ]] || install_skills_bundle || true mkdir -p "$SKILL_DIR/bin" "$BIN_DIR" ln -sfn "../cli/compose-preview-${VERSION}/bin/compose-preview" "$SKILL_LAUNCHER" ln -sfn "$LAUNCHER" "$BIN_DIR/compose-preview" "$LAUNCHER" --help >/dev/null 2>&1 || die "installed launcher is broken: $LAUNCHER" [[ "$CLI_ONLY" == 1 ]] || link_skills_for_detected_hosts maybe_write_env_file exit 0 fi # ---- Skill bundles -------------------------------------------------------- # Skill markdown lives in yschimke/skills (separate from the CLI). One fetch # covers both compose-preview and compose-preview-review. Skipped under # --cli-only — when the caller is the in-skill bootstrap stub, the bundles # are already on disk via the plugin / marketplace install path, and a # second copy at $SKILL_DIR would duplicate the entry into Claude / Codex # scan paths (issue #1005). if [[ "$CLI_ONLY" != 1 ]]; then install_skills_bundle || true else log "--cli-only: skipping skill-bundle install (already on disk)" fi # ---- CLI tarball --------------------------------------------------------- if [[ -x "$LAUNCHER" ]]; then log "CLI $VERSION already extracted at $LAUNCHER" else # ---- Fetch release metadata (best-effort for sha256) ---- CLI_DIGEST="" log "fetching release metadata for v$VERSION" META_HEADERS=(-H "Accept: application/vnd.github+json") [[ -n "${GITHUB_TOKEN:-}" ]] && META_HEADERS+=(-H "Authorization: Bearer $GITHUB_TOKEN") if META="$(curl -fsSL "${META_HEADERS[@]}" \ "https://api.github.com/repos/$REPO/releases/tags/v$VERSION" 2>/dev/null)"; then CLI_DIGEST="$(printf '%s' "$META" | awk -v asset="$CLI_ASSET" ' /"name":/ { in_asset = ($0 ~ asset) } in_asset && /"digest":/ { sub(/.*"digest":[[:space:]]*"sha256:/, "") sub(/".*/, "") print exit } ')" else log "warning: api.github.com unreachable (likely rate-limited); skipping sha256 verification" fi # The plain github.com release URL is the happy path, but an egress policy # that blocks github.com while allowing api.github.com leaves it unreachable # — the same asymmetry `candidate_versions` already works around for listing. # The API's per-asset endpoint serves the same bytes (it redirects to # objects.githubusercontent.com), so keep it as a fallback rather than dying # one step from a working install. CLI_API_URL="" if [[ -n "${META:-}" ]]; then if command -v jq >/dev/null 2>&1; then CLI_API_URL="$(printf '%s' "$META" \ | jq -r --arg n "$CLI_ASSET" '.assets[]? | select(.name == $n) | .url' 2>/dev/null \ | head -n1)" else CLI_API_URL="$(printf '%s' "$META" | awk -v asset="$CLI_ASSET" ' /"url":/ { u = $0; sub(/.*"url":[[:space:]]*"/, "", u); sub(/".*/, "", u) } $0 ~ ("\"name\":[[:space:]]*\"" asset "\"") { print u; exit }')" fi fi log "downloading $CLI_URL" if ! curl -fL --progress-bar -o "$TMP/$CLI_ASSET" "$CLI_URL"; then [[ -n "$CLI_API_URL" ]] || die "download failed: $CLI_URL" log "github.com unreachable; retrying via the api.github.com asset endpoint" ASSET_HEADERS=(-H "Accept: application/octet-stream") [[ -n "${GITHUB_TOKEN:-}" ]] && ASSET_HEADERS+=(-H "Authorization: Bearer $GITHUB_TOKEN") curl -fL --progress-bar "${ASSET_HEADERS[@]}" -o "$TMP/$CLI_ASSET" "$CLI_API_URL" \ || die "download failed: $CLI_URL (and API fallback $CLI_API_URL)" fi if [[ -n "${CLI_DIGEST:-}" ]]; then got="$(sha256_of "$TMP/$CLI_ASSET")" [[ "$got" == "$CLI_DIGEST" ]] \ || die "sha256 mismatch: expected $CLI_DIGEST, got $got" log "verified sha256 $got" fi log "installing CLI to $CLI_DEST" mkdir -p "$CLI_DEST" tar -xzf "$TMP/$CLI_ASSET" -C "$CLI_DEST" fi [[ -x "$LAUNCHER" ]] || die "launcher not found after extract: $LAUNCHER" mkdir -p "$SKILL_DIR" printf '%s\n' "$VERSION" > "$CLI_VERSION_FILE" # ---- Wire up the in-bundle launcher -------------------------------------- mkdir -p "$SKILL_DIR/bin" ln -sf "../cli/compose-preview-${VERSION}/bin/compose-preview" "$SKILL_LAUNCHER" log "skill bundle launcher: $SKILL_LAUNCHER" # ---- Optional global symlink --------------------------------------------- mkdir -p "$BIN_DIR" ln -sf "$LAUNCHER" "$BIN_DIR/compose-preview" log "symlinked $BIN_DIR/compose-preview -> $LAUNCHER" # ---- Smoke test ----------------------------------------------------------- if ! "$LAUNCHER" --help >/dev/null 2>&1; then die "launcher failed smoke test (needs Java 17+ on PATH or JAVA_HOME)" fi # ---- Cloud: write env vars ------------------------------------------------ maybe_write_env_file # ---- PATH advice ---------------------------------------------------------- case ":$PATH:" in *":$BIN_DIR:"*) ;; *) if [[ "$CLAUDE_CLOUD" != 1 ]]; then cat >&2 <> ~/.bashrc # or ~/.zshrc fish: fish_add_path $BIN_DIR EOF fi ;; esac [[ "$CLI_ONLY" == 1 ]] || link_skills_for_detected_hosts log "installed compose-preview $VERSION" [[ "$CLI_ONLY" == 1 ]] || log "skill bundle: $SKILL_DIR" log "next: run 'compose-preview doctor' in your project to verify Gradle access"