#!/bin/sh
set -eu
# ── ZeroClaw installer ───────────────────────────────────────────
# Builds and installs ZeroClaw from source.
# All feature lists and version info read from Cargo.toml — nothing hardcoded.
# POSIX sh — no bash required. Works on Alpine, Debian, macOS, everywhere.
REPO_URL="https://github.com/zeroclaw-labs/zeroclaw.git"
# ── Output helpers (terminal-aware) ──────────────────────────────
if [ -t 1 ]; then
BOLD='\033[1m' GREEN='\033[32m' YELLOW='\033[33m' RED='\033[31m' RESET='\033[0m'
else
BOLD='' GREEN='' YELLOW='' RED='' RESET=''
fi
info() { printf " ${GREEN}✓${RESET} %s\n" "$*"; }
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$*" >&2; }
die() {
printf " ${RED}✗${RESET} %s\n" "$*" >&2
exit 1
}
bold() { printf "${BOLD}%s${RESET}" "$*"; }
# >>> generated:route-contract by `cargo generate installers` - do not edit <<<
TUI_BIN_NAME="zerocode"
DEFAULT_APPS="zerocode"
PIPED_INSTALL_MODE="prebuilt"
QUICKSTART_COMMAND="zeroclaw quickstart"
QUICKSTART_SUBCOMMAND="quickstart"
GUIDED_INSTALL_MODE="choice"
GUIDED_QUICKSTART_MODE="offer"
UNIX_PATH_RELOAD="true"
# >>> end generated:route-contract <<<
# ── Parse Cargo.toml (source of truth) ────────────────────────────
parse_cargo_toml() {
local toml="$1"
[ -f "$toml" ] || die "Cargo.toml not found at $toml"
VERSION=$(awk '/^\[workspace\.package\]/{p=1;next} /^\[/{p=0} p && /^version *=/{split($0,a,"\"");print a[2]}' "$toml")
MSRV=$(awk '/^\[workspace\.package\]/{p=1;next} /^\[/{p=0} p && /^rust-version *=/{split($0,a,"\"");print a[2]}' "$toml")
EDITION=$(awk '/^\[workspace\.package\]/{p=1;next} /^\[/{p=0} p && /^edition *=/{split($0,a,"\"");print a[2]}' "$toml")
DEFAULT_FEATURES=$(feature_members "$toml" default | paste -sd, -)
ALL_FEATURES=$(awk '/^\[features\]/{p=1;next} /^\[/{p=0} p && /^[a-z][a-z0-9_-]* *=/{sub(/ *=.*/,"");print}' "$toml")
}
# Print the members of one feature from `[features]`, one per line. Spans
# multi-line array literals. The single source of truth for reading the
# feature graph out of Cargo.toml.
feature_members() {
awk -v key="$2" '
$0 ~ "^" key " *= *\\[" {p=1}
p {while (match($0,/"[^"]+"/)) {print substr($0,RSTART+1,RLENGTH-2); $0=substr($0,RSTART+RLENGTH)}}
p && /\]/ {exit}
' "$1"
}
# Aggregate/meta features and deprecated aliases: internal groupings, not
# individual picker rows. The single source of truth for what to skip when
# rendering rows and what to expand when resolving `default`.
NON_ROW_FEATURES="default default-channels channels-full ci-all fantoccini landlock metrics embedded-web"
is_aggregate() {
case " $NON_ROW_FEATURES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}
# Expand `default` to the picker rows it implies: walk aggregates
# (default-channels, etc.) until only real feature names remain. Reads the
# graph from Cargo.toml — no hardcoded channel list.
expand_default_features() {
local toml="$1" queue leaf=" " f members
queue=$(printf '%s' "$DEFAULT_FEATURES" | tr ',' ' ')
while [ -n "$queue" ]; do
f=${queue%% *}; queue=${queue#"$f"}; queue=${queue# }
case "$f" in dep:* | */*) continue ;; esac
if is_aggregate "$f"; then
members=$(feature_members "$toml" "$f" | tr '\n' ' ')
queue="$queue $members"
else
case "$leaf" in *" $f "*) ;; *) leaf="$leaf$f " ;; esac
fi
done
printf '%s' "$leaf"
}
# ── App registry ──────────────────────────────────────────────────
#
# Apps are standalone binaries under `apps/
` installed via
# `cargo install --path apps/` — they are NOT cargo features of the
# main binary. The installable set is discovered from `apps/*/Cargo.toml`
# so adding an app surfaces here without editing this script. `zerocode`
# (the TUI) is the default app. Tauri-based apps (e.g. zeroclaw-desktop)
# need the Tauri toolchain + system webview deps and are excluded from the
# simple `cargo install` path.
discover_apps() {
APPS=""
for dir in apps/*/; do
[ -f "${dir}Cargo.toml" ] || continue
name=$(awk -F'"' '/^name *=/{print $2; exit}' "${dir}Cargo.toml")
[ -n "$name" ] || continue
APPS="${APPS:+$APPS }$name"
done
}
# Resolve the app directory for a given app/bin name.
app_dir_for() {
for dir in apps/*/; do
[ -f "${dir}Cargo.toml" ] || continue
name=$(awk -F'"' '/^name *=/{print $2; exit}' "${dir}Cargo.toml")
if [ "$name" = "$1" ]; then
printf '%s' "${dir%/}"
return 0
fi
done
return 1
}
validate_app() {
case " $APPS " in
*" $1 "*) return 0 ;;
*) die "Unknown app '$1'. Installable apps: $APPS" ;;
esac
}
# ── Feature validation ────────────────────────────────────────────
validate_feature() {
case "$1" in
fantoccini)
warn "'fantoccini' is deprecated — use 'browser-native'"
return 0
;;
landlock)
warn "'landlock' is deprecated — use 'sandbox-landlock'"
return 0
;;
metrics)
warn "'metrics' is deprecated — use 'observability-prometheus'"
return 0
;;
esac
echo "$ALL_FEATURES" | grep -qx "$1" && return 0
die "Unknown feature '$1'. Run: $0 --list-features"
}
selected_feature_enabled() {
case ",$USER_FEATURES," in
*",$1,"*) return 0 ;;
*) return 1 ;;
esac
}
# ── List features ─────────────────────────────────────────────────
list_features() {
parse_cargo_toml "$1"
echo
printf "%s — available build features\n" "$(bold "ZeroClaw v${VERSION}")"
echo
printf " %s\n" "$(bold "Default") (included unless --minimal):"
printf " %s\n" "$DEFAULT_FEATURES"
echo
channels="" observability="" platform="" other=""
for feat in $ALL_FEATURES; do
case "$feat" in
default | ci-all | fantoccini | landlock | metrics) continue ;;
channel-*) channels="${channels:+$channels, }$feat" ;;
observability-*) observability="${observability:+$observability, }$feat" ;;
hardware | peripheral-* | sandbox-* | browser-* | probe | webauthn)
platform="${platform:+$platform, }$feat"
;;
*) other="${other:+$other, }$feat" ;;
esac
done
[ -n "$channels" ] && printf " %s\n %s\n\n" "$(bold "Channels:")" "$channels"
[ -n "$observability" ] && printf " %s\n %s\n\n" "$(bold "Observability:")" "$observability"
[ -n "$platform" ] && printf " %s\n %s\n\n" "$(bold "Platform:")" "$platform"
[ -n "$other" ] && printf " %s\n %s\n\n" "$(bold "Other:")" "$other"
printf " %s\n" "$(bold "Build profiles:")"
printf " %s # full (default features)\n" "$0"
printf " %s --minimal # kernel only (~6.6MB)\n" "$0"
printf " %s --minimal --features agent-runtime,channel-discord\n" "$0"
echo
}
# ── Version comparison ────────────────────────────────────────────
version_gte() {
# Returns 0 if $1 >= $2 (dot-separated version strings)
local IFS=.
set -- $1 $2
local a1="${1:-0}" a2="${2:-0}" a3="${3:-0}"
shift 3 2>/dev/null || shift $#
local b1="${1:-0}" b2="${2:-0}" b3="${3:-0}"
[ "$a1" -gt "$b1" ] 2>/dev/null && return 0
[ "$a1" -lt "$b1" ] 2>/dev/null && return 1
[ "$a2" -gt "$b2" ] 2>/dev/null && return 0
[ "$a2" -lt "$b2" ] 2>/dev/null && return 1
[ "$a3" -gt "$b3" ] 2>/dev/null && return 0
[ "$a3" -lt "$b3" ] 2>/dev/null && return 1
return 0
}
# ── Detect user's shell ──────────────────────────────────────────
detect_shell_profile() {
local shell_name
shell_name=$(basename "${SHELL:-/bin/bash}")
case "$shell_name" in
zsh) echo "$HOME/.zshrc" ;;
fish) echo "$HOME/.config/fish/config.fish" ;;
*) echo "$HOME/.bashrc" ;;
esac
}
shell_export_syntax() {
local shell_name
shell_name=$(basename "${SHELL:-/bin/bash}")
case "$shell_name" in
fish) printf 'set -gx PATH "%s/bin" $PATH' "$CARGO_HOME" ;;
*) printf 'export PATH="%s/bin:$PATH"' "$CARGO_HOME" ;;
esac
}
# ── Platform / target triple detection ───────────────────────────
detect_libc() {
if [ -e /lib/ld-musl-*.so.1 ] 2>/dev/null || \
ldd --version 2>&1 | grep -qi musl || \
{ [ -r /etc/os-release ] && grep -qiE 'alpine|postmarket' /etc/os-release; }; then
echo "musl"
else
echo "gnu"
fi
}
detect_target_triple() {
local os arch libc
os=$(uname -s)
arch=$(uname -m)
case "$os" in
Darwin)
# Apple Silicon reports arm64; Intel reports x86_64. A Rosetta-translated
# shell on Apple Silicon also reports x86_64 from `uname -m`, so consult
# `sysctl hw.optional.arm64` to recover the true CPU. Without this an Intel
# Mac (or an M-series Mac run under Rosetta) is handed the wrong-arch
# binary and hits "bad CPU type in executable".
if [ "$arch" = "arm64" ] || [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
echo "aarch64-apple-darwin"
else
echo "x86_64-apple-darwin"
fi
;;
Linux)
if [ -n "${TERMUX_VERSION:-}" ] || [ -d /data/data/com.termux ]; then
case "$arch" in
aarch64 | arm64) echo "aarch64-linux-android" ;;
*)
printf "Unsupported Android architecture: %s (only aarch64/arm64 is supported)\n" "$arch" >&2
echo ""
;;
esac
else
libc=$(detect_libc)
case "$arch" in
x86_64) echo "x86_64-unknown-linux-${libc}" ;;
aarch64 | arm64) echo "aarch64-unknown-linux-${libc}" ;;
armv7l) echo "armv7-unknown-linux-gnueabihf" ;;
armv6l | arm*) echo "arm-unknown-linux-gnueabihf" ;;
*) echo "" ;;
esac
fi
;;
*) echo "" ;;
esac
}
# ── Pre-built binary install ──────────────────────────────────────
install_prebuilt() {
local triple version asset_name asset_url sha256_url tmp_dir web_data_dir
triple=$(detect_target_triple)
if [ -z "$triple" ]; then
warn "No pre-built binary for this platform — falling back to source build"
return 1
fi
# Resolve latest release version via GitHub API
version=$(curl -fsSL "https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/latest" |
grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
if [ -z "$version" ]; then
warn "Could not resolve latest release — falling back to source build"
return 1
fi
asset_name="zeroclaw-${triple}.tar.gz"
asset_url="https://github.com/zeroclaw-labs/zeroclaw/releases/download/${version}/${asset_name}"
sha256_url="https://github.com/zeroclaw-labs/zeroclaw/releases/download/${version}/SHA256SUMS"
echo
printf "%s\n" "$(bold "Installing ZeroClaw ${version} (pre-built)")"
info "Platform: $triple"
info "Source: $asset_url"
info "Channels: pre-built binaries ship the lean standard distribution set; availability is target-specific."
info "Run 'zeroclaw channel list' to inspect this binary. For other channels such as Slack, build from source with --preset full."
echo
# Resolve platform-correct web data directory to match gateway auto-detect
web_data_dir=$(resolve_web_data_dir)
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would download $asset_url"
info "[dry-run] Would install to $CARGO_HOME/bin/zeroclaw"
info "[dry-run] Would install $TUI_BIN_NAME to $CARGO_HOME/bin/$TUI_BIN_NAME (if in tarball)"
info "[dry-run] Would install web dashboard to $web_data_dir"
return 0
fi
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
# Fetch the checksum manifest first — it lists every published asset, so we
# can tell "no pre-built binary for this platform" (e.g. Intel macOS, which
# ships no release tarball) from a genuine download failure, and we never
# pull a tarball we couldn't verify anyway. All failure modes fall back to
# source rather than install unverified.
if ! curl -fsSL "$sha256_url" -o "$tmp_dir/SHA256SUMS" 2>/dev/null; then
warn "Could not fetch SHA256SUMS — falling back to source build"
rm -rf "$tmp_dir"
return 1
fi
expected=$(grep "$asset_name" "$tmp_dir/SHA256SUMS" | awk '{print $1}')
if [ -z "$expected" ]; then
warn "No pre-built binary published for $triple — falling back to source build"
rm -rf "$tmp_dir"
return 1
fi
curl -fSL --progress-bar "$asset_url" -o "$tmp_dir/$asset_name" ||
{
warn "Download failed — falling back to source build"
rm -rf "$tmp_dir"
return 1
}
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$tmp_dir/$asset_name" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$tmp_dir/$asset_name" | awk '{print $1}')
else
warn "No checksum tool available (sha256sum/shasum) — falling back to source build"
rm -rf "$tmp_dir"
return 1
fi
if [ "$actual" != "$expected" ]; then
die "Checksum mismatch — download may be corrupt. Expected: $expected Got: $actual"
fi
info "Checksum verified"
tar -xzf "$tmp_dir/$asset_name" -C "$tmp_dir"
mkdir -p "$CARGO_HOME/bin"
install -m 755 "$tmp_dir/zeroclaw" "$CARGO_HOME/bin/zeroclaw"
if [ -f "$tmp_dir/$TUI_BIN_NAME" ]; then
install -m 755 "$tmp_dir/$TUI_BIN_NAME" "$CARGO_HOME/bin/$TUI_BIN_NAME"
fi
# Install web dashboard assets bundled in the release tarball
if [ -d "$tmp_dir/web/dist" ]; then
mkdir -p "$web_data_dir"
cp -r "$tmp_dir/web/dist/." "$web_data_dir/"
info "Web dashboard installed to $web_data_dir"
fi
rm -rf "$tmp_dir"
trap - EXIT
return 0
}
# ── Usage ─────────────────────────────────────────────────────────
usage() {
cat </dev/null || true
"$bin" service uninstall 2>/dev/null || true
rm -f "$bin"
info "Removed $bin"
else
warn "Binary not found at $bin"
fi
local tui_bin="$CARGO_HOME/bin/$TUI_BIN_NAME"
if [ -f "$tui_bin" ]; then
rm -f "$tui_bin"
info "Removed $tui_bin"
fi
local config_dir="$PREFIX/.zeroclaw"
if [ -d "$config_dir" ]; then
if [ -t 0 ]; then
printf " Remove config and data (%s)? [y/N] " "$config_dir"
read -r confirm
case "$confirm" in
[Yy]*)
rm -rf "$config_dir"
info "Removed $config_dir"
;;
*) info "Config preserved at $config_dir" ;;
esac
else
info "Config preserved at $config_dir (non-interactive — use rm -rf to remove)"
fi
fi
# Strip the PATH marker block this installer may have added to the profile.
local profile
profile=$(detect_shell_profile)
if [ -f "$profile" ] && grep -q "# >>> zeroclaw >>>" "$profile" 2>/dev/null; then
local tmp_profile
tmp_profile=$(mktemp)
if sed '/# >>> zeroclaw >>>/,/# <<< zeroclaw <<"$tmp_profile" 2>/dev/null &&
cat "$tmp_profile" >"$profile" 2>/dev/null; then
info "Removed PATH entry from $profile"
else
warn "Could not edit $profile — remove the zeroclaw PATH block manually"
fi
rm -f "$tmp_profile"
fi
# Check if another zeroclaw still lurks in PATH
local other_bin
other_bin=$(PATH="$ORIGINAL_PATH" command -v zeroclaw 2>/dev/null || true)
if [ -n "$other_bin" ]; then
local other_version
other_version=$("$other_bin" --version 2>/dev/null | awk '{print $NF}' || echo "unknown")
echo
warn "Another zeroclaw found at $other_bin (v$other_version)"
warn "Remove it manually if you want a full uninstall"
fi
echo
info "ZeroClaw uninstalled"
exit 0
}
# ── Quickstart-needed status check ───────────────────────────────
#
# Detect whether the operator already has a configured ZeroClaw so the
# 3-way "how would you like to complete setup?" prompt can skip silently
# on a re-install. We treat setup as complete when a config file exists
# at the expected path AND it contains at least one `[providers.models.*]`
# or `[providers.fallback]` line — i.e. some provider is configured.
# Empty or default config files still trigger the prompt.
quickstart_needed() {
cfg="$PREFIX/.zeroclaw/config.toml"
[ -f "$cfg" ] || return 0 # no config → run quickstart
# Already-configured signal: any of these patterns means a provider was set.
if grep -qE '^\[providers\.models\.|^fallback *=|^default_provider *=' "$cfg" 2>/dev/null; then
return 1 # configured → skip
fi
return 0 # config exists but empty → run quickstart
}
# ── Interactive feature picker ───────────────────────────────────
# >>> generated:feature-picker-helper by `cargo generate installers` - do not edit <<<
# Offers every non-aggregate feature and installable app. Defaults start
# checked and can be toggled off. Prompts are written to stderr; the selected
# values are returned through PICKED_FEATURES and PICKED_APPS. The caller only
# invokes this helper for an interactive TTY, never for a piped `curl | sh` run.
interactive_feature_picker() {
toml="$1"
parse_cargo_toml "$toml"
discover_apps
# Split features into channels (channel-*) and everything else. Skip
# aggregate/meta features (see $NON_ROW_FEATURES) — they are internal
# groupings, not individual toggles. Defaults are pre-checked below.
channel_features=""
other_features=""
for feat in $ALL_FEATURES; do
if is_aggregate "$feat"; then continue; fi
case "$feat" in
channel-*)
channel_features="${channel_features:+$channel_features }$feat"
;;
*)
other_features="${other_features:+$other_features }$feat"
;;
esac
done
# Apps default-on set (zerocode); features pre-checked from the crate's
# `default = [...]` list, expanded transitively so aggregate defaults like
# `default-channels` pre-check their leaf channel-* rows.
selected_apps="$DEFAULT_APPS"
selected_features=$(expand_default_features "$toml")
# Flat entry list, in display order: apps, then features, then channels.
# Each entry is tagged "app:" or "feat:" so toggling routes to the right
# selection set.
entries=""
for a in $APPS; do entries="${entries:+$entries }app:$a"; done
for f in $other_features; do entries="${entries:+$entries }feat:$f"; done
for c in $channel_features; do entries="${entries:+$entries }feat:$c"; done
# Prompt-side output goes to stderr; the result is returned via globals.
echo >&2
printf " %s\n" "$(bold "Select apps and optional features:")" >&2
printf " %s\n" "Type the numbers to toggle, blank line to confirm." >&2
printf " %s\n" "Checked (✓) items are on by default — uncheck to drop them." >&2
echo >&2
while :; do
i=1
last_section=""
for entry in $entries; do
kind=${entry%%:*}
name=${entry#*:}
# Section header when the group changes.
section=""
case "$kind" in
app) section="Apps (--apps)" ;;
feat) case "$name" in channel-*) section="Channels (--features)" ;; *) section="Features (--features)" ;; esac ;;
esac
if [ "$section" != "$last_section" ]; then
[ -n "$last_section" ] && echo >&2
printf " %s\n" "$(bold "$section:")" >&2
last_section="$section"
fi
mark=" "
case "$kind" in
app) case " $selected_apps " in *" $name "*) mark="✓" ;; esac ;;
feat) case " $selected_features " in *" $name "*) mark="✓" ;; esac ;;
esac
printf " [%2d] %s %s\n" "$i" "$mark" "$name" >&2
i=$((i + 1))
done
echo >&2
printf " toggle (e.g. \"1 3 5\"), %s confirm: " "$(bold "Enter to")" >&2
read -r choices
[ -z "$choices" ] && break
for n in $choices; do
case "$n" in
'' | *[!0-9]*) continue ;;
esac
idx=1
for entry in $entries; do
if [ "$idx" -eq "$n" ]; then
kind=${entry%%:*}
name=${entry#*:}
if [ "$kind" = app ]; then
case " $selected_apps " in
*" $name "*) selected_apps=$(printf '%s' "$selected_apps" | tr ' ' '\n' | grep -vx "$name" | paste -sd' ' -) ;;
*) selected_apps="${selected_apps:+$selected_apps }$name" ;;
esac
else
case " $selected_features " in
*" $name "*) selected_features=$(printf '%s' "$selected_features" | tr ' ' '\n' | grep -vx "$name" | paste -sd' ' -) ;;
*) selected_features="${selected_features:+$selected_features }$name" ;;
esac
fi
break
fi
idx=$((idx + 1))
done
done
done
PICKED_FEATURES=$(printf '%s' "$selected_features" | tr ' ' ',')
PICKED_APPS=$(printf '%s' "$selected_apps" | tr ' ' ',')
}
# >>> end generated:feature-picker-helper <<<
# Resolve the platform data directory the gateway auto-detects for the
# dashboard bundle. Single source of truth so the prebuilt and source
# install paths cannot drift.
resolve_web_data_dir() {
case "$(uname -s)" in
Darwin)
printf '%s' "${HOME}/Library/Application Support/zeroclaw/web/dist"
;;
MINGW* | CYGWIN* | MSYS*)
printf '%s' "${LOCALAPPDATA}/zeroclaw/web/dist"
;;
*)
printf '%s' "${XDG_DATA_HOME:-${PREFIX}/.local/share}/zeroclaw/web/dist"
;;
esac
}
# Copy a built web/dist into the gateway data directory so a
# systemd-launched daemon finds it regardless of CWD.
install_web_dist() {
src_dist="$1"
if [ ! -f "$src_dist/index.html" ]; then
return 0
fi
web_data_dir=$(resolve_web_data_dir)
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would install web dashboard to $web_data_dir"
return 0
fi
mkdir -p "$web_data_dir"
cp -r "$src_dist/." "$web_data_dir/"
# Prune files the fresh build no longer ships. Copy-then-prune keeps
# index.html present throughout, so a running gateway never 503s
# mid-install, while stale hashed chunks still get removed.
(
cd "$web_data_dir" || exit 0
find . -type f | while IFS= read -r f; do
[ -f "$src_dist/$f" ] || rm -f "$f"
done
find . -depth -type d -empty -exec rmdir {} \; 2>/dev/null
)
info "Web dashboard installed to $web_data_dir"
}
# ── Web dashboard build for source installs ──────────────────────
#
# When a source build includes the `gateway` feature, the dashboard
# (`web/dist`) needs to be built so the gateway can serve it. If Node.js
# is on PATH we run `cargo web build` from the source root so the
# generated API client is refreshed before TypeScript compiles, then the
# built bundle is copied into the gateway data directory. Without Node.js
# we warn and tell the user to re-run the installer once Node.js is
# present — never to run cargo by hand.
build_web_dashboard() {
src_dir="$1"
required="${2:-false}"
if [ ! -d "$src_dir/web" ]; then
if [ "$required" = true ]; then
die "feature embedded-web requires a web/ directory in the source checkout."
fi
warn "Source has no web/ directory; skipping dashboard build."
return 0
fi
if ! command -v npm >/dev/null 2>&1; then
if [ "$required" = true ]; then
die "feature embedded-web requires Node.js/npm to build web/dist before cargo install. Install the Node version from .nvmrc and re-run, or remove embedded-web."
fi
warn "npm not found — skipping dashboard build. The gateway will run"
warn " in API-only mode. Install Node.js (npm) and re-run ./install.sh"
warn " --source to build and install the dashboard."
return 0
fi
# Always rebuild — a stale dist from a prior revision serves outdated
# assets against an updated gateway. Incremental caching keeps no-op
# re-runs cheap.
info "Building web dashboard (cargo web build)..."
(cd "$src_dir" && cargo web build) || {
if [ "$required" = true ]; then
die "feature embedded-web requires a successful dashboard build before cargo install."
fi
warn "Dashboard build failed — gateway will run in API-only mode."
return 0
}
info "Web dashboard built at $src_dir/web/dist"
install_web_dist "$src_dir/web/dist"
}
# ── Low-memory build heuristic ────────────────────────────────────
#
# [profile.release] in Cargo.toml uses fat LTO + codegen-units = 1.
# With heavy crates in the graph (matrix-sdk-crypto, ruma, vodozemac)
# a single rustc process can peak past 7 GB RSS during the cross-crate
# type pass, OOM-ing 8 GB ARM devices. Thin LTO trades a small
# binary-size hit for a much lower build-time RAM peak. Apply it as
# a default on Linux hosts with under ~12 GiB MemTotal, but only when
# the user has not already pinned CARGO_PROFILE_RELEASE_LTO.
apply_low_mem_lto_default() {
[ "$(uname -s)" = "Linux" ] || return 0
[ -r /proc/meminfo ] || return 0
[ -n "${CARGO_PROFILE_RELEASE_LTO:-}" ] && return 0
mem_kb=$(awk '/^MemTotal:/{print $2; exit}' /proc/meminfo 2>/dev/null)
case "$mem_kb" in
'' | *[!0-9]*) return 0 ;;
esac
# 12 GiB in KiB = 12 * 1024 * 1024
if [ "$mem_kb" -lt 12582912 ]; then
mem_gib=$((mem_kb / 1048576))
export CARGO_PROFILE_RELEASE_LTO=thin
info "Low-memory device detected (${mem_gib} GiB RAM): using thin LTO to keep build RAM bounded. Set CARGO_PROFILE_RELEASE_LTO=fat to override."
fi
}
# ── Parse arguments ───────────────────────────────────────────────
MINIMAL=false
USER_FEATURES=""
SKIP_QUICKSTART=false
LIST_FEATURES=false
UNINSTALL=false
DRY_RUN=false
MODIFY_PATH=true # append PATH export to the shell profile; --no-modify-path opts out
PREFIX="$HOME"
INSTALL_MODE="" # ""=ask, "prebuilt"=force prebuilt, "source"=force source
PRESET="" # ""=unset, "minimal"=alias for --minimal, "full"=default-features
WITH_GATEWAY="" # ""=unset (preset/feature default applies), "true"/"false"=explicit toggle
WITHOUT_TUI="" # ""=unset (default: install TUI), "true"=skip TUI
USER_APPS="" # ""=unset (default apps), "none"=skip all, or comma list (e.g. "zerocode")
FULL_APPS=false # true when --full: install every discovered app, not just the defaults
# Support legacy env var
if [ -n "${ZEROCLAW_CARGO_FEATURES:-}" ]; then
USER_FEATURES="${USER_FEATURES:+$USER_FEATURES,}$ZEROCLAW_CARGO_FEATURES"
fi
while [ $# -gt 0 ]; do
case "$1" in
--minimal) MINIMAL=true ;;
--preset)
if [ $# -lt 2 ]; then
die "Missing value for --preset. Expected: --preset minimal|full"
fi
shift
case "$1" in
minimal)
PRESET="minimal"
MINIMAL=true
;;
full) PRESET="full" ;;
*) die "Unknown preset '$1'. Expected: minimal or full" ;;
esac
;;
--full)
# Everything: the 'full' feature preset plus every installable app.
PRESET="full"
FULL_APPS=true
;;
--features)
if [ $# -lt 2 ]; then
die "Missing value for --features. Expected: --features X,Y"
fi
shift
USER_FEATURES="${USER_FEATURES:+$USER_FEATURES,}$1"
;;
--apps)
if [ $# -lt 2 ]; then
die "Missing value for --apps. Expected: --apps zerocode[,...] or --apps none"
fi
shift
USER_APPS="${USER_APPS:+$USER_APPS,}$1"
;;
--with-gateway) WITH_GATEWAY="true" ;;
--without-gateway) WITH_GATEWAY="false" ;;
--without-tui) WITHOUT_TUI=true ;;
--list-features) LIST_FEATURES=true ;;
--prefix)
if [ $# -lt 2 ]; then
die "Missing value for --prefix. Expected: --prefix /path"
fi
shift
PREFIX=$(echo "$1" | sed 's|/*$||')
;;
--dry-run) DRY_RUN=true ;;
--no-modify-path) MODIFY_PATH=false ;;
--skip-quickstart) SKIP_QUICKSTART=true ;;
# >>> generated:route-flags by `cargo generate installers` - do not edit <<<
--prebuilt) INSTALL_MODE="prebuilt" ;;
--source) INSTALL_MODE="source" ;;
# >>> end generated:route-flags <<<
--uninstall) UNINSTALL=true ;;
-h | --help)
usage
exit 0
;;
-V | --version)
if [ -f "Cargo.toml" ]; then
parse_cargo_toml "Cargo.toml"
echo "install.sh for ZeroClaw v$VERSION"
else
echo "install.sh (version unknown — not in repo)"
fi
exit 0
;;
*) die "Unknown option: $1. Run: $0 --help" ;;
esac
shift
done
# ── Derive paths from prefix ─────────────────────────────────────
CARGO_HOME="${CARGO_HOME:-$PREFIX/.cargo}"
RUSTUP_HOME="${RUSTUP_HOME:-$PREFIX/.rustup}"
INSTALL_DIR="${ZEROCLAW_INSTALL_DIR:-$PREFIX/.zeroclaw/src}"
ORIGINAL_PATH="$PATH"
PATH="$CARGO_HOME/bin:$PATH"
export CARGO_HOME RUSTUP_HOME PATH
[ "$UNINSTALL" = true ] && do_uninstall
# ── List features (can run without cloning if in repo) ────────────
if [ "$LIST_FEATURES" = true ]; then
if [ -f "Cargo.toml" ]; then
list_features "Cargo.toml"
elif [ -f "$INSTALL_DIR/Cargo.toml" ]; then
list_features "$INSTALL_DIR/Cargo.toml"
else
die "No Cargo.toml found. Clone the repo first or run from the repo root."
fi
exit 0
fi
# ── Decide: pre-built or source ───────────────────────────────────
# >>> generated:route-decision by `cargo generate installers` - do not edit <<<
# --minimal, --features, --apps, --without-gateway, or --preset full imply
# source. Prebuilt archives have fixed feature and app contents, so any flag
# that changes the feature set or selects apps must force a source build.
if [ "$MINIMAL" = true ] || [ -n "$USER_FEATURES" ] || [ -n "$USER_APPS" ] ||
[ "$WITH_GATEWAY" = "false" ] || [ "$PRESET" = "full" ]; then
INSTALL_MODE="source"
fi
if [ "$INSTALL_MODE" = "" ]; then
triple=$(detect_target_triple)
if [ -n "$triple" ]; then
if [ "$GUIDED_INSTALL_MODE" = "choice" ] && [ -t 0 ]; then
echo
printf " %s\n" "$(bold "How would you like to install ZeroClaw?")"
printf " [P] Pre-built binary — fast, no Rust required %s\n" "$(bold "(default)")"
printf " [s] Build from source — custom features, latest code\n"
printf "\n Choice [P/s]: "
read -r install_choice
case "$install_choice" in
[Ss]*) INSTALL_MODE="source" ;;
*) INSTALL_MODE="prebuilt" ;;
esac
else
# Non-interactive (curl | bash): default to pre-built silently
INSTALL_MODE="$PIPED_INSTALL_MODE"
fi
else
INSTALL_MODE="source"
fi
fi
if [ "$INSTALL_MODE" = "prebuilt" ]; then
if install_prebuilt; then
PREBUILT_OK=true
else
warn "Pre-built install failed — continuing with source build"
INSTALL_MODE="source"
PREBUILT_OK=false
fi
fi
# >>> end generated:route-decision <<<
[ "${PREBUILT_OK:-false}" = true ] && [ "$DRY_RUN" != true ] && {
BIN="$CARGO_HOME/bin/zeroclaw"
if [ -f "$BIN" ]; then
NEW_VERSION=$("$BIN" --version 2>/dev/null | awk '{print $NF}' || echo "?")
SIZE=$(du -h "$BIN" | awk '{print $1}')
echo
info "Installed: $BIN (v$NEW_VERSION, $SIZE)"
fi
TUI_BIN="$CARGO_HOME/bin/$TUI_BIN_NAME"
if [ -f "$TUI_BIN" ]; then
TUI_SIZE=$(du -h "$TUI_BIN" | awk '{print $1}')
info "Installed: $TUI_BIN ($TUI_SIZE)"
fi
}
# ── Locate source ─────────────────────────────────────────────────
# >>> generated:source-dispatch-open by `cargo generate installers` - do not edit <<<
if [ "${PREBUILT_OK:-false}" != true ]; then
# >>> end generated:source-dispatch-open <<<
echo
printf "%s\n" "$(bold "ZeroClaw — source install")"
if [ "$PREFIX" != "$HOME" ]; then
printf " prefix: %s\n" "$(bold "$PREFIX")"
fi
echo
if [ -f "Cargo.toml" ] && grep -q "zeroclaw" "Cargo.toml" 2>/dev/null; then
INSTALL_DIR="$(pwd)"
info "Building from $(pwd)"
elif [ -d "$INSTALL_DIR/.git" ]; then
info "Updating source in $INSTALL_DIR"
git -C "$INSTALL_DIR" pull --ff-only --quiet 2>/dev/null || {
warn "Fast-forward pull failed — resetting to origin/master"
git -C "$INSTALL_DIR" fetch origin master --quiet
git -C "$INSTALL_DIR" reset --hard origin/master --quiet
}
cd "$INSTALL_DIR"
else
info "Cloning into $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# ── Parse Cargo.toml ──────────────────────────────────────────────
parse_cargo_toml "Cargo.toml"
printf " Version: %s (MSRV: %s, edition: %s)\n" "$(bold "$VERSION")" "$MSRV" "$EDITION"
# ── Preflight: Rust ───────────────────────────────────────────────
# >>> generated:rust-bootstrap by `cargo generate installers` - do not edit <<<
NEED_RUST=false
if ! command -v rustc >/dev/null 2>&1 || ! command -v cargo >/dev/null 2>&1; then
NEED_RUST=true
elif [ "$PREFIX" != "$HOME" ] && [ ! -d "$RUSTUP_HOME/toolchains" ]; then
NEED_RUST=true
fi
if [ "$NEED_RUST" = true ]; then
if [ "$DRY_RUN" = true ]; then
warn "[dry-run] Would install Rust via rustup into $RUSTUP_HOME"
else
warn "Installing Rust via rustup into $CARGO_HOME"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--no-modify-path --default-toolchain stable
. "$CARGO_HOME/env"
fi
fi
if [ "$DRY_RUN" != true ]; then
RUST_VERSION=$(rustc --version | awk '{print $2}')
if ! version_gte "$RUST_VERSION" "$MSRV"; then
die "Rust $RUST_VERSION is too old. ZeroClaw requires $MSRV+ (edition $EDITION). Run: rustup update stable"
fi
info "Rust $RUST_VERSION (>= $MSRV)"
fi
# >>> end generated:rust-bootstrap <<<
# ── Preflight: 32-bit ARM ────────────────────────────────────────
case "$(uname -m)" in
armv7l | armv6l | armhf)
die "32-bit ARM detected — the default feature 'observability-prometheus'
requires 64-bit atomics and will not compile on this architecture.
Example (full agent without prometheus):
$0 --minimal --features agent-runtime,schema-export
See all available features:
$0 --list-features"
;;
esac
# ── Build feature flags ──────────────────────────────────────────
#
# Cargo cannot remove individual entries from `default`, so toggling
# `gateway` off requires `--no-default-features` plus an explicit list
# of the rest. Derive that list from $DEFAULT_FEATURES (parsed from
# Cargo.toml above) so it stays in sync automatically.
CARGO_FLAGS=""
if [ "$MINIMAL" = true ]; then
CARGO_FLAGS="--no-default-features"
fi
# `--without-gateway` overrides the default-features set: switch to
# --no-default-features and re-add everything in `default` except gateway.
if [ "$WITH_GATEWAY" = "false" ] && [ "$MINIMAL" != true ]; then
CARGO_FLAGS="--no-default-features"
defaults_no_gateway=$(printf '%s' "$DEFAULT_FEATURES" | tr ',' '\n' | grep -vx gateway | paste -sd, -)
USER_FEATURES="${USER_FEATURES:+$USER_FEATURES,}$defaults_no_gateway"
fi
# `--with-gateway` is a no-op when default features are on (gateway is
# already there), and additive when --no-default-features is in play.
if [ "$WITH_GATEWAY" = "true" ]; then
case "$CARGO_FLAGS" in
*--no-default-features*) USER_FEATURES="${USER_FEATURES:+$USER_FEATURES,}gateway" ;;
esac
fi
# `--preset full` must actually deliver the broad bundle the installer
# advertises (whatsapp-web, channel-matrix, the heavyweight extras), not
# Cargo's lean `default`. Resolve the explicit feature list from the
# canonical registry (`cargo generate features --selection all`), the same
# source of truth the release workflow consumes, and build with
# --no-default-features against it so the advertised set is exact.
if [ "$PRESET" = "full" ]; then
preset_features=$(cargo run --quiet -p xtask --bin generate -- features --selection all 2>/dev/null || true)
if [ -n "$preset_features" ]; then
CARGO_FLAGS="--no-default-features"
USER_FEATURES="${USER_FEATURES:+$USER_FEATURES,}$preset_features"
else
warn "Could not resolve --preset full from the feature registry; falling back to default features."
fi
fi
# Interactive picker — only when the operator did not pin features or
# apps via the CLI and is running under a TTY. Skipped on `--minimal`,
# `--preset`, `--features`, `--apps`, `--with-gateway` /
# `--without-gateway`, and any non-interactive run (curl | sh).
# >>> generated:feature-picker by `cargo generate installers` - do not edit <<<
if [ -t 0 ] &&
[ "$MINIMAL" != true ] &&
[ -z "$USER_FEATURES" ] &&
[ -z "$USER_APPS" ] &&
[ -z "$PRESET" ] &&
[ -z "$WITH_GATEWAY" ]; then
discover_apps
interactive_feature_picker "Cargo.toml"
# The picker pre-checks the crate defaults and lets the operator add or
# remove any of them, so its result is the authoritative, complete
# feature set — build with --no-default-features and exactly what was
# checked. This makes unchecking a default (e.g. gateway) actually drop
# it instead of silently leaving the default applied.
CARGO_FLAGS="--no-default-features"
USER_FEATURES="$PICKED_FEATURES"
info "Picked features: ${USER_FEATURES:-}"
# Picker always resolves the app set explicitly (selected or none).
USER_APPS="${PICKED_APPS:-none}"
info "Picked apps: $USER_APPS"
fi
if [ -n "$USER_FEATURES" ]; then
# Normalize: treat commas, spaces, tabs as delimiters; deduplicate; trim empty
USER_FEATURES=$(printf '%s' "$USER_FEATURES" | tr ',[:space:]' '\n' | grep -v '^$' | sort -u | paste -sd, - || true)
if [ -n "$USER_FEATURES" ]; then
# Validate each feature
OLD_IFS="$IFS"
IFS=','
for feat in $USER_FEATURES; do
[ -n "$feat" ] && validate_feature "$feat"
done
IFS="$OLD_IFS"
CARGO_FLAGS="$CARGO_FLAGS --features $USER_FEATURES"
fi
fi
# >>> end generated:feature-picker <<<
# ── Detect existing installs ──────────────────────────────────────
PATH_BIN=$(PATH="$ORIGINAL_PATH" command -v zeroclaw 2>/dev/null || true)
if [ -n "$PATH_BIN" ]; then
PATH_VERSION=$("$PATH_BIN" --version 2>/dev/null | awk '{print $NF}' || echo "unknown")
TARGET_BIN="$CARGO_HOME/bin/zeroclaw"
if [ "$PATH_BIN" != "$TARGET_BIN" ]; then
warn "zeroclaw found at $PATH_BIN (v$PATH_VERSION)"
warn "This install targets $TARGET_BIN"
warn "The old binary will shadow the new one unless removed or PATH is reordered"
else
warn "Existing install: $PATH_BIN (v$PATH_VERSION)"
fi
if [ "$MINIMAL" = true ] && [ "$DRY_RUN" != true ]; then
if [ -t 0 ]; then
printf " --minimal will produce a reduced binary (no agent runtime by default). Continue? [Y/n] "
read -r confirm
case "$confirm" in
[Nn]*)
echo "Aborted."
exit 0
;;
esac
fi
fi
if [ "$PRESET" = "full" ] && [ "$DRY_RUN" != true ] && [ -t 1 ]; then
info "--preset full: building from source with the complete feature set (every channel plus heavyweight extras) resolved from the registry."
fi
fi
# ── Build profile RAM heuristic (Linux low-mem hosts) ─────────────
apply_low_mem_lto_default
# ── Build and install ─────────────────────────────────────────────
WANT_EMBEDDED_WEB=false
if selected_feature_enabled embedded-web; then
WANT_EMBEDDED_WEB=true
fi
echo
printf "%s\n" "$(bold "Building ZeroClaw v$VERSION")"
if [ -n "$CARGO_FLAGS" ]; then
info "Feature flags: $CARGO_FLAGS"
else
info "Feature flags: (defaults)"
fi
echo
# embedded-web includes web/dist at Rust compile time, so the dashboard must
# exist before cargo install reaches zeroclaw-gateway's build script.
if [ "$WANT_EMBEDDED_WEB" = true ]; then
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would build web dashboard before cargo install for embedded-web"
else
build_web_dashboard "$INSTALL_DIR" true
fi
fi
# >>> generated:source-cargo-install by `cargo generate installers` - do not edit <<<
if [ "$DRY_RUN" = true ]; then
# shellcheck disable=SC2086
info "[dry-run] Would run: cargo install --path . --locked --force $CARGO_FLAGS"
else
# shellcheck disable=SC2086
cargo install --path . --locked --force $CARGO_FLAGS
fi
# >>> end generated:source-cargo-install <<<
# ── Web dashboard (gateway feature only) ──────────────────────────
# When the install includes the `gateway` feature, build `web/dist` so
# the dashboard route serves something. Skips silently when the build
# excluded gateway (`--without-gateway`, `--minimal` without explicit
# gateway in --features, etc).
WANT_GATEWAY=true
case "$CARGO_FLAGS" in
*--no-default-features*)
case ",$USER_FEATURES," in
*,gateway,*) ;;
*) WANT_GATEWAY=false ;;
esac
;;
esac
if [ "$WANT_GATEWAY" = true ]; then
if [ "$DRY_RUN" = true ]; then
if [ "$WANT_EMBEDDED_WEB" = true ]; then
info "[dry-run] Web dashboard would already be built for embedded-web"
else
info "[dry-run] Would build web dashboard"
fi
elif [ "$WANT_EMBEDDED_WEB" = true ]; then
info "Web dashboard already built for embedded-web"
else
build_web_dashboard "$INSTALL_DIR"
fi
fi
# ── Apps (standalone binaries under apps/) ──────────────────
# Apps connect to zeroclaw-runtime's RPC server, so they need the
# agent-runtime feature. Without it there's no daemon — skip apps.
discover_apps
# Resolve the app set: explicit --apps list, "none" to skip, or the
# full installable set by default. --without-tui is back-compat for
# dropping the TUI app from the default set.
# >>> generated:app-selection by `cargo generate installers` - do not edit <<<
if [ "$FULL_APPS" = true ] && [ -z "$USER_APPS" ]; then
# --full installs every discovered app (an explicit --apps still wins) —
# except Tauri-based apps (tauri.conf.json present): they need the Tauri
# toolchain + system webview deps (webkit2gtk/GTK on Linux), which most
# machines don't have. Request them explicitly via --apps to opt in.
WANT_APPS=""
for app in $APPS; do
app_path=$(app_dir_for "$app") || continue
if [ -f "$app_path/tauri.conf.json" ]; then
info "Skipping $app: Tauri app needs system webview deps — install explicitly with --apps $app"
continue
fi
WANT_APPS="${WANT_APPS:+$WANT_APPS }$app"
done
elif [ "$USER_APPS" = "none" ]; then
WANT_APPS=""
elif [ -n "$USER_APPS" ]; then
WANT_APPS=$(printf '%s' "$USER_APPS" | tr ',[:space:]' '\n' | grep -v '^$' | sort -u | paste -sd' ' -)
for app in $WANT_APPS; do validate_app "$app"; done
else
WANT_APPS="$DEFAULT_APPS"
fi
# --without-tui drops the TUI app from the resolved default or --full
# set (an explicit --apps list is honored as-is).
if [ "$WITHOUT_TUI" = true ] && [ -z "$USER_APPS" ]; then
WANT_APPS=$(printf '%s' "$WANT_APPS" | tr ' ' '\n' | grep -vx "$TUI_BIN_NAME" | paste -sd' ' -)
fi
# agent-runtime is a default feature; if defaults are stripped and it
# wasn't re-added, no daemon exists to back the apps.
case "$CARGO_FLAGS" in
*--no-default-features*)
case ",$USER_FEATURES," in
*,agent-runtime,*) ;;
*) WANT_APPS="" ;;
esac
;;
esac
for app in $WANT_APPS; do
app_path=$(app_dir_for "$app") || continue
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would run: cargo install --path $app_path --locked --force"
else
echo
printf "%s\n" "$(bold "Building $app")"
echo
cargo install --path "$app_path" --locked --force
fi
done
# >>> end generated:app-selection <<<
# ── Summary ───────────────────────────────────────────────────────
if [ "$DRY_RUN" != true ]; then
BIN="$CARGO_HOME/bin/zeroclaw"
if [ -f "$BIN" ]; then
SIZE=$(du -h "$BIN" | awk '{print $1}')
NEW_VERSION=$("$BIN" --version 2>/dev/null | awk '{print $NF}' || echo "$VERSION")
echo
info "Installed: $BIN (v$NEW_VERSION, $SIZE)"
ACTIVE_BIN=$(PATH="$ORIGINAL_PATH" command -v zeroclaw 2>/dev/null || true)
if [ -n "$ACTIVE_BIN" ] && [ "$ACTIVE_BIN" != "$BIN" ]; then
ACTIVE_VERSION=$("$ACTIVE_BIN" --version 2>/dev/null | awk '{print $NF}' || echo "unknown")
echo
warn "$(bold "WARNING:") zeroclaw in your PATH is $ACTIVE_BIN (v$ACTIVE_VERSION)"
warn "It will shadow the v$NEW_VERSION binary you just installed at $BIN"
warn "Fix: remove the old binary or put $CARGO_HOME/bin earlier in your PATH"
fi
else
warn "Binary not found at expected path: $BIN"
fi
TUI_BIN="$CARGO_HOME/bin/$TUI_BIN_NAME"
if [ -f "$TUI_BIN" ]; then
TUI_SIZE=$(du -h "$TUI_BIN" | awk '{print $1}')
info "Installed: $TUI_BIN ($TUI_SIZE)"
fi
fi
# >>> generated:source-dispatch-close by `cargo generate installers` - do not edit <<<
fi # end source build block
# >>> end generated:source-dispatch-close <<<
BIN="$CARGO_HOME/bin/zeroclaw"
# ── PATH setup ────────────────────────────────────────────────────
# >>> generated:path-handoff by `cargo generate installers` - do not edit <<<
PROFILE=$(detect_shell_profile)
EXPORT_LINE=$(shell_export_syntax)
# Is our bin dir already on PATH via the profile — either pre-existing or
# from a prior run of this installer? If so there's nothing to do.
PATH_ALREADY_SET=false
if [ -f "$PROFILE" ] && grep -q "$CARGO_HOME/bin" "$PROFILE" 2>/dev/null; then
PATH_ALREADY_SET=true
fi
print_path_help() {
echo
printf " %s (%s):\n" "$(bold "Add to your shell profile")" "$PROFILE"
echo
printf " %s\n" "$EXPORT_LINE"
echo
printf " Then reload:\n"
echo
printf " source %s\n" "$PROFILE"
echo
}
if [ "$PATH_ALREADY_SET" = true ]; then
: # already on PATH — nothing to do
elif [ "$MODIFY_PATH" = true ] && [ "$PREFIX" = "$HOME" ]; then
# Auto-append to the profile, wrapped in a marker block so re-installs
# stay idempotent and an uninstall can strip it cleanly.
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would add $CARGO_HOME/bin to PATH in $PROFILE"
elif {
printf '\n# >>> zeroclaw >>>\n'
printf '%s\n' "$EXPORT_LINE"
printf '# <<< zeroclaw <<<\n'
} >>"$PROFILE" 2>/dev/null; then
info "Added $CARGO_HOME/bin to PATH in $PROFILE"
if [ "$UNIX_PATH_RELOAD" = true ]; then
printf " Reload your shell or run: source %s\n" "$PROFILE"
fi
else
warn "Could not write to $PROFILE — add this line manually:"
print_path_help
fi
else
# --no-modify-path, or a custom --prefix install we won't auto-edit for.
print_path_help
fi
# >>> end generated:path-handoff <<<
# ── Quickstart prompt ─────────────────────────────────────────────
# >>> generated:quickstart-handoff by `cargo generate installers` - do not edit <<<
if [ "$SKIP_QUICKSTART" = false ] && [ "$DRY_RUN" != true ] && [ -f "$BIN" ]; then
# Skip the prompt entirely when the operator already has a configured
# ZeroClaw — re-installs should not re-prompt.
if ! quickstart_needed; then
info "Existing ZeroClaw config detected at $PREFIX/.zeroclaw/config.toml — skipping setup prompt."
info "Run '$QUICKSTART_COMMAND' to reconfigure."
elif [ "$GUIDED_QUICKSTART_MODE" = "offer" ] && [ -t 0 ]; then
# 3-way setup choice. Bare Enter accepts the [1] CLI quickstart default;
# option [2] foregrounds the daemon so the operator can finish in the
# browser and Ctrl+C to return; [3] skips and prints a follow-up hint.
# Non-TTY runs fall through to the silent skip in the else branch.
echo
printf "%s\n" "$(bold "ZeroClaw installed. How would you like to complete setup?")"
printf " [1] CLI quickstart ($QUICKSTART_COMMAND)\n"
printf " [2] Open gateway in browser (zeroclaw daemon + dashboard)\n"
printf " [3] Skip for now\n"
printf " Choice [1-3, default 1]: "
read -r quickstart_choice
case "${quickstart_choice:-1}" in
1 | "")
echo
"$BIN" "$QUICKSTART_SUBCOMMAND" || warn "Quickstart exited with an error — run '$QUICKSTART_COMMAND' manually"
;;
2)
echo
info "Starting gateway daemon for browser-based setup..."
info "Open the dashboard in your browser; pair with the code shown in logs."
info "Stop the daemon with Ctrl+C when done; then run 'zeroclaw service install' for always-on."
"$BIN" daemon || warn "Daemon exited with an error — run 'zeroclaw daemon' manually"
;;
3)
info "Skipped setup. Run '$QUICKSTART_COMMAND' (CLI) or 'zeroclaw daemon' (browser) when ready."
;;
*)
warn "Unknown choice '$quickstart_choice' — skipping. Run '$QUICKSTART_COMMAND' to configure."
;;
esac
else
info "Non-interactive — skipping setup prompt. Run '$QUICKSTART_COMMAND' to configure."
fi
fi
# >>> end generated:quickstart-handoff <<<
echo
# Next-step hint, smartest-first: if zerocode (the TUI) was installed, that's
# the best place to start; otherwise point at the daemon + web dashboard, then
# fall back to a one-off CLI agent run.
if [ -f "$CARGO_HOME/bin/$TUI_BIN_NAME" ]; then
info "Done. Run $(bold "$TUI_BIN_NAME") to launch the terminal UI and start working."
elif [ -f "$CARGO_HOME/bin/zeroclaw" ] && "$CARGO_HOME/bin/zeroclaw" --help 2>/dev/null | grep -q '\bdaemon\b'; then
info "Done. Run $(bold "zeroclaw daemon") for the always-on daemon + web dashboard,"
info "or $(bold "zeroclaw agent") for a one-off CLI chat."
else
info "Done. Run $(bold "zeroclaw agent") to start chatting."
fi
echo