#!/usr/bin/env bash # # maoclaw installer # # One-liner install: # curl -fsSL "https://raw.githubusercontent.com/miounet11/maoclaw/main/install.sh?$(date +%s)" | bash # # Highlights: # - Installs latest (or requested) GitHub release binary for your platform # - Verifies artifact checksum via SHA256SUMS # - Detects existing TypeScript pi and can migrate to Rust canonical `pi` # - Creates `legacy-pi` alias for the preserved TypeScript CLI when migrated # - Writes installer state for idempotent re-runs and clean uninstall set -euo pipefail umask 022 shopt -s lastpipe 2>/dev/null || true OWNER="${OWNER:-miounet11}" REPO="${REPO:-maoclaw}" VERSION="${VERSION:-}" DEST_DEFAULT="$HOME/.local/bin" DEST="$DEST_DEFAULT" DEST_EXPLICIT=0 SYSTEM=0 EASY=0 YES=0 QUIET=0 NO_GUM=0 FROM_SOURCE=0 VERIFY=0 NO_VERIFY=0 FORCE_INSTALL=0 OFFLINE="${PI_INSTALLER_OFFLINE:-0}" OFFLINE_TARBALL="${PI_INSTALLER_OFFLINE_TARBALL:-}" AGENT_SKILLS_ENABLED="${AGENT_SKILLS_ENABLED:-1}" MACOS_LAUNCHER_MODE="${PI_INSTALLER_MACOS_LAUNCHER_MODE:-auto}" MACOS_LAUNCHER_PATH="${PI_INSTALLER_MACOS_LAUNCHER_PATH:-}" CHECKSUM="${CHECKSUM:-}" CHECKSUM_URL="${CHECKSUM_URL:-}" ARTIFACT_URL="${ARTIFACT_URL:-}" SIGSTORE_BUNDLE_URL="${SIGSTORE_BUNDLE_URL:-}" COSIGN_IDENTITY_RE="${COSIGN_IDENTITY_RE:-^https://github.com/${OWNER}/${REPO}/.github/workflows/release.yml@refs/tags/.*$}" COSIGN_OIDC_ISSUER="${COSIGN_OIDC_ISSUER:-https://token.actions.githubusercontent.com}" COMPLETIONS_MODE="${COMPLETIONS_MODE:-auto}" PROXY_ARGS=() PROXY_SOURCE="" WSL_DETECTED=0 # ask|yes|no ADOPT_MODE="ask" LEGACY_ALIAS_NAME="${LEGACY_ALIAS_NAME:-legacy-pi}" OS="" ARCH="" TARGET="" EXE_EXT="" ASSET_PLATFORM="" ASSET_NAME="" SHA_URL="" CURRENT_PI_PATH="" CURRENT_PI_VERSION="" TS_PI_DETECTED=0 ADOPT_TS=0 ADOPT_CANONICAL=0 FINAL_BIN_NAME="mao" INSTALL_BIN_PATH="" LEGACY_ALIAS_PATH="" LEGACY_TARGET_PATH="" LEGACY_MOVED_FROM="" LEGACY_MOVED_TO="" PATH_MARKER="# maoclaw installer PATH" LEGACY_PATH_MARKER="# pi-agent-rust installer PATH" PATH_UPDATED_FILES="" LEGACY_BRAND_TOKEN="pi""_agent_rust" AGENT_SKILL_NAME="maoclaw" LEGACY_AGENT_SKILL_NAME="pi-agent-rust" AGENT_SKILL_STATUS="pending" AGENT_SKILL_CLAUDE_PATH="" AGENT_SKILL_CODEX_PATH="" AGENT_SKILL_MARKER="maoclaw installer managed skill" LEGACY_AGENT_SKILL_MARKER="${LEGACY_BRAND_TOKEN} installer managed skill" MACOS_LAUNCHER_STATUS="pending" MACOS_LAUNCHER_MARKER="maoclaw installer managed macOS launcher" LEGACY_MACOS_LAUNCHER_MARKER="${LEGACY_BRAND_TOKEN} installer managed macOS launcher" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/maoclaw" LEGACY_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/pi-agent-rust" STATE_FILE="$STATE_DIR/install-state.env" STATE_VERSION="1" TMP="" LOCK_DIR="${PI_INSTALLER_LOCK_DIR:-/tmp/maoclaw-install.lock.d}" LOCKED=0 MIGRATION_MOVED=0 INSTALL_COMMITTED=0 INSTALL_SOURCE="release" CHECKSUM_STATUS="pending" SIGSTORE_STATUS="pending" COMPLETIONS_STATUS="pending" HAS_GUM=0 if command -v gum >/dev/null 2>&1 && [ -t 1 ]; then HAS_GUM=1 fi log() { [ "$QUIET" -eq 1 ] && return 0 echo -e "$*" >&2 } info() { [ "$QUIET" -eq 1 ] && return 0 if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 39 "→ $*" >&2 else echo -e "\033[0;34m→\033[0m $*" >&2 fi } ok() { [ "$QUIET" -eq 1 ] && return 0 if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 42 "✓ $*" >&2 else echo -e "\033[0;32m✓\033[0m $*" >&2 fi } warn() { [ "$QUIET" -eq 1 ] && return 0 if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 214 "⚠ $*" >&2 else echo -e "\033[1;33m⚠\033[0m $*" >&2 fi } err() { if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum style --foreground 196 "✗ $*" >&2 else echo -e "\033[0;31m✗\033[0m $*" >&2 fi } run_with_spinner() { local title="$1" shift if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ] && [ "$QUIET" -eq 0 ] && [ -t 1 ]; then gum spin --spinner dot --title "$title" -- "$@" else # Keep status text off stdout so callers can safely capture command output. info "$title" >&2 "$@" fi } version_timeout_cmd() { if command -v timeout >/dev/null 2>&1; then printf '%s\n' "timeout" return 0 fi if command -v gtimeout >/dev/null 2>&1; then printf '%s\n' "gtimeout" return 0 fi printf '%s\n' "" } run_command_with_timeout_capture() { local timeout_seconds="$1" shift local timeout_cmd="" timeout_cmd="$(version_timeout_cmd)" if [ -n "$timeout_cmd" ]; then if "$timeout_cmd" "$timeout_seconds" "$@"; then return 0 else local timeout_rc=$? # Broken timeout wrappers may return 127; fall back to internal timeout handling. if [ "$timeout_rc" -ne 127 ]; then return "$timeout_rc" fi fi fi local out_file="" out_file="$(mktemp 2>/dev/null || true)" if [ -z "$out_file" ]; then return 125 fi local timed_out_file="" timed_out_file="$(mktemp 2>/dev/null || true)" if [ -z "$timed_out_file" ]; then rm -f "$out_file" 2>/dev/null || true return 125 fi : > "$timed_out_file" "$@" > "$out_file" 2>/dev/null & local cmd_pid=$! ( sleep "$timeout_seconds" if kill -0 "$cmd_pid" 2>/dev/null; then printf '1\n' > "$timed_out_file" kill "$cmd_pid" >/dev/null 2>&1 || true sleep 1 kill -9 "$cmd_pid" >/dev/null 2>&1 || true fi ) & local watchdog_pid=$! local cmd_rc=0 if wait "$cmd_pid" 2>/dev/null; then cmd_rc=0 else cmd_rc=$? fi kill "$watchdog_pid" >/dev/null 2>&1 || true wait "$watchdog_pid" 2>/dev/null || true local timed_out=0 if [ -s "$timed_out_file" ]; then timed_out=1 fi if [ -f "$out_file" ]; then cat "$out_file" rm -f "$out_file" 2>/dev/null || true fi rm -f "$timed_out_file" 2>/dev/null || true if [ "$timed_out" -eq 1 ]; then return 124 fi return "$cmd_rc" } capture_version_line() { local bin_path="$1" local out="" out="$(run_command_with_timeout_capture 2 "$bin_path" --version || true)" out="$(printf '%s\n' "$out" | head -1)" printf '%s\n' "$out" } setup_proxy() { PROXY_ARGS=() PROXY_SOURCE="" local https_proxy_value="${HTTPS_PROXY:-${https_proxy:-}}" local http_proxy_value="${HTTP_PROXY:-${http_proxy:-}}" if [ -n "$https_proxy_value" ]; then PROXY_ARGS=(--proxy "$https_proxy_value") PROXY_SOURCE="$https_proxy_value" info "Using HTTPS proxy from environment" return 0 fi if [ -n "$http_proxy_value" ]; then PROXY_ARGS=(--proxy "$http_proxy_value") PROXY_SOURCE="$http_proxy_value" info "Using HTTP proxy from environment" return 0 fi } is_local_resource_ref() { local ref="${1:-}" if [ -z "$ref" ]; then return 1 fi if [[ "$ref" != *"://"* ]]; then # Plain paths (relative or absolute) are treated as local filesystem refs. return 0 fi case "$ref" in file://*) return 0 ;; *) return 1 ;; esac } resource_to_local_path() { local ref="$1" case "$ref" in file://*) local path="${ref#file://}" path="${path%%\?*}" path="${path%%#*}" printf '%s\n' "$path" ;; *) printf '%s\n' "$ref" ;; esac } redact_proxy_value() { local raw="${1:-}" if [ -z "$raw" ]; then printf '%s\n' "$raw" return 0 fi if [[ "$raw" == *"://"* ]]; then local scheme="${raw%%://*}" local rest="${raw#*://}" local host="${rest##*@}" if [ "$host" != "$rest" ]; then printf '%s\n' "${scheme}://***@${host}" return 0 fi fi if [[ "$raw" == *"@"* ]] && [[ "$raw" == *":"* ]]; then local host="${raw##*@}" printf '%s\n' "***@${host}" return 0 fi printf '%s\n' "$raw" } ensure_network_allowed() { local url="$1" local context="$2" if [ "$OFFLINE" -eq 1 ] && ! is_local_resource_ref "$url"; then err "Offline mode forbids network access for ${context}: $url" return 1 fi return 0 } fetch_url_to_file() { local url="$1" local output_path="$2" local context="${3:-resource}" local connect_timeout="${PI_INSTALLER_CONNECT_TIMEOUT:-10}" local max_time="${PI_INSTALLER_MAX_TIME:-180}" local retries="${PI_INSTALLER_RETRIES:-2}" local retry_delay="${PI_INSTALLER_RETRY_DELAY:-1}" if ! ensure_network_allowed "$url" "$context"; then return 1 fi case "$context" in "agent skill") connect_timeout="${PI_INSTALLER_AGENT_SKILL_CONNECT_TIMEOUT:-3}" max_time="${PI_INSTALLER_AGENT_SKILL_MAX_TIME:-8}" retries="${PI_INSTALLER_AGENT_SKILL_RETRIES:-0}" ;; "release artifact") connect_timeout="${PI_INSTALLER_ARTIFACT_CONNECT_TIMEOUT:-10}" max_time="${PI_INSTALLER_ARTIFACT_MAX_TIME:-240}" retries="${PI_INSTALLER_ARTIFACT_RETRIES:-2}" ;; "release checksum manifest"|"checksum file"|"derived checksum file"|"sigstore bundle") connect_timeout="${PI_INSTALLER_META_CONNECT_TIMEOUT:-5}" max_time="${PI_INSTALLER_META_MAX_TIME:-20}" retries="${PI_INSTALLER_META_RETRIES:-2}" ;; esac if is_local_resource_ref "$url"; then local local_path local_path="$(resource_to_local_path "$url")" if [ ! -e "$local_path" ]; then err "Local ${context} not found: $local_path" return 1 fi cp "$local_path" "$output_path" return 0 fi curl -fsSL ${PROXY_ARGS[@]+"${PROXY_ARGS[@]}"} \ --connect-timeout "$connect_timeout" \ --max-time "$max_time" \ --retry "$retries" \ --retry-delay "$retry_delay" \ --retry-connrefused \ "$url" -o "$output_path" } fetch_url_to_stdout() { local url="$1" local context="${2:-resource}" local connect_timeout="${PI_INSTALLER_CONNECT_TIMEOUT:-10}" local max_time="${PI_INSTALLER_MAX_TIME:-180}" local retries="${PI_INSTALLER_RETRIES:-2}" local retry_delay="${PI_INSTALLER_RETRY_DELAY:-1}" if ! ensure_network_allowed "$url" "$context"; then return 1 fi case "$context" in "agent skill") connect_timeout="${PI_INSTALLER_AGENT_SKILL_CONNECT_TIMEOUT:-3}" max_time="${PI_INSTALLER_AGENT_SKILL_MAX_TIME:-8}" retries="${PI_INSTALLER_AGENT_SKILL_RETRIES:-0}" ;; "release checksum manifest"|"checksum file"|"derived checksum file"|"sigstore bundle") connect_timeout="${PI_INSTALLER_META_CONNECT_TIMEOUT:-5}" max_time="${PI_INSTALLER_META_MAX_TIME:-20}" retries="${PI_INSTALLER_META_RETRIES:-2}" ;; esac if is_local_resource_ref "$url"; then local local_path local_path="$(resource_to_local_path "$url")" if [ ! -f "$local_path" ]; then err "Local ${context} not found: $local_path" return 1 fi cat "$local_path" return 0 fi curl -fsSL ${PROXY_ARGS[@]+"${PROXY_ARGS[@]}"} \ --connect-timeout "$connect_timeout" \ --max-time "$max_time" \ --retry "$retries" \ --retry-delay "$retry_delay" \ --retry-connrefused \ "$url" } fetch_effective_url() { local url="$1" local context="${2:-resource}" local connect_timeout="${PI_INSTALLER_CONNECT_TIMEOUT:-10}" local max_time="${PI_INSTALLER_MAX_TIME:-180}" local retries="${PI_INSTALLER_RETRIES:-2}" local retry_delay="${PI_INSTALLER_RETRY_DELAY:-1}" if ! ensure_network_allowed "$url" "$context"; then return 1 fi case "$context" in "agent skill") connect_timeout="${PI_INSTALLER_AGENT_SKILL_CONNECT_TIMEOUT:-3}" max_time="${PI_INSTALLER_AGENT_SKILL_MAX_TIME:-8}" retries="${PI_INSTALLER_AGENT_SKILL_RETRIES:-0}" ;; "release checksum manifest"|"checksum file"|"derived checksum file"|"sigstore bundle") connect_timeout="${PI_INSTALLER_META_CONNECT_TIMEOUT:-5}" max_time="${PI_INSTALLER_META_MAX_TIME:-20}" retries="${PI_INSTALLER_META_RETRIES:-2}" ;; esac if is_local_resource_ref "$url"; then printf '%s\n' "$url" return 0 fi curl -fsSL -o /dev/null -w '%{url_effective}' ${PROXY_ARGS[@]+"${PROXY_ARGS[@]}"} \ --connect-timeout "$connect_timeout" \ --max-time "$max_time" \ --retry "$retries" \ --retry-delay "$retry_delay" \ --retry-connrefused \ "$url" } probe_url_head() { local url="$1" local context="${2:-resource}" if ! ensure_network_allowed "$url" "$context"; then return 1 fi if is_local_resource_ref "$url"; then local local_path local_path="$(resource_to_local_path "$url")" [ -e "$local_path" ] return $? fi curl -fsSLI ${PROXY_ARGS[@]+"${PROXY_ARGS[@]}"} --connect-timeout 5 --max-time 10 "$url" >/dev/null 2>&1 } remove_path_recursively() { local target="$1" if [ -z "$target" ]; then return 1 fi if [ ! -e "$target" ] && [ ! -L "$target" ]; then return 0 fi if [ -L "$target" ] || [ -f "$target" ] || [ -p "$target" ] || [ -S "$target" ] || [ -b "$target" ] || [ -c "$target" ]; then rm -f "$target" return $? fi if [ -d "$target" ]; then local child="" while IFS= read -r -d '' child; do remove_path_recursively "$child" || return 1 done < <(find "$target" -mindepth 1 -maxdepth 1 -print0 2>/dev/null) rmdir "$target" 2>/dev/null || return 1 return 0 fi return 1 } maoclaw_ascii_logo() { cat <<'ASCII' ███╗ ███╗ █████╗ ██████╗ ██████╗ ██╗ █████╗ ██╗ ██╗ ████╗ ████║ ██╔══██╗ ██╔═══██╗ ██╔════╝ ██║ ██╔══██╗██║ ██║ ██╔████╔██║ ███████║ ██║ ██║ ██║ ██║ ███████║██║ █╗ ██║ ██║╚██╔╝██║ ██╔══██║ ██║ ██║ ██║ ██║ ██╔══██║██║███╗██║ ██║ ╚═╝ ██║ ██║ ██║ ╚██████╔╝ ╚██████╗ ███████╗██║ ██║╚███╔███╔╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ 猫爪 /\_/\ .-( o.o )-. / (___) \ / \ ASCII } maoclaw_ascii_logo_normalized() { local logo="$1" local line="" local max_width=0 while IFS= read -r line; do if [ "${#line}" -gt "$max_width" ]; then max_width="${#line}" fi done <<< "$logo" while IFS= read -r line; do printf '%-*s\n' "$max_width" "$line" done <<< "$logo" } maoclaw_ascii_logo_gum() { local logo="$1" local line="" local idx=0 local styled="" while IFS= read -r line; do local color=51 case "$idx" in 0|1) color=159 ;; 2) color=123 ;; 3) color=117 ;; 4) color=111 ;; 5) color=75 ;; 6) color=69 ;; 7) color=63 ;; 8|9) color=223 ;; 10) color=159 ;; 11) color=117 ;; 12) color=111 ;; 13) color=75 ;; esac local rendered rendered="$(gum style --foreground "$color" --bold "$line")" if [ -z "$styled" ]; then styled="$rendered" else styled="${styled}"$'\n'"$rendered" fi idx=$((idx + 1)) done <<< "$logo" printf '%s\n' "$styled" } maoclaw_ascii_logo_ansi() { local logo="$1" local line="" local idx=0 while IFS= read -r line; do local color=51 case "$idx" in 0|1) color=159 ;; 2) color=123 ;; 3) color=117 ;; 4) color=111 ;; 5) color=75 ;; 6) color=69 ;; 7) color=63 ;; 8|9) color=223 ;; 10) color=159 ;; 11) color=117 ;; 12) color=111 ;; 13) color=75 ;; esac printf '\033[1;38;5;%sm%s\033[0m\n' "$color" "$line" idx=$((idx + 1)) done <<< "$logo" } usage() { cat <<'USAGE' Usage: install.sh [options] Options: --version vX.Y.Z Install a specific release tag --dest DIR Install directory (default: ~/.local/bin) --system Install to /usr/local/bin --easy-mode Add install dir to PATH in shell rc files --artifact-url URL Install from explicit release artifact URL --checksum HEX Expected SHA256 for explicit artifact --checksum-url URL URL to checksum file or manifest --sigstore-bundle-url URL URL to Sigstore bundle (.sigstore.json) --from-source Build from source instead of downloading release binary --verify Run `mao --version` after install --no-verify Skip checksum + signature verification --offline [TARBALL] Offline mode; optional local artifact path --completions SHELL Install shell completions for auto|off|bash|zsh|fish --no-completions Skip shell completion installation --no-agent-skills Skip installing AI agent skill files for Claude/Codex --macos-launcher-path PATH Install a Finder-launchable maoclaw.command at PATH --no-macos-launcher Skip installing the macOS Finder launcher --yes, -y Non-interactive yes to prompts --adopt Auto-adopt Rust as canonical `pi` when TS pi is detected --keep-existing-pi Do not replace existing `pi`; install as `pi-rust` --legacy-alias NAME Alias name for migrated TypeScript pi (default: legacy-pi) --force Reinstall even if same version is already installed --quiet, -q Suppress non-error output --no-gum Disable gum formatting -h, --help Show this help USAGE } while [ $# -gt 0 ]; do case "$1" in --version) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --version requires a value" usage exit 1 fi VERSION="$2" shift 2 ;; --dest) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --dest requires a value" usage exit 1 fi DEST="$2" DEST_EXPLICIT=1 shift 2 ;; --system) SYSTEM=1 DEST="/usr/local/bin" DEST_EXPLICIT=1 shift ;; --easy-mode) EASY=1 shift ;; --artifact-url) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --artifact-url requires a value" usage exit 1 fi ARTIFACT_URL="$2" shift 2 ;; --checksum) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --checksum requires a value" usage exit 1 fi CHECKSUM="$2" shift 2 ;; --checksum-url) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --checksum-url requires a value" usage exit 1 fi CHECKSUM_URL="$2" shift 2 ;; --sigstore-bundle-url) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --sigstore-bundle-url requires a value" usage exit 1 fi SIGSTORE_BUNDLE_URL="$2" shift 2 ;; --from-source) FROM_SOURCE=1 shift ;; --verify) VERIFY=1 shift ;; --no-verify) NO_VERIFY=1 shift ;; --offline) OFFLINE=1 if [ $# -ge 2 ] && [[ "$2" != -* ]]; then OFFLINE_TARBALL="$2" shift 2 else shift fi ;; --completions) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --completions requires a value" usage exit 1 fi COMPLETIONS_MODE="$2" shift 2 ;; --no-completions) COMPLETIONS_MODE="off" shift ;; --no-agent-skills) AGENT_SKILLS_ENABLED=0 shift ;; --macos-launcher-path) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --macos-launcher-path requires a value" usage exit 1 fi MACOS_LAUNCHER_MODE="on" MACOS_LAUNCHER_PATH="$2" shift 2 ;; --no-macos-launcher) MACOS_LAUNCHER_MODE="off" shift ;; --yes|-y) YES=1 shift ;; --adopt) ADOPT_MODE="yes" shift ;; --keep-existing-pi) ADOPT_MODE="no" shift ;; --legacy-alias) if [ $# -lt 2 ] || [[ "$2" == -* ]]; then err "Option --legacy-alias requires a value" usage exit 1 fi LEGACY_ALIAS_NAME="$2" shift 2 ;; --force) FORCE_INSTALL=1 shift ;; --quiet|-q) QUIET=1 shift ;; --no-gum) NO_GUM=1 shift ;; -h|--help) usage exit 0 ;; *) err "Unknown option: $1" usage exit 1 ;; esac done show_header() { [ "$QUIET" -eq 1 ] && return 0 local logo local header_version local header_indent=" " if [ -n "$VERSION" ]; then header_version="$VERSION" else header_version="latest (auto)" fi logo="$(maoclaw_ascii_logo)" logo="$(maoclaw_ascii_logo_normalized "$logo")" if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then local styled_logo styled_logo="$(maoclaw_ascii_logo_gum "$logo")" styled_logo="${styled_logo}"$'\n' gum style \ --border double \ --border-foreground 45 \ --padding "1 3" \ --margin "1 0" \ "$styled_logo" \ "$(gum style --foreground 51 --bold "${header_indent}maoclaw / 猫爪")" \ "$(gum style --foreground 226 --bold "${header_indent}Install target version: ${header_version}")" \ "$(gum style --foreground 252 "${header_indent}High-performance local AI agent runtime")" \ "$(gum style --foreground 248 "${header_indent}Desktop app + CLI install path with guided first run")" \ "$(gum style --foreground 248 "${header_indent}Checksum verification by default | Optional Sigstore/cosign")" \ "$(gum style --foreground 111 "${header_indent}Repository: ${OWNER}/${REPO}")" else echo "" maoclaw_ascii_logo_ansi "$logo" echo "" echo -e "\033[1;38;5;51m${header_indent}maoclaw / 猫爪\033[0m" echo -e "\033[1;38;5;226m${header_indent}Install target version: ${header_version}\033[0m" echo -e "\033[0;38;5;252m${header_indent}High-performance local AI agent runtime\033[0m" echo -e "\033[0;38;5;248m${header_indent}Desktop app + CLI install path with guided first run\033[0m" echo -e "\033[0;38;5;248m${header_indent}Checksum verification by default | Optional Sigstore/cosign\033[0m" echo -e "\033[0;38;5;111m${header_indent}Repository: ${OWNER}/${REPO}\033[0m" echo "" fi } prompt_confirm() { local prompt="$1" local default_yes="${2:-0}" if [ "$YES" -eq 1 ]; then return 0 fi if [ ! -t 0 ]; then if [ "$default_yes" -eq 1 ]; then return 0 fi return 1 fi if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then gum confirm "$prompt" return $? fi local suffix="[y/N]" if [ "$default_yes" -eq 1 ]; then suffix="[Y/n]" fi printf "%s %s " "$prompt" "$suffix" local ans read -r ans || true if [ -z "$ans" ]; then if [ "$default_yes" -eq 1 ]; then return 0 fi return 1 fi case "$ans" in y|Y|yes|YES|Yes) return 0 ;; *) return 1 ;; esac } normalize_version() { if [ -z "$VERSION" ]; then return 0 fi if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.].+)?$ ]]; then VERSION="v$VERSION" fi } resolve_version() { normalize_version if [ -n "$VERSION" ]; then return 0 fi if [ -n "$ARTIFACT_URL" ] && [ "$FROM_SOURCE" -eq 0 ]; then VERSION="custom-artifact" info "Using custom artifact URL; skipping release tag resolution" return 0 fi if [ "$OFFLINE" -eq 1 ]; then err "Offline mode requires --version or --offline " exit 1 fi info "Resolving latest release tag" local latest_url="https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" local tag="" if command -v curl >/dev/null 2>&1; then tag=$(fetch_url_to_stdout "$latest_url" "release metadata" 2>/dev/null \ | grep '"tag_name":' \ | sed -E 's/.*"([^"]+)".*/\1/' \ || true) if [ -z "$tag" ]; then local redirect_target="" redirect_target=$(fetch_effective_url "https://github.com/${OWNER}/${REPO}/releases/latest" "release redirect" 2>/dev/null || true) if [[ "$redirect_target" =~ /tag/([^/?#]+) ]]; then tag="${BASH_REMATCH[1]}" fi fi fi if [ -z "$tag" ]; then err "Failed to resolve latest release tag" err "Pass --version vX.Y.Z or check network connectivity" exit 1 fi VERSION="$tag" ok "Resolved ${VERSION}" } detect_platform() { OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH="x86_64" ;; arm64|aarch64) ARCH="aarch64" ;; esac TARGET="" EXE_EXT="" if [ "$OS" = "linux" ]; then if [ "${PI_INSTALLER_TEST_FORCE_WSL:-0}" = "1" ] \ || grep -qi microsoft /proc/version 2>/dev/null \ || grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; then WSL_DETECTED=1 warn "WSL detected; terminal/path integration may need extra configuration" fi fi case "${OS}-${ARCH}" in linux-x86_64) TARGET="x86_64-unknown-linux-musl" ASSET_PLATFORM="linux-amd64" ;; linux-aarch64) TARGET="aarch64-unknown-linux-musl" ASSET_PLATFORM="linux-arm64" ;; darwin-x86_64) TARGET="x86_64-apple-darwin" ASSET_PLATFORM="darwin-amd64" ;; darwin-aarch64) TARGET="aarch64-apple-darwin" ASSET_PLATFORM="darwin-arm64" ;; msys_nt*-x86_64|mingw*-x86_64|cygwin_nt*-x86_64) TARGET="x86_64-pc-windows-msvc" ASSET_PLATFORM="windows-amd64" EXE_EXT=".exe" ;; *) ;; esac if [ -z "$TARGET" ] && [ "$FROM_SOURCE" -eq 0 ]; then warn "No prebuilt binary published for ${OS}/${ARCH}; switching to --from-source" FROM_SOURCE=1 fi } prepare_asset_urls() { if [ "$FROM_SOURCE" -eq 1 ]; then return 0 fi if [ -n "$ARTIFACT_URL" ]; then local cleaned="${ARTIFACT_URL%%\?*}" cleaned="${cleaned%%#*}" ASSET_NAME="$(basename "$cleaned")" if [ -z "$ASSET_NAME" ] || [ "$ASSET_NAME" = "/" ] || [ "$ASSET_NAME" = "." ]; then err "Could not infer artifact name from --artifact-url: $ARTIFACT_URL" exit 1 fi else ASSET_NAME="pi-${VERSION}-${TARGET}${EXE_EXT}" fi SHA_URL="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/SHA256SUMS" } ensure_dest_dir() { mkdir -p "$DEST" 2>/dev/null || true if [ ! -d "$DEST" ]; then err "Install directory does not exist and could not be created: $DEST" exit 1 fi if [ ! -w "$DEST" ]; then err "No write permission for install directory: $DEST" if [ "$SYSTEM" -eq 1 ]; then err "Re-run with sudo for --system installs" else err "Choose a writable directory with --dest" fi exit 1 fi local resolved_dest="" if resolved_dest="$(cd "$DEST" 2>/dev/null && pwd -P)"; then DEST="$resolved_dest" fi } check_disk_space() { local min_kb=20480 local path="$DEST" if [ ! -d "$path" ]; then path=$(dirname "$path") fi if command -v df >/dev/null 2>&1; then local avail_kb avail_kb=$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $4}') if [ -n "$avail_kb" ] && [ "$avail_kb" -lt "$min_kb" ]; then err "Insufficient disk space in $path (need at least 20MB free)" exit 1 fi else warn "df not found; skipping disk space preflight check" fi } check_existing_install() { local existing="$DEST/pi" if [ -x "$existing" ]; then local current current="$(capture_version_line "$existing")" if [ -n "$current" ]; then info "Existing pi detected at $existing: $current" fi fi } check_network_preflight() { if [ "$OFFLINE" -eq 1 ]; then info "Offline mode enabled; skipping network preflight" return 0 fi if ! command -v curl >/dev/null 2>&1; then warn "curl not found; skipping network preflight check" return 0 fi local probe_url="" if [ "$FROM_SOURCE" -eq 1 ]; then probe_url="https://github.com/${OWNER}/${REPO}" elif [ -n "$ARTIFACT_URL" ]; then probe_url="$ARTIFACT_URL" else probe_url="$SHA_URL" fi if [ -z "$probe_url" ]; then return 0 fi if ! probe_url_head "$probe_url" "network preflight probe"; then warn "Network preflight failed for $probe_url" warn "Continuing; install may still succeed on retry" fi } preflight_checks() { info "Running installer preflight checks" check_disk_space check_existing_install check_network_preflight } validate_options() { case "$AGENT_SKILLS_ENABLED" in 0|1) ;; *) warn "Invalid AGENT_SKILLS_ENABLED value '$AGENT_SKILLS_ENABLED'; defaulting to 1" AGENT_SKILLS_ENABLED=1 ;; esac case "$COMPLETIONS_MODE" in auto|off|bash|zsh|fish) ;; *) err "Invalid --completions value '$COMPLETIONS_MODE' (expected auto|off|bash|zsh|fish)" exit 1 ;; esac case "$MACOS_LAUNCHER_MODE" in auto|on|off) ;; *) warn "Invalid PI_INSTALLER_MACOS_LAUNCHER_MODE value '$MACOS_LAUNCHER_MODE'; defaulting to auto" MACOS_LAUNCHER_MODE="auto" ;; esac if [ -n "$MACOS_LAUNCHER_PATH" ] && [[ "$MACOS_LAUNCHER_PATH" != *.command ]]; then err "--macos-launcher-path must end with .command" exit 1 fi if [ "$FROM_SOURCE" -eq 1 ] && [ -n "$ARTIFACT_URL" ]; then warn "--artifact-url is ignored with --from-source" fi if [ "$NO_VERIFY" -eq 1 ] && { [ -n "$CHECKSUM" ] || [ -n "$CHECKSUM_URL" ] || [ -n "$SIGSTORE_BUNDLE_URL" ]; }; then warn "--no-verify set; checksum/signature override flags are ignored" fi if [ -n "$CHECKSUM" ] && [[ ! "$CHECKSUM" =~ ^[0-9a-fA-F]{64}$ ]]; then err "--checksum must be a 64-character hex SHA256 digest" exit 1 fi if [ -n "$CHECKSUM" ] && [ -n "$CHECKSUM_URL" ]; then warn "Both --checksum and --checksum-url provided; --checksum takes precedence" fi if [ "$OFFLINE" -eq 1 ] && [ -n "$ARTIFACT_URL" ] && ! is_local_resource_ref "$ARTIFACT_URL"; then err "Offline mode requires a local --artifact-url path (or use --offline )" exit 1 fi if [ "$OFFLINE" -eq 1 ] && [ -n "$CHECKSUM_URL" ] && ! is_local_resource_ref "$CHECKSUM_URL"; then err "Offline mode requires a local --checksum-url path" exit 1 fi if [ "$OFFLINE" -eq 1 ] && [ -n "$SIGSTORE_BUNDLE_URL" ] && ! is_local_resource_ref "$SIGSTORE_BUNDLE_URL"; then err "Offline mode requires a local --sigstore-bundle-url path" exit 1 fi if [ "$OFFLINE" -eq 1 ] && [ -z "$OFFLINE_TARBALL" ] && [ -z "$ARTIFACT_URL" ] && [ "$FROM_SOURCE" -eq 0 ]; then err "Offline mode requires a local artifact path via --offline or --artifact-url " exit 1 fi if [ "$OFFLINE" -eq 1 ] && [ "$FROM_SOURCE" -eq 1 ]; then err "--offline cannot be combined with --from-source (source build needs network access)" exit 1 fi if [ -n "$OFFLINE_TARBALL" ]; then OFFLINE=1 if [ -n "$ARTIFACT_URL" ]; then err "Pass either --offline or --artifact-url, not both" exit 1 fi if ! is_local_resource_ref "$OFFLINE_TARBALL"; then err "--offline expects a local artifact path (absolute, relative, or file://)" exit 1 fi local offline_path offline_path="$(resource_to_local_path "$OFFLINE_TARBALL")" if [ ! -f "$offline_path" ]; then err "Offline artifact not found: $offline_path" exit 1 fi if [ -d "$(dirname "$offline_path")" ]; then local resolved_dir resolved_dir="$(cd "$(dirname "$offline_path")" && pwd -P)" offline_path="${resolved_dir}/$(basename "$offline_path")" fi OFFLINE_TARBALL="$offline_path" ARTIFACT_URL="file://${OFFLINE_TARBALL}" fi } check_dependencies() { if [ "$FROM_SOURCE" -eq 0 ]; then local needs_curl=1 if [ -n "$ARTIFACT_URL" ] && is_local_resource_ref "$ARTIFACT_URL" \ && { [ -z "$CHECKSUM_URL" ] || is_local_resource_ref "$CHECKSUM_URL"; } \ && { [ -z "$SIGSTORE_BUNDLE_URL" ] || is_local_resource_ref "$SIGSTORE_BUNDLE_URL"; }; then needs_curl=0 fi if [ "$needs_curl" -eq 1 ] && ! command -v curl >/dev/null 2>&1; then err "curl is required for release downloads" exit 1 fi fi if [ "$FROM_SOURCE" -eq 1 ]; then if ! command -v git >/dev/null 2>&1; then err "git is required for --from-source installs" exit 1 fi if ! command -v cargo >/dev/null 2>&1; then err "cargo is required for --from-source installs" err "Install Rust nightly first: https://rustup.rs" exit 1 fi fi } acquire_lock() { if mkdir "$LOCK_DIR" 2>/dev/null; then LOCKED=1 echo $$ > "$LOCK_DIR/pid" return 0 fi if [ -f "$LOCK_DIR/pid" ]; then local old_pid old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true) if [ -n "$old_pid" ] && ! kill -0 "$old_pid" 2>/dev/null; then rmdir "$LOCK_DIR" 2>/dev/null || true if mkdir "$LOCK_DIR" 2>/dev/null; then LOCKED=1 echo $$ > "$LOCK_DIR/pid" return 0 fi fi fi err "Another installer appears to be running: $LOCK_DIR" exit 1 } cleanup() { local exit_code=$? if [ "$exit_code" -ne 0 ] && [ "$MIGRATION_MOVED" -eq 1 ] && [ "$INSTALL_COMMITTED" -eq 0 ]; then if [ -n "$LEGACY_MOVED_FROM" ] && [ -n "$LEGACY_MOVED_TO" ] && [ -e "$LEGACY_MOVED_TO" ] && [ ! -e "$LEGACY_MOVED_FROM" ]; then mv "$LEGACY_MOVED_TO" "$LEGACY_MOVED_FROM" 2>/dev/null || true warn "Rolled back legacy pi preservation due to installer failure" fi fi if [ -n "$TMP" ] && [ -d "$TMP" ]; then remove_path_recursively "$TMP" 2>/dev/null || true fi if [ "$LOCKED" -eq 1 ]; then rm -f "$LOCK_DIR/pid" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || true fi trap - EXIT exit "$exit_code" } trap cleanup EXIT is_rust_pi_output() { local out="$1" [[ "$out" =~ ^(pi|mao)[[:space:]][0-9]+\.[0-9]+\.[0-9]+[[:space:]]\( ]] } looks_like_node_script() { local path="$1" [ -f "$path" ] || return 1 if [[ "$path" == *.js ]] || [[ "$path" == *node_modules* ]]; then return 0 fi local head_line head_line=$(head -n 1 "$path" 2>/dev/null || true) if [[ "$head_line" == *node* ]]; then return 0 fi return 1 } canonicalize_existing_path() { local path="$1" [ -n "$path" ] || return 1 local dir base dir="$(dirname "$path")" base="$(basename "$path")" dir="$(cd "$dir" 2>/dev/null && pwd -P)" || return 1 printf '%s/%s\n' "$dir" "$base" } canonicalize_dir_path() { local path="$1" [ -n "$path" ] || return 1 cd "$path" 2>/dev/null && pwd -P } same_physical_path() { local left="$1" local right="$2" [ -n "$left" ] || return 1 [ -n "$right" ] || return 1 local left_canon="" right_canon="" left_canon="$(canonicalize_existing_path "$left" 2>/dev/null || true)" right_canon="$(canonicalize_existing_path "$right" 2>/dev/null || true)" [ -n "$left_canon" ] || return 1 [ -n "$right_canon" ] || return 1 [ "$left_canon" = "$right_canon" ] } same_physical_dir() { local left="$1" local right="$2" [ -n "$left" ] || return 1 [ -n "$right" ] || return 1 local left_canon="" right_canon="" left_canon="$(canonicalize_dir_path "$left" 2>/dev/null || true)" right_canon="$(canonicalize_dir_path "$right" 2>/dev/null || true)" [ -n "$left_canon" ] || return 1 [ -n "$right_canon" ] || return 1 [ "$left_canon" = "$right_canon" ] } detect_existing_pi() { CURRENT_PI_PATH=$(command -v pi 2>/dev/null || true) CURRENT_PI_VERSION="" TS_PI_DETECTED=0 if [ -z "$CURRENT_PI_PATH" ]; then return 0 fi CURRENT_PI_VERSION="$(capture_version_line "$CURRENT_PI_PATH")" if is_rust_pi_output "$CURRENT_PI_VERSION"; then TS_PI_DETECTED=0 return 0 fi if looks_like_node_script "$CURRENT_PI_PATH"; then TS_PI_DETECTED=1 return 0 fi if command -v npm >/dev/null 2>&1; then if npm list -g --depth=0 @mariozechner/pi-coding-agent >/dev/null 2>&1; then TS_PI_DETECTED=1 return 0 fi fi if [ -n "$CURRENT_PI_VERSION" ] && ! is_rust_pi_output "$CURRENT_PI_VERSION"; then TS_PI_DETECTED=1 fi } choose_adoption_mode() { ADOPT_TS=0 ADOPT_CANONICAL=0 FINAL_BIN_NAME="mao" if [ "$TS_PI_DETECTED" -eq 0 ]; then return 0 fi info "Detected existing non-Rust pi command at: $CURRENT_PI_PATH" if [ -n "$CURRENT_PI_VERSION" ]; then info "Existing pi reports: $CURRENT_PI_VERSION" fi if [ "$ADOPT_MODE" = "ask" ]; then info "Primary maoclaw CLI now installs as 'mao'; the existing pi command can stay unchanged." return 0 fi local decision="" case "$ADOPT_MODE" in yes) decision="yes" ;; no) decision="no" ;; ask) if prompt_confirm "Install Rust Pi as canonical 'pi' and preserve existing one as '${LEGACY_ALIAS_NAME}'?" 0; then decision="yes" else decision="no" fi ;; *) decision="no" ;; esac if [ "$decision" = "yes" ]; then ADOPT_TS=1 ADOPT_CANONICAL=1 FINAL_BIN_NAME="pi" else ADOPT_TS=0 ADOPT_CANONICAL=0 FINAL_BIN_NAME="pi-rust" warn "Keeping existing pi untouched; Rust binary will be installed as ${FINAL_BIN_NAME}" fi } choose_dest_for_adoption() { if [ "$ADOPT_TS" -ne 1 ]; then return 0 fi local current_dir="" if [ -n "$CURRENT_PI_PATH" ]; then current_dir=$(dirname "$CURRENT_PI_PATH") fi if [ "$DEST_EXPLICIT" -eq 1 ]; then if [ -n "$current_dir" ] && same_physical_dir "$DEST" "$current_dir"; then ADOPT_CANONICAL=1 else ADOPT_CANONICAL=0 fi return 0 fi if [ -z "$CURRENT_PI_PATH" ]; then return 0 fi if [ -w "$current_dir" ]; then DEST="$current_dir" ADOPT_CANONICAL=1 info "Using existing pi directory for canonical replacement: $DEST" return 0 fi ADOPT_CANONICAL=0 warn "Cannot write to existing pi directory: $current_dir" warn "Will install to default destination: $DEST" warn "Enable --easy-mode to prepend that path for future shells" } ensure_install_target() { INSTALL_BIN_PATH="$DEST/$FINAL_BIN_NAME" } compute_sha256() { local file="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$file" | awk '{print $1}' return 0 fi if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$file" | awk '{print $1}' return 0 fi err "No SHA256 tool found (sha256sum or shasum)" return 1 } verify_download_checksum() { local artifact_file="$1" local asset_name="${2:-$ASSET_NAME}" local artifact_url="${3:-}" if [ "$NO_VERIFY" -eq 1 ]; then CHECKSUM_STATUS="skipped (--no-verify)" return 0 fi local checksum_file="$TMP/checksum.txt" local expected="" local checksum_source_kind="release-manifest" if [ -n "$CHECKSUM" ]; then expected="$CHECKSUM" checksum_source_kind="inline" elif [ -n "$CHECKSUM_URL" ]; then if ! fetch_url_to_file "$CHECKSUM_URL" "$checksum_file" "checksum file"; then err "Failed to download checksum file: $CHECKSUM_URL" return 4 fi checksum_source_kind="custom-url" elif [ -n "$ARTIFACT_URL" ]; then local artifact_base="${artifact_url%%\?*}" artifact_base="${artifact_base%%#*}" local derived_checksum_url="${artifact_base}.sha256" if ! fetch_url_to_file "$derived_checksum_url" "$checksum_file" "derived checksum file"; then err "Failed to download checksum file: $derived_checksum_url" err "Provide --checksum or --checksum-url for custom artifact installs" return 4 fi checksum_source_kind="artifact-derived" else if ! fetch_url_to_file "$SHA_URL" "$checksum_file" "release checksum manifest"; then warn "No SHA256SUMS found in release; skipping checksum verification" CHECKSUM_STATUS="skipped (no SHA256SUMS in release)" return 0 fi fi if [ -z "$expected" ]; then expected=$(awk -v name="$asset_name" ' $1 ~ /^[0-9a-fA-F]{64}$/ { file=$2 sub(/^\*/, "", file) sub(/^\.\//, "", file) if (file == name) { print $1 exit } } ' "$checksum_file") if [ -z "$expected" ]; then if [ "$checksum_source_kind" != "release-manifest" ]; then local checksum_count checksum_count=$(awk '$1 ~ /^[0-9a-fA-F]{64}$/ { c += 1 } END { print c + 0 }' "$checksum_file") if [ "$checksum_count" -eq 1 ]; then expected=$(awk '$1 ~ /^[0-9a-fA-F]{64}$/ { print $1; exit }' "$checksum_file") fi fi fi if [ -z "$expected" ]; then CHECKSUM_STATUS="failed (missing checksum entry)" return 2 fi fi local actual actual=$(compute_sha256 "$artifact_file") if [ "$actual" != "$expected" ]; then CHECKSUM_STATUS="failed (mismatch)" err "Checksum mismatch for $asset_name" err "Expected: $expected" err "Actual: $actual" return 3 fi local source_desc="SHA256SUMS" if [ -n "$CHECKSUM" ]; then source_desc="--checksum" elif [ -n "$CHECKSUM_URL" ]; then source_desc="--checksum-url" elif [ -n "$ARTIFACT_URL" ]; then source_desc="artifact .sha256" fi CHECKSUM_STATUS="verified (${source_desc})" ok "Checksum verified for ${asset_name}" if [ -n "$artifact_url" ] && [ "$QUIET" -eq 0 ]; then log " checksum source: $source_desc" fi } verify_sigstore_bundle() { local artifact_file="$1" local artifact_url="$2" if [ "$NO_VERIFY" -eq 1 ]; then SIGSTORE_STATUS="skipped (--no-verify)" return 0 fi if ! command -v cosign >/dev/null 2>&1; then SIGSTORE_STATUS="skipped (cosign not found)" warn "cosign not found; skipping signature verification" return 0 fi local bundle_url="$SIGSTORE_BUNDLE_URL" if [ "$OFFLINE" -eq 1 ] && [ -z "$bundle_url" ]; then SIGSTORE_STATUS="skipped (offline; bundle not provided)" warn "Offline mode: skipping signature verification without --sigstore-bundle-url" return 0 fi if [ -z "$bundle_url" ]; then local artifact_base="${artifact_url%%\?*}" artifact_base="${artifact_base%%#*}" bundle_url="${artifact_base}.sigstore.json" fi local bundle_file bundle_file="$TMP/$(basename "${bundle_url%%\?*}")" if ! fetch_url_to_file "$bundle_url" "$bundle_file" "sigstore bundle"; then SIGSTORE_STATUS="skipped (bundle unavailable)" warn "Sigstore bundle not found; skipping signature verification" return 0 fi if ! cosign verify-blob \ --bundle "$bundle_file" \ --certificate-identity-regexp "$COSIGN_IDENTITY_RE" \ --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \ "$artifact_file"; then SIGSTORE_STATUS="failed" err "Sigstore verification failed for $(basename "$artifact_file")" return 1 fi SIGSTORE_STATUS="verified" ok "Signature verified (cosign)" } extract_release_artifact() { local candidate="$1" local artifact_file="$2" if [[ "$candidate" == *.tar.xz ]]; then if ! command -v tar >/dev/null 2>&1; then warn "tar is not available to extract $candidate" return 1 fi if ! command -v xz >/dev/null 2>&1; then warn "xz is not available to extract $candidate" return 1 fi local extract_dir="$TMP/extract-${candidate//\//_}" mkdir -p "$extract_dir" if ! tar -xJf "$artifact_file" -C "$extract_dir"; then warn "Failed to extract archive: $candidate" return 1 fi local found_bin="" found_bin="$(find "$extract_dir" -type f \( -name "mao${EXE_EXT}" -o -name "maoclaw${EXE_EXT}" -o -name "pi${EXE_EXT}" -o -name "mao" -o -name "maoclaw" -o -name "pi" -o -name "mao.exe" -o -name "maoclaw.exe" -o -name "pi.exe" \) | head -1)" if [ -z "$found_bin" ]; then warn "archive '$candidate' did not contain a supported CLI binary" return 1 fi chmod +x "$found_bin" 2>/dev/null || true printf '%s\n' "$found_bin" return 0 fi if [[ "$candidate" == *.tar.gz ]] || [[ "$candidate" == *.tgz ]]; then if ! command -v tar >/dev/null 2>&1; then warn "tar is not available to extract $candidate" return 1 fi local extract_dir="$TMP/extract-${candidate//\//_}" mkdir -p "$extract_dir" if ! tar -xzf "$artifact_file" -C "$extract_dir"; then warn "Failed to extract archive: $candidate" return 1 fi local found_bin="" found_bin="$(find "$extract_dir" -type f \( -name "mao${EXE_EXT}" -o -name "maoclaw${EXE_EXT}" -o -name "pi${EXE_EXT}" -o -name "mao" -o -name "maoclaw" -o -name "pi" -o -name "mao.exe" -o -name "maoclaw.exe" -o -name "pi.exe" \) | head -1)" if [ -z "$found_bin" ]; then warn "archive '$candidate' did not contain a supported CLI binary" return 1 fi chmod +x "$found_bin" 2>/dev/null || true printf '%s\n' "$found_bin" return 0 fi if [[ "$candidate" == *.zip ]]; then if ! command -v unzip >/dev/null 2>&1; then warn "unzip is not available to extract $candidate" return 1 fi local extract_dir="$TMP/extract-${candidate//\//_}" mkdir -p "$extract_dir" if ! unzip -q "$artifact_file" -d "$extract_dir"; then warn "Failed to extract archive: $candidate" return 1 fi local found_bin="" found_bin="$(find "$extract_dir" -type f \( -name "mao${EXE_EXT}" -o -name "maoclaw${EXE_EXT}" -o -name "pi${EXE_EXT}" -o -name "mao" -o -name "maoclaw" -o -name "pi" -o -name "mao.exe" -o -name "maoclaw.exe" -o -name "pi.exe" \) | head -1)" if [ -z "$found_bin" ]; then warn "archive '$candidate' did not contain a supported CLI binary" return 1 fi chmod +x "$found_bin" 2>/dev/null || true printf '%s\n' "$found_bin" return 0 fi chmod +x "$artifact_file" 2>/dev/null || true printf '%s\n' "$artifact_file" } download_release_binary() { local candidates=() if [ -n "$ARTIFACT_URL" ]; then candidates+=("$ASSET_NAME|$ARTIFACT_URL") else # Try candidates in priority order. dsr bare-binary names first (most common # for local releases), then archive formats, then Rust target-triple names. local base_v="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}" local base_l="https://github.com/${OWNER}/${REPO}/releases/latest/download" # Prefer the branded release names first, then keep compatibility fallbacks. if [ -n "$ASSET_PLATFORM" ]; then local dsr_mao_name="mao_${ASSET_PLATFORM//-/_}${EXE_EXT}" candidates+=("${dsr_mao_name}|${base_v}/${dsr_mao_name}") local dsr_name="pi_${ASSET_PLATFORM//-/_}${EXE_EXT}" candidates+=("${dsr_name}|${base_v}/${dsr_name}") fi candidates+=("mao${EXE_EXT}|${base_v}/mao${EXE_EXT}") candidates+=("pi${EXE_EXT}|${base_v}/pi${EXE_EXT}") # Archive formats (GH Actions output) if [ -n "$ASSET_PLATFORM" ]; then if [ -n "$EXE_EXT" ]; then candidates+=("mao-${ASSET_PLATFORM}.zip|${base_v}/mao-${ASSET_PLATFORM}.zip") candidates+=("pi-${ASSET_PLATFORM}.zip|${base_v}/pi-${ASSET_PLATFORM}.zip") else candidates+=("mao-${ASSET_PLATFORM}.tar.xz|${base_v}/mao-${ASSET_PLATFORM}.tar.xz") candidates+=("mao-${ASSET_PLATFORM}.tar.gz|${base_v}/mao-${ASSET_PLATFORM}.tar.gz") candidates+=("pi-${ASSET_PLATFORM}.tar.xz|${base_v}/pi-${ASSET_PLATFORM}.tar.xz") candidates+=("pi-${ASSET_PLATFORM}.tar.gz|${base_v}/pi-${ASSET_PLATFORM}.tar.gz") fi fi # Rust target-triple naming candidates+=("mao-${TARGET}${EXE_EXT}|${base_v}/mao-${TARGET}${EXE_EXT}") candidates+=("mao-${OS}-${ARCH}${EXE_EXT}|${base_v}/mao-${OS}-${ARCH}${EXE_EXT}") candidates+=("pi-${TARGET}${EXE_EXT}|${base_v}/pi-${TARGET}${EXE_EXT}") candidates+=("pi-${OS}-${ARCH}${EXE_EXT}|${base_v}/pi-${OS}-${ARCH}${EXE_EXT}") fi local entry="" for entry in "${candidates[@]}"; do local candidate="${entry%%|*}" local candidate_url="${entry#*|}" local artifact_file="$TMP/$candidate" # Suppress stderr for candidate probing — 404s are expected as we try # multiple naming conventions. Only show errors for explicit --artifact-url. if ! fetch_url_to_file "$candidate_url" "$artifact_file" "release artifact" 2>/dev/null; then if [ -n "$ARTIFACT_URL" ]; then err "Failed to download artifact: $candidate_url" fi continue fi ASSET_NAME="$candidate" local checksum_rc=0 if verify_download_checksum "$artifact_file" "$candidate" "$candidate_url"; then : else checksum_rc=$? if [ "$checksum_rc" -eq 2 ]; then if [ -n "$ARTIFACT_URL" ] || [ -n "$CHECKSUM" ] || [ -n "$CHECKSUM_URL" ]; then err "No checksum entry found for $candidate" return "$checksum_rc" fi warn "No checksum entry for $candidate in SHA256SUMS; trying next candidate" continue fi return "$checksum_rc" fi if ! verify_sigstore_bundle "$artifact_file" "$candidate_url"; then return 5 fi local extracted="" extracted="$(extract_release_artifact "$candidate" "$artifact_file" || true)" if [ -n "$extracted" ] && [ -e "$extracted" ]; then printf '%s\n' "$extracted" return 0 fi done err "No downloadable release artifact found for version ${VERSION} and target ${TARGET}" return 1 } build_from_source() { if [ "$OFFLINE" -eq 1 ]; then err "Offline mode cannot build from source (network access required)" return 1 fi local src_dir="$TMP/src" git clone --depth 1 --branch "$VERSION" "https://github.com/${OWNER}/${REPO}.git" "$src_dir" >&2 (cd "$src_dir" && cargo build --release --locked --bin mao >&2) local built_bin="$src_dir/target/release/mao${EXE_EXT}" if [ ! -x "$built_bin" ]; then err "Source build succeeded but binary was not found: $built_bin" return 1 fi printf '%s\n' "$built_bin" } install_binary_file() { local source_bin="$1" install -m 0755 "$source_bin" "$INSTALL_BIN_PATH" ok "Installed $FINAL_BIN_NAME to $INSTALL_BIN_PATH" } choose_legacy_alias_path() { local candidate="$DEST/$LEGACY_ALIAS_NAME" if [ ! -e "$candidate" ]; then LEGACY_ALIAS_PATH="$candidate" return 0 fi if grep -Eq "maoclaw installer managed alias|${LEGACY_BRAND_TOKEN} installer managed alias" "$candidate" 2>/dev/null; then LEGACY_ALIAS_PATH="$candidate" return 0 fi local alt="$DEST/${LEGACY_ALIAS_NAME}-ts" if [ ! -e "$alt" ]; then warn "Existing $candidate is not installer-managed; using ${LEGACY_ALIAS_NAME}-ts instead" LEGACY_ALIAS_PATH="$alt" return 0 fi local idx=1 while :; do alt="$DEST/${LEGACY_ALIAS_NAME}-ts-${idx}" if [ ! -e "$alt" ]; then warn "Using alternate legacy alias: $(basename "$alt")" LEGACY_ALIAS_PATH="$alt" return 0 fi idx=$((idx + 1)) done } create_legacy_alias_wrapper() { local alias_path="$1" local target_path="$2" if [ -z "$alias_path" ] || [ -z "$target_path" ]; then return 1 fi { printf '#!/usr/bin/env bash\n' printf '# maoclaw installer managed alias\n' printf 'set -euo pipefail\n' printf 'exec %q "$@"\n' "$target_path" } > "$alias_path" chmod 0755 "$alias_path" ok "Created legacy alias: $alias_path" } prepare_typescript_migration() { MIGRATION_MOVED=0 LEGACY_ALIAS_PATH="" LEGACY_TARGET_PATH="" LEGACY_MOVED_FROM="" LEGACY_MOVED_TO="" if [ "$ADOPT_TS" -ne 1 ]; then return 0 fi choose_legacy_alias_path if [ -z "$CURRENT_PI_PATH" ]; then warn "No existing pi command path found; skipping legacy alias creation" return 0 fi local current_real="$CURRENT_PI_PATH" if same_physical_path "$current_real" "$INSTALL_BIN_PATH" && [ -e "$current_real" ]; then local preserve_candidate="$DEST/.pi-legacy-typescript" if [ -e "$preserve_candidate" ]; then local stamp stamp=$(date +%Y%m%d%H%M%S) preserve_candidate="$DEST/.pi-legacy-typescript.${stamp}" fi mv "$current_real" "$preserve_candidate" LEGACY_MOVED_FROM="$current_real" LEGACY_MOVED_TO="$preserve_candidate" LEGACY_TARGET_PATH="$preserve_candidate" MIGRATION_MOVED=1 ok "Preserved existing pi binary at: $preserve_candidate" else LEGACY_TARGET_PATH="$current_real" info "Existing pi remains at: $current_real" fi create_legacy_alias_wrapper "$LEGACY_ALIAS_PATH" "$LEGACY_TARGET_PATH" } maybe_add_path() { case ":$PATH:" in *":$DEST:"*) return 0 ;; esac if [ "$EASY" -ne 1 ]; then warn "Add this directory to PATH to use installed binaries: $DEST" return 0 fi local updated="" for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do if [ -e "$rc" ] && [ ! -w "$rc" ]; then continue fi if [ ! -e "$rc" ]; then : > "$rc" fi if grep -F "$PATH_MARKER" "$rc" >/dev/null 2>&1; then continue fi printf "\nexport PATH=\"%s:\$PATH\" %s\n" "$DEST" "$PATH_MARKER" >> "$rc" if [ -z "$updated" ]; then updated="$rc" else updated="$updated:$rc" fi done PATH_UPDATED_FILES="$updated" if [ -n "$updated" ]; then ok "Updated PATH in shell rc files" warn "Restart your shell (or source rc files) to use updated PATH" else warn "Could not update PATH automatically; add $DEST manually" fi } detect_default_shell() { local shell_name="${SHELL:-}" [ -z "$shell_name" ] && return 1 shell_name=$(basename "$shell_name") case "$shell_name" in bash|zsh|fish) printf '%s\n' "$shell_name" return 0 ;; *) return 1 ;; esac } install_completions_for_shell() { local shell_name="$1" local bin="$INSTALL_BIN_PATH" if [ ! -x "$bin" ]; then COMPLETIONS_STATUS="skipped (binary missing)" return 1 fi local subcommand="" local probe_timeout="${PI_INSTALLER_COMPLETION_PROBE_TIMEOUT:-3}" local generation_timeout="${PI_INSTALLER_COMPLETION_CMD_TIMEOUT:-10}" # Prefer static command discovery from top-level --help (safe, fast path). # If that fails, fall back to legacy subcommand probes guarded by a timeout. local root_help="" local root_help_ok=0 local root_help_rc=0 local should_probe_subcommands=0 if root_help="$(run_command_with_timeout_capture "$probe_timeout" "$bin" --help)"; then root_help_ok=1 else root_help_rc=$? fi if [ "$root_help_ok" -eq 1 ]; then if printf '%s\n' "$root_help" | grep -Eq '^[[:space:]]+completions([[:space:]]|$)'; then subcommand="completions" elif printf '%s\n' "$root_help" | grep -Eq '^[[:space:]]+completion([[:space:]]|$)'; then subcommand="completion" elif printf '%s\n' "$root_help" | grep -Eq '^[[:space:]]*Commands:'; then # Help output is conclusive and lists no completion command. should_probe_subcommands=0 else # Help output was available but inconclusive for command discovery. should_probe_subcommands=1 fi else should_probe_subcommands=1 fi if [ "$should_probe_subcommands" -eq 1 ]; then # Help probe was unavailable or inconclusive: guard runtime probes with timeout. local probe_rc=0 local probe_timed_out=0 if run_command_with_timeout_capture "$probe_timeout" "$bin" completions --help >/dev/null 2>&1; then subcommand="completions" else probe_rc=$? if [ "$probe_rc" -eq 124 ] || [ "$probe_rc" -eq 137 ]; then probe_timed_out=1 fi if run_command_with_timeout_capture "$probe_timeout" "$bin" completion --help >/dev/null 2>&1; then subcommand="completion" else probe_rc=$? if [ "$probe_rc" -eq 124 ] || [ "$probe_rc" -eq 137 ]; then probe_timed_out=1 fi fi fi if [ -z "$subcommand" ] && [ "$probe_timed_out" -eq 1 ]; then COMPLETIONS_STATUS="failed (completion probe timed out)" warn "Shell completions probe timed out; skipping completion installation" return 1 fi fi if [ -z "$subcommand" ] && [ "$root_help_ok" -eq 0 ] && [ "$root_help_rc" -eq 125 ]; then COMPLETIONS_STATUS="skipped (completion probe unavailable)" info "Shell completions: skipped (unable to safely probe completion support)" return 0 fi if [ -z "$subcommand" ]; then COMPLETIONS_STATUS="skipped (unsupported by this ${FINAL_BIN_NAME} build)" info "Shell completions: skipped (binary has no completion subcommand)" return 0 fi local target="" case "$shell_name" in bash) target="${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/${FINAL_BIN_NAME}" ;; zsh) target="${XDG_DATA_HOME:-$HOME/.local/share}/zsh/site-functions/_${FINAL_BIN_NAME}" ;; fish) target="${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions/${FINAL_BIN_NAME}.fish" ;; *) COMPLETIONS_STATUS="skipped (unsupported shell: $shell_name)" return 1 ;; esac if ! mkdir -p "$(dirname "$target")" 2>/dev/null; then COMPLETIONS_STATUS="failed (cannot create completion dir)" warn "Failed to create shell completion directory for $shell_name" return 1 fi local completion_output local completion_rc=0 completion_output="$(run_command_with_timeout_capture "$generation_timeout" "$bin" "$subcommand" "$shell_name")" || completion_rc=$? if [ "$completion_rc" -ne 0 ]; then if [ "$completion_rc" -eq 124 ] || [ "$completion_rc" -eq 137 ]; then COMPLETIONS_STATUS="failed (completion generation timed out)" warn "Failed to generate $shell_name completions (timed out)" return 1 fi COMPLETIONS_STATUS="failed (completion generation error)" warn "Failed to generate $shell_name completions" return 1 fi if [ -z "$completion_output" ]; then COMPLETIONS_STATUS="failed (completion generation error)" warn "Failed to generate $shell_name completions" return 1 fi if ! printf '%s\n' "$completion_output" > "$target"; then COMPLETIONS_STATUS="failed (write error)" warn "Failed to write $shell_name completions to $target" return 1 fi ok "Installed $shell_name completions to $target" COMPLETIONS_STATUS="installed ($shell_name)" return 0 } maybe_install_completions() { if [ "$COMPLETIONS_MODE" = "off" ]; then COMPLETIONS_STATUS="skipped (--no-completions)" return 0 fi local shell_name="$COMPLETIONS_MODE" if [ "$shell_name" = "auto" ]; then if ! shell_name=$(detect_default_shell); then COMPLETIONS_STATUS="skipped (unknown shell)" info "Shell completions: skipped (unable to detect shell)" return 0 fi fi install_completions_for_shell "$shell_name" || true } should_install_macos_launcher() { [ "$OS" = "darwin" ] || return 1 case "$MACOS_LAUNCHER_MODE" in off) return 1 ;; auto|on) return 0 ;; esac return 1 } resolve_macos_launcher_path() { if [ -n "$MACOS_LAUNCHER_PATH" ]; then printf '%s\n' "$MACOS_LAUNCHER_PATH" return 0 fi printf '%s\n' "$HOME/Applications/maoclaw.command" } is_managed_macos_launcher() { local path="$1" [ -f "$path" ] || return 1 grep -Eq "$MACOS_LAUNCHER_MARKER|$LEGACY_MACOS_LAUNCHER_MARKER" "$path" 2>/dev/null } install_macos_launcher() { local launcher_path="$1" local launcher_dir="" launcher_dir="$(dirname "$launcher_path")" if ! mkdir -p "$launcher_dir" 2>/dev/null; then MACOS_LAUNCHER_STATUS="failed (cannot create launcher dir)" warn "Failed to create macOS launcher directory: $launcher_dir" return 1 fi if [ -e "$launcher_path" ] && ! is_managed_macos_launcher "$launcher_path"; then MACOS_LAUNCHER_STATUS="skipped (existing custom launcher)" warn "Skipping existing non-installer-managed launcher at $launcher_path" return 1 fi { printf '#!/bin/zsh\n' printf '# %s\n' "$MACOS_LAUNCHER_MARKER" printf 'set -euo pipefail\n' printf 'PI_BIN=%q\n' "$INSTALL_BIN_PATH" printf 'for rc in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do\n' printf ' if [ -f "$rc" ]; then\n' printf ' source "$rc" >/dev/null 2>&1 || true\n' printf ' fi\n' printf 'done\n' printf 'if [ ! -x "$PI_BIN" ]; then\n' printf ' echo "maoclaw launcher error: binary not found at $PI_BIN"\n' printf ' echo "Re-run install.sh to repair the local installation."\n' printf ' exit 1\n' printf 'fi\n' printf 'exec "$PI_BIN" "$@"\n' } > "$launcher_path" chmod 0755 "$launcher_path" MACOS_LAUNCHER_PATH="$launcher_path" MACOS_LAUNCHER_STATUS="installed" ok "Installed macOS Finder launcher to $launcher_path" } maybe_install_macos_launcher() { if ! should_install_macos_launcher; then if [ "$OS" = "darwin" ]; then MACOS_LAUNCHER_STATUS="skipped (--no-macos-launcher)" else MACOS_LAUNCHER_STATUS="not applicable" fi return 0 fi local launcher_path="" launcher_path="$(resolve_macos_launcher_path)" install_macos_launcher "$launcher_path" || true } is_expected_legacy_agent_settings_path() { local path="$1" local agent="$2" [ -n "$path" ] || return 1 case "$agent" in claude) case "$path" in "$HOME/.claude/settings.json"|"$HOME/.config/claude/settings.json"|"$HOME/Library/Application Support/Claude/settings.json") return 0 ;; esac ;; gemini) case "$path" in "$HOME/.gemini/settings.json"|"$HOME/.gemini-cli/settings.json") return 0 ;; esac ;; esac return 1 } cleanup_legacy_settings_entries() { local settings_file="$1" local hook_key="$2" local matcher="$3" local require_name="$4" shift 4 local bin_candidates=("$@") [ -f "$settings_file" ] || return 0 [ "${#bin_candidates[@]}" -gt 0 ] || return 0 command -v python3 >/dev/null 2>&1 || return 0 local py_result="" if ! py_result=$(python3 - "$settings_file" "$hook_key" "$matcher" "$require_name" "${bin_candidates[@]}" <<'PYEOF' import json import os import shlex import sys settings_file = sys.argv[1] hook_key = sys.argv[2] matcher = sys.argv[3] require_name = sys.argv[4] candidate_bins = [arg for arg in sys.argv[5:] if arg] if not candidate_bins: print("NO_CANDIDATES") raise SystemExit(0) def command_matches(command: str) -> bool: if not isinstance(command, str): return False cmd = command.strip() if not cmd: return False try: parts = shlex.split(cmd) except Exception: parts = cmd.split() if len(parts) != 1: return False first = parts[0] if not os.path.isabs(first): return False for bin_path in candidate_bins: if first == bin_path: return True try: if os.path.realpath(first) == os.path.realpath(bin_path): return True except Exception: pass return False try: with open(settings_file, "r", encoding="utf-8") as f: settings = json.load(f) except Exception: print("SKIP_INVALID_JSON") raise SystemExit(0) if not isinstance(settings, dict): print("SKIP_INVALID_JSON") raise SystemExit(0) hooks = settings.get("hooks") if not isinstance(hooks, dict): print("NO_HOOKS") raise SystemExit(0) entries = hooks.get(hook_key) if not isinstance(entries, list): print("NO_HOOKS") raise SystemExit(0) removed = 0 changed = False new_entries = [] for entry in entries: if isinstance(entry, dict) and entry.get("matcher") == matcher: existing_hooks = entry.get("hooks", []) if not isinstance(existing_hooks, list): existing_hooks = [] kept = [] for hook in existing_hooks: should_remove = False if isinstance(hook, dict): command = str(hook.get("command", "")) if require_name: if ( str(hook.get("name", "")) == require_name and str(hook.get("type", "")) == "command" and (set(hook.keys()) <= {"name", "type", "command", "timeout"}) and hook.get("timeout", 5000) in (5000, "5000") and command_matches(command) ): should_remove = True else: if ( str(hook.get("type", "")) == "command" and (set(hook.keys()) <= {"type", "command"}) and command_matches(command) ): should_remove = True if should_remove: removed += 1 changed = True continue kept.append(hook) if kept: entry["hooks"] = kept new_entries.append(entry) elif existing_hooks: changed = True else: new_entries.append(entry) if not changed: print("ALREADY_ABSENT") raise SystemExit(0) hooks[hook_key] = new_entries if not hooks[hook_key]: del hooks[hook_key] if not hooks: settings.pop("hooks", None) with open(settings_file, "w", encoding="utf-8") as f: json.dump(settings, f, indent=2) f.write("\n") print(f"REMOVED:{removed}") PYEOF ); then warn "Legacy settings cleanup failed for $settings_file" return 0 fi case "$py_result" in REMOVED:*) local count="${py_result#REMOVED:}" if [ "$count" -gt 0 ] 2>/dev/null; then ok "Removed ${count} legacy installer entries from $settings_file" fi ;; esac } cleanup_legacy_agent_settings() { local bin_candidates=() local recorded_bin="${PIAR_INSTALL_BIN:-}" local current_bin="${INSTALL_BIN_PATH:-}" if [ -n "$recorded_bin" ]; then bin_candidates+=("$recorded_bin") fi if [ -n "$current_bin" ] && [ "$current_bin" != "$recorded_bin" ]; then bin_candidates+=("$current_bin") fi [ "${#bin_candidates[@]}" -gt 0 ] || return 0 local claude_candidates=() if [ -n "${PIAR_CLAUDE_HOOK_SETTINGS:-}" ]; then claude_candidates+=("${PIAR_CLAUDE_HOOK_SETTINGS}") fi claude_candidates+=( "$HOME/.claude/settings.json" "$HOME/.config/claude/settings.json" "$HOME/Library/Application Support/Claude/settings.json" ) local gemini_candidates=() if [ -n "${PIAR_GEMINI_HOOK_SETTINGS:-}" ]; then gemini_candidates+=("${PIAR_GEMINI_HOOK_SETTINGS}") fi gemini_candidates+=( "$HOME/.gemini/settings.json" "$HOME/.gemini-cli/settings.json" ) local settings_path="" for settings_path in "${claude_candidates[@]}"; do if is_expected_legacy_agent_settings_path "$settings_path" "claude"; then cleanup_legacy_settings_entries "$settings_path" "PreToolUse" "Bash" "" "${bin_candidates[@]}" fi done for settings_path in "${gemini_candidates[@]}"; do if is_expected_legacy_agent_settings_path "$settings_path" "gemini"; then cleanup_legacy_settings_entries "$settings_path" "BeforeTool" "run_shell_command" "maoclaw" "${bin_candidates[@]}" cleanup_legacy_settings_entries "$settings_path" "BeforeTool" "run_shell_command" "pi-agent-rust" "${bin_candidates[@]}" fi done } is_installer_managed_skill_file() { local file="$1" [ -f "$file" ] || return 1 grep -Eq "$AGENT_SKILL_MARKER|$LEGACY_AGENT_SKILL_MARKER" "$file" 2>/dev/null } is_expected_skill_destination() { local destination="$1" [ -n "$destination" ] || return 1 case "$destination" in */skills/${AGENT_SKILL_NAME}|*/skills/${LEGACY_AGENT_SKILL_NAME}) return 0 ;; *) return 1 ;; esac } maoclaw_skill_inline_content() { cat <<'SKILL' --- name: maoclaw description: >- Speeds up maoclaw development and verification workflows. Use when editing providers, tools, sessions, extensions, installer/uninstaller logic, or triaging regressions in this repo. --- # maoclaw ## Use This Skill When - You are working inside `maoclaw` and need the fastest path to safe, verified edits. - You are touching provider/tool/session/extension behavior and need targeted triage. - You are changing installer/uninstaller/skill install behavior and need deterministic safety checks. - You need symptom-first debugging playbooks instead of ad-hoc command hunting. ## 60-Second Bootstrap ```bash export CARGO_TARGET_DIR="/data/tmp/maoclaw/${USER:-agent}" export TMPDIR="/data/tmp/maoclaw/${USER:-agent}/tmp" mkdir -p "$TMPDIR" rch exec -- cargo check --all-targets rch exec -- cargo clippy --all-targets -- -D warnings cargo fmt --check bash tests/installer_regression.sh ``` ## Symptom Router | Symptom | First 3 Commands | |---|---| | Provider stream/tool-call regression | `cargo test provider_streaming -- --nocapture` ; `rg -n "stream|tool|delta|event|SSE" src/providers src/sse.rs` ; `cargo test conformance` | | Session replay/index drift | `cargo test session -- --nocapture` ; `rg -n "Session|save|open|index|jsonl|sqlite" src/session.rs src/session_index.rs` ; `cargo test conformance` | | Extension policy/runtime failure | `cargo test extension -- --nocapture` ; `rg -n "policy|hostcall|capability|quickjs|deny|allow" src/extensions.rs src/extensions_js.rs` ; `cargo test conformance` | | Installer/uninstaller/skill issue | `bash tests/installer_regression.sh` ; `rg -n "AGENT_SKILL_STATUS|CHECKSUM_STATUS|SIGSTORE_STATUS|COMPLETIONS_STATUS" install.sh` ; `rg -n "managed skill|expected skill directory|PIAR_AGENT_SKILL" uninstall.sh` | | Interactive vs RPC divergence | `cargo test e2e_rpc -- --nocapture` ; `rg -n "interactive|rpc|stdin|event|session" src/main.rs src/interactive.rs src/rpc.rs` ; `cargo test conformance` | For deeper diagnosis, use `references/DEBUGGING-PLAYBOOKS.md`. ## Non-Negotiables - Read `AGENTS.md` first, then follow it exactly. - Do not delete files or run destructive git/filesystem commands. - Keep edits in-place; avoid creating variant files for the same purpose. - Use `main` semantics in docs/scripts; do not introduce `master`. - Prefer `rg` for fast text recon and `ast-grep` for structural matching/refactors. - Prefer `rch exec -- ` for heavy compile/test workloads. - After substantive edits, run compile/lint/format gates and the smallest relevant regression slice. ## Core Workflow - [ ] Recon: identify exact change surface and invariants. - [ ] Implement: minimal, behavior-focused patch with explicit failure semantics. - [ ] Validate: targeted tests first, broaden only as needed. - [ ] Verify UX: error/status output is explicit, stable, and non-ambiguous. - [ ] Sync docs: update `README.md` when flags/behavior/user guidance changed. ## Changed Files -> Required Tests | Changed Files (examples) | Minimum Required Tests | |---|---| | `install.sh`, `uninstall.sh`, `.claude/skills/maoclaw/**` | `bash -n install.sh uninstall.sh tests/installer_regression.sh` ; `shellcheck -x install.sh uninstall.sh tests/installer_regression.sh` ; `bash tests/installer_regression.sh` ; `bash scripts/skill-smoke.sh` | | `src/providers/**`, `src/provider.rs`, `src/sse.rs` | `cargo test provider_streaming` ; `cargo test conformance` | | `src/session.rs`, `src/session_index.rs`, `src/session_test.rs` | `cargo test session` ; `cargo test conformance` | | `src/extensions.rs`, `src/extensions_js.rs` | `cargo test extension` ; `cargo test conformance` | | `src/tools.rs` | `cargo test tools` ; `cargo test conformance` | | `src/interactive.rs`, `src/rpc.rs`, `src/main.rs` | `cargo test e2e_rpc` ; `cargo test conformance` | ## Do Not Run Yet Run these only after targeted repro + focused slice indicates need: - Broad `cargo test` across entire workspace when a narrower slice already reproduces. - Heavy multi-surface runs before confirming changed-file impact. - Repeated full conformance loops while the core failing slice is still unstable. ## High-Value Commands ```bash # Fast recon git status --short rg -n "install|uninstall|skill|checksum|sigstore|completion|provider|session|extension" \ install.sh uninstall.sh README.md tests/installer_regression.sh src/ # Installer + skill safety gates bash -n install.sh uninstall.sh tests/installer_regression.sh shellcheck -x install.sh uninstall.sh tests/installer_regression.sh bash tests/installer_regression.sh bash scripts/skill-smoke.sh # Rust gates rch exec -- cargo check --all-targets rch exec -- cargo clippy --all-targets -- -D warnings cargo fmt --check ``` For an expanded command cookbook, see `references/COMMANDS.md`. For deep incident triage, see `references/DEBUGGING-PLAYBOOKS.md`. ## Critical Files - `src/main.rs`: CLI entry and mode dispatch. - `src/agent.rs`: agent loop and tool iteration behavior. - `src/provider.rs`: provider trait contract. - `src/providers/`: provider implementations and factory wiring. - `src/tools.rs`: built-in tools (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`). - `src/session.rs`: JSONL session persistence. - `src/session_index.rs`: session index and metadata cache. - `src/extensions.rs` + `src/extensions_js.rs`: extension policy and QuickJS bridge. - `src/interactive.rs` + `src/rpc.rs`: TUI and RPC/stdin surfaces. - `install.sh` + `uninstall.sh`: install lifecycle, migration, and skill management. - `tests/installer_regression.sh`: installer regression harness. - `scripts/skill-smoke.sh`: skill integrity + inline-sync validation. ## Known Footguns - Custom artifact install paths without compatible release context can fall back incorrectly if not explicitly guarded. - Skill status can become misleading on mixed outcomes unless partial/failure branches are explicit. - Uninstall logic must enforce both marker checks and expected destination path shape. - Installer progress/status text should stay on stderr when stdout is used for data plumbing. - Bundled skill and inline fallback can silently drift unless explicitly checked. ## Patch Patterns ### Pattern 1: Mixed Outcome Status Clarity ```bash # BEFORE: everything collapsed into "skipped custom" if [ "$skipped_custom" -ge 1 ]; then AGENT_SKILL_STATUS="skipped (existing custom skill)" fi # AFTER: distinguish custom-skip from write failure if [ "$skipped_custom" -ge 1 ] && [ "$failed_writes" -ge 1 ]; then AGENT_SKILL_STATUS="partial (custom skill kept; other install failed)" elif [ "$skipped_custom" -ge 1 ]; then AGENT_SKILL_STATUS="skipped (existing custom skill)" fi ``` ### Pattern 2: Safe Skill Replacement ```bash # BEFORE: remove destination before validating copy result rm -rf "$destination" cp "$source" "$destination/SKILL.md" # AFTER: stage then atomically move into place staged="$(mktemp -d ...)" cp "$source" "$staged/SKILL.md" mv "$staged" "$destination" ``` ## Failure Triage - Installer summary/status mismatch: trace `AGENT_SKILL_STATUS`, `CHECKSUM_STATUS`, and `COMPLETIONS_STATUS` in `install.sh`. - Install/uninstall safety concern: verify marker checks and expected destination guards in both scripts. - Provider/session/extension regressions: use symptom router, then follow `references/DEBUGGING-PLAYBOOKS.md`. - Docs drift: ensure `README.md` flags/examples match current installer behavior. ## Done Criteria - Changed-file matrix minimum tests passed. - Compile/lint/format checks passed for touched surfaces. - Installer/skill changes pass `tests/installer_regression.sh` and `scripts/skill-smoke.sh`. - Behavior is explicit on failure paths; no silent fallback surprises. - Skill docs and inline fallback remain aligned and current. SKILL } install_skill_to_destination() { local destination="$1" local source_kind="$2" local source_path="$3" if ! is_expected_skill_destination "$destination"; then warn "Skipping unexpected skill destination path: $destination" return 1 fi case "$source_kind" in dir) if [ ! -d "$source_path" ] || [ ! -f "$source_path/SKILL.md" ]; then warn "Invalid bundled skill source directory: $source_path" return 1 fi ;; file) if [ ! -f "$source_path" ]; then warn "Missing skill source file: $source_path" return 1 fi ;; *) warn "Unknown skill source kind: $source_kind" return 1 ;; esac local existing_skill="$destination/SKILL.md" if [ -e "$destination" ] && [ ! -f "$existing_skill" ]; then warn "Skipping existing skill directory without SKILL.md at $destination" return 2 fi if [ -f "$existing_skill" ] && ! is_installer_managed_skill_file "$existing_skill"; then warn "Skipping existing non-installer-managed skill at $destination" return 2 fi local destination_parent destination_parent="$(dirname "$destination")" if ! mkdir -p "$destination_parent" 2>/dev/null; then warn "Failed to create skill parent directory: $destination_parent" return 1 fi local staged_destination="" staged_destination="$(mktemp -d "${destination_parent}/.${AGENT_SKILL_NAME}.tmp.XXXXXX" 2>/dev/null || true)" if [ -z "$staged_destination" ] || [ ! -d "$staged_destination" ]; then warn "Failed to create staging directory for skill install: $destination" return 1 fi case "$source_kind" in dir) if ! cp -R "$source_path/." "$staged_destination/" 2>/dev/null; then warn "Failed to install bundled skill into $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi ;; file) if ! cp "$source_path" "$staged_destination/SKILL.md" 2>/dev/null; then warn "Failed to install skill file into $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi ;; esac if [ ! -f "$staged_destination/SKILL.md" ]; then warn "Skill install failed: missing SKILL.md at $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi if ! is_installer_managed_skill_file "$staged_destination/SKILL.md"; then if ! printf '\n\n' "$AGENT_SKILL_MARKER" >> "$staged_destination/SKILL.md"; then warn "Failed to mark skill as installer-managed at $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi fi if ! is_installer_managed_skill_file "$staged_destination/SKILL.md"; then warn "Skill install failed: managed marker missing at $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi if [ -e "$destination" ] || [ -L "$destination" ]; then if ! remove_path_recursively "$destination" 2>/dev/null; then warn "Failed to replace existing skill directory: $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi fi if [ -e "$destination" ] || [ -L "$destination" ]; then warn "Failed to clear existing skill directory: $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi if ! mv "$staged_destination" "$destination" 2>/dev/null; then warn "Failed to move staged skill into place: $destination" remove_path_recursively "$staged_destination" 2>/dev/null || true return 1 fi return 0 } install_agent_skills() { if [ "$AGENT_SKILLS_ENABLED" -eq 0 ]; then AGENT_SKILL_STATUS="skipped (--no-agent-skills)" return 0 fi local codex_home="${CODEX_HOME:-$HOME/.codex}" AGENT_SKILL_CLAUDE_PATH="$HOME/.claude/skills/${AGENT_SKILL_NAME}" AGENT_SKILL_CODEX_PATH="${codex_home}/skills/${AGENT_SKILL_NAME}" local source_kind="file" local source_path="" local source_desc="inline" local bundled_dir="" local script_dir="" local script_dir_candidate="" if script_dir_candidate="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"; then script_dir="$script_dir_candidate" fi for candidate in "$PWD/.claude/skills/${AGENT_SKILL_NAME}" "$script_dir/.claude/skills/${AGENT_SKILL_NAME}"; do if [ -f "$candidate/SKILL.md" ]; then bundled_dir="$candidate" break fi done local temp_skill="" if [ -n "$bundled_dir" ]; then source_kind="dir" source_path="$bundled_dir" source_desc="bundled" else if command -v curl >/dev/null 2>&1; then local refs=() if [ -n "$VERSION" ] && [ "$VERSION" != "custom-artifact" ]; then refs+=("$VERSION") fi refs+=("main") local prev_ref="" local ref="" for ref in "${refs[@]}"; do if [ -n "$prev_ref" ] && [ "$ref" = "$prev_ref" ]; then continue fi prev_ref="$ref" local skill_url="https://raw.githubusercontent.com/${OWNER}/${REPO}/${ref}/.claude/skills/${AGENT_SKILL_NAME}/SKILL.md" local downloaded downloaded="$(mktemp 2>/dev/null || true)" if [ -z "$downloaded" ]; then break fi if fetch_url_to_file "$skill_url" "$downloaded" "agent skill" >/dev/null 2>&1; then temp_skill="$downloaded" source_path="$downloaded" source_desc="github:${ref}" break fi rm -f "$downloaded" 2>/dev/null || true done fi if [ -z "$source_path" ]; then local inline_skill inline_skill="$(mktemp 2>/dev/null || true)" if [ -z "$inline_skill" ]; then AGENT_SKILL_STATUS="failed (temp file error)" warn "Failed to prepare inline agent skill file" return 0 fi maoclaw_skill_inline_content > "$inline_skill" temp_skill="$inline_skill" source_path="$inline_skill" source_desc="inline" fi fi local installed_claude=0 local installed_codex=0 local skipped_custom=0 local failed_writes=0 local install_rc=0 if install_skill_to_destination "$AGENT_SKILL_CLAUDE_PATH" "$source_kind" "$source_path"; then installed_claude=1 else install_rc=$? if [ "$install_rc" -eq 2 ]; then skipped_custom=$((skipped_custom + 1)) else failed_writes=$((failed_writes + 1)) fi fi if install_skill_to_destination "$AGENT_SKILL_CODEX_PATH" "$source_kind" "$source_path"; then installed_codex=1 else install_rc=$? if [ "$install_rc" -eq 2 ]; then skipped_custom=$((skipped_custom + 1)) else failed_writes=$((failed_writes + 1)) fi fi if [ -n "$temp_skill" ] && [ -f "$temp_skill" ]; then rm -f "$temp_skill" 2>/dev/null || true fi if [ "$installed_claude" -eq 1 ] && [ "$installed_codex" -eq 1 ]; then AGENT_SKILL_STATUS="installed (claude,codex)" ok "Installed ${AGENT_SKILL_NAME} skill for Claude and Codex (${source_desc})" return 0 fi if [ "$installed_claude" -eq 1 ] && [ "$installed_codex" -eq 0 ]; then if [ "$failed_writes" -ge 1 ]; then AGENT_SKILL_STATUS="partial (claude installed; codex failed)" warn "Installed ${AGENT_SKILL_NAME} skill for Claude, but Codex install failed (${source_desc})" elif [ "$skipped_custom" -ge 1 ]; then AGENT_SKILL_STATUS="installed (claude only; codex custom kept)" warn "Installed ${AGENT_SKILL_NAME} skill for Claude; kept existing custom Codex skill (${source_desc})" else AGENT_SKILL_STATUS="installed (claude only)" warn "Installed ${AGENT_SKILL_NAME} skill for Claude only (${source_desc})" fi return 0 fi if [ "$installed_claude" -eq 0 ] && [ "$installed_codex" -eq 1 ]; then if [ "$failed_writes" -ge 1 ]; then AGENT_SKILL_STATUS="partial (codex installed; claude failed)" warn "Installed ${AGENT_SKILL_NAME} skill for Codex, but Claude install failed (${source_desc})" elif [ "$skipped_custom" -ge 1 ]; then AGENT_SKILL_STATUS="installed (codex only; claude custom kept)" warn "Installed ${AGENT_SKILL_NAME} skill for Codex; kept existing custom Claude skill (${source_desc})" else AGENT_SKILL_STATUS="installed (codex only)" warn "Installed ${AGENT_SKILL_NAME} skill for Codex only (${source_desc})" fi return 0 fi if [ "$skipped_custom" -ge 1 ] && [ "$failed_writes" -ge 1 ]; then AGENT_SKILL_STATUS="partial (custom skill kept; other install failed)" warn "Kept existing custom skill at one destination; install failed at another (${source_desc})" elif [ "$skipped_custom" -ge 1 ]; then AGENT_SKILL_STATUS="skipped (existing custom skill)" else AGENT_SKILL_STATUS="failed (unable to write skill files)" fi } load_existing_state() { local state_path="$STATE_FILE" if [ ! -f "$state_path" ] && [ -f "$LEGACY_STATE_DIR/install-state.env" ]; then state_path="$LEGACY_STATE_DIR/install-state.env" fi if [ -f "$state_path" ]; then # shellcheck disable=SC1090 source "$state_path" if [ -n "${PIAR_PATH_MARKER:-}" ]; then PATH_MARKER="${PIAR_PATH_MARKER}" fi fi } write_state() { mkdir -p "$STATE_DIR" { printf '# maoclaw installer state\n' printf 'PIAR_STATE_VERSION=%q\n' "$STATE_VERSION" printf 'PIAR_INSTALL_VERSION=%q\n' "$VERSION" printf 'PIAR_INSTALL_SOURCE=%q\n' "$INSTALL_SOURCE" printf 'PIAR_INSTALL_DEST=%q\n' "$DEST" printf 'PIAR_INSTALL_BIN=%q\n' "$INSTALL_BIN_PATH" printf 'PIAR_INSTALL_BIN_NAME=%q\n' "$FINAL_BIN_NAME" printf 'PIAR_CHECKSUM_STATUS=%q\n' "$CHECKSUM_STATUS" printf 'PIAR_SIGSTORE_STATUS=%q\n' "$SIGSTORE_STATUS" printf 'PIAR_COMPLETIONS_STATUS=%q\n' "$COMPLETIONS_STATUS" printf 'PIAR_MACOS_LAUNCHER_STATUS=%q\n' "$MACOS_LAUNCHER_STATUS" printf 'PIAR_MACOS_LAUNCHER_PATH=%q\n' "$MACOS_LAUNCHER_PATH" printf 'PIAR_AGENT_SKILL_STATUS=%q\n' "$AGENT_SKILL_STATUS" printf 'PIAR_AGENT_SKILL_CLAUDE_PATH=%q\n' "$AGENT_SKILL_CLAUDE_PATH" printf 'PIAR_AGENT_SKILL_CODEX_PATH=%q\n' "$AGENT_SKILL_CODEX_PATH" printf 'PIAR_ADOPTED_TYPESCRIPT=%q\n' "$ADOPT_TS" printf 'PIAR_LEGACY_ALIAS_PATH=%q\n' "$LEGACY_ALIAS_PATH" printf 'PIAR_LEGACY_TARGET_PATH=%q\n' "$LEGACY_TARGET_PATH" printf 'PIAR_LEGACY_MOVED_FROM=%q\n' "$LEGACY_MOVED_FROM" printf 'PIAR_LEGACY_MOVED_TO=%q\n' "$LEGACY_MOVED_TO" printf 'PIAR_PATH_MARKER=%q\n' "$PATH_MARKER" printf 'PIAR_PATH_UPDATED_FILES=%q\n' "$PATH_UPDATED_FILES" printf 'PIAR_INSTALLED_AT_UTC=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$STATE_FILE" } should_skip_reinstall() { if [ "$FORCE_INSTALL" -eq 1 ]; then return 1 fi if [ ! -x "$INSTALL_BIN_PATH" ]; then return 1 fi local out out="$(capture_version_line "$INSTALL_BIN_PATH")" if ! is_rust_pi_output "$out"; then return 1 fi if [ -n "${PIAR_INSTALL_VERSION:-}" ] && [ "$PIAR_INSTALL_VERSION" = "$VERSION" ]; then return 0 fi return 1 } print_summary() { [ "$QUIET" -eq 1 ] && return 0 local repo_url="https://github.com/${OWNER}/${REPO}" local releases_url="${repo_url}/releases" local lines=() local next_steps=() local first_run=() local desktop_app_path="" lines+=("Installed: $INSTALL_BIN_PATH") lines+=("Version: $VERSION") lines+=("Source: $INSTALL_SOURCE") lines+=("Checksum: $CHECKSUM_STATUS") lines+=("Signature: $SIGSTORE_STATUS") lines+=("Shell: $COMPLETIONS_STATUS") next_steps+=("Start the CLI now: mao") if [ "$OS" = "darwin" ]; then desktop_app_path="$(find_installed_macos_app || true)" if [ -n "$desktop_app_path" ]; then lines+=("Desktop: $desktop_app_path") next_steps+=("Open the desktop app: open \"$desktop_app_path\"") else lines+=("Desktop: not installed") next_steps+=("Desktop app downloads/builds: ${releases_url}") next_steps+=("If you are in a source checkout, build it with: bash scripts/build_macos_app.sh --install") fi lines+=("Launcher: $MACOS_LAUNCHER_STATUS") if [ -n "$MACOS_LAUNCHER_PATH" ] && [ -f "$MACOS_LAUNCHER_PATH" ]; then lines+=("Finder: $MACOS_LAUNCHER_PATH") next_steps+=("Use the Finder launcher if you prefer one-click startup: $MACOS_LAUNCHER_PATH") fi fi if [ -n "$PROXY_SOURCE" ]; then lines+=("Proxy: $(redact_proxy_value "$PROXY_SOURCE")") fi if [ "$WSL_DETECTED" -eq 1 ]; then lines+=("Platform: WSL detected") fi lines+=("Skills: $AGENT_SKILL_STATUS") if [ -n "$AGENT_SKILL_CLAUDE_PATH" ] && [ -f "$AGENT_SKILL_CLAUDE_PATH/SKILL.md" ]; then lines+=("Claude: $AGENT_SKILL_CLAUDE_PATH") fi if [ -n "$AGENT_SKILL_CODEX_PATH" ] && [ -f "$AGENT_SKILL_CODEX_PATH/SKILL.md" ]; then lines+=("Codex: $AGENT_SKILL_CODEX_PATH") fi if [ "$ADOPT_TS" -eq 1 ]; then if [ "$ADOPT_CANONICAL" -eq 1 ]; then lines+=("Mode: Rust is canonical 'pi'") else lines+=("Mode: Adoption requested; ensure '$DEST' precedes existing pi in PATH") fi if [ -n "$LEGACY_ALIAS_PATH" ]; then lines+=("Legacy: $(basename "$LEGACY_ALIAS_PATH") -> $LEGACY_TARGET_PATH") fi elif [ "$FINAL_BIN_NAME" = "pi-rust" ]; then lines+=("Mode: Existing pi kept; Rust installed as pi-rust") fi next_steps+=("Check the install: mao --version") next_steps+=("Open interactive setup: mao") next_steps+=("Release notes and downloads: ${releases_url}") if [ "$OS" = "darwin" ] && [ -n "$desktop_app_path" ]; then first_run+=("1. Open the desktop app: open \"$desktop_app_path\"") first_run+=("2. Choose one provider, enter one key, and save the setup") first_run+=("3. Start one real conversation in a real repository") first_run+=("4. If you prefer Terminal, you can also run: mao") first_run+=("5. If anything feels wrong, run: mao doctor") else first_run+=("1. Launch maoclaw in Terminal: mao") first_run+=("2. Configure one working provider with /setup or /login") first_run+=("3. If you use env vars, set one of: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, or AZURE_OPENAI_API_KEY") first_run+=("4. Verify readiness with: mao --list-providers") first_run+=("5. Start your first session and confirm you get a real model response") fi if [ "$HAS_GUM" -eq 1 ] && [ "$NO_GUM" -eq 0 ]; then { gum style --foreground 42 --bold "${FINAL_BIN_NAME} installed successfully" echo "" for line in "${lines[@]}"; do gum style --foreground 245 "$line" done echo "" gum style --foreground 51 --bold "Next steps" for step in "${next_steps[@]}"; do gum style --foreground 159 "• $step" done echo "" gum style --foreground 51 --bold "First run checklist" for step in "${first_run[@]}"; do gum style --foreground 222 "$step" done echo "" gum style --foreground 245 "Uninstall: curl -fsSL https://raw.githubusercontent.com/${OWNER}/${REPO}/main/uninstall.sh | bash" } | gum style --border normal --border-foreground 42 --padding "1 2" else echo -e "\033[0;36m+------------------------------------------------------------------+\033[0m" echo -e "\033[1;32m| maoclaw installed successfully |\033[0m" echo -e "\033[0;36m+------------------------------------------------------------------+\033[0m" for line in "${lines[@]}"; do echo -e " \033[0;37m$line\033[0m" done echo "" echo -e " \033[1;36mNext steps\033[0m" for step in "${next_steps[@]}"; do echo -e " \033[0;37m- $step\033[0m" done echo "" echo -e " \033[1;36mFirst run checklist\033[0m" for step in "${first_run[@]}"; do echo -e " \033[0;37m$step\033[0m" done echo "" echo -e " \033[0;90mUninstall: curl -fsSL https://raw.githubusercontent.com/${OWNER}/${REPO}/main/uninstall.sh | bash\033[0m" fi } find_installed_macos_app() { local candidate="" for candidate in \ "$HOME/Applications/maoclaw.app" \ "$HOME/Applications/Pi Desktop.app" \ "/Applications/maoclaw.app" \ "/Applications/Pi Desktop.app" do if [ -d "$candidate" ] && [ -x "$candidate/Contents/MacOS/maoclaw" ]; then printf '%s\n' "$candidate" return 0 fi done return 1 } main() { validate_options load_existing_state setup_proxy resolve_version show_header if [ "$OFFLINE" -eq 1 ] && [ -n "$OFFLINE_TARBALL" ]; then info "Offline artifact mode enabled: $OFFLINE_TARBALL" fi detect_existing_pi choose_adoption_mode choose_dest_for_adoption detect_platform prepare_asset_urls ensure_dest_dir ensure_install_target check_dependencies preflight_checks if should_skip_reinstall; then INSTALL_SOURCE="existing (no reinstall)" CHECKSUM_STATUS="not run (already installed)" SIGSTORE_STATUS="not run (already installed)" ok "pi ${VERSION} already installed at $INSTALL_BIN_PATH" if [ "$ADOPT_TS" -eq 1 ]; then local refresh_legacy=0 if [ -z "${PIAR_LEGACY_ALIAS_PATH:-}" ]; then refresh_legacy=1 elif [ ! -f "${PIAR_LEGACY_ALIAS_PATH}" ]; then refresh_legacy=1 elif ! grep -Eq "maoclaw installer managed alias|${LEGACY_BRAND_TOKEN} installer managed alias" "${PIAR_LEGACY_ALIAS_PATH}" 2>/dev/null; then refresh_legacy=1 fi if [ "$refresh_legacy" -eq 1 ]; then prepare_typescript_migration write_state fi fi maybe_add_path maybe_install_completions maybe_install_macos_launcher cleanup_legacy_agent_settings install_agent_skills write_state print_summary return 0 fi acquire_lock TMP=$(mktemp -d) local source_bin="" if [ "$FROM_SOURCE" -eq 1 ]; then INSTALL_SOURCE="source" CHECKSUM_STATUS="not applicable (source build)" SIGSTORE_STATUS="not applicable (source build)" run_with_spinner "Building pi from source" build_from_source > "$TMP/source_bin_path" source_bin=$(cat "$TMP/source_bin_path") else INSTALL_SOURCE="release" local download_rc=0 if run_with_spinner "Downloading release binary" download_release_binary > "$TMP/source_bin_path"; then source_bin=$(cat "$TMP/source_bin_path") else download_rc=$? if [ "$download_rc" -eq 2 ] || [ "$download_rc" -eq 3 ] || [ "$download_rc" -eq 4 ]; then err "Release checksum verification failed; aborting install" exit 1 fi if [ "$download_rc" -eq 5 ]; then err "Release signature verification failed; aborting install" exit 1 fi if [ -n "$ARTIFACT_URL" ] && [ "$VERSION" = "custom-artifact" ]; then err "Custom artifact download failed; cannot fall back to source without a release tag" err "Pass --version vX.Y.Z with --artifact-url, or use --from-source directly" exit 1 fi if [ "$OFFLINE" -eq 1 ]; then err "Offline mode download failed; source fallback is disabled" exit 1 fi warn "Release download failed; falling back to source build" FROM_SOURCE=1 INSTALL_SOURCE="source (release fallback)" CHECKSUM_STATUS="not applicable (source fallback)" SIGSTORE_STATUS="not applicable (source fallback)" check_dependencies run_with_spinner "Building pi from source" build_from_source > "$TMP/source_bin_path" source_bin=$(cat "$TMP/source_bin_path") fi fi prepare_typescript_migration install_binary_file "$source_bin" if [ "$VERIFY" -eq 1 ]; then "$INSTALL_BIN_PATH" --version >/dev/null ok "Verification passed ($FINAL_BIN_NAME --version)" fi maybe_add_path maybe_install_completions maybe_install_macos_launcher cleanup_legacy_agent_settings install_agent_skills write_state INSTALL_COMMITTED=1 print_summary } main "$@"