Files
ai-agent/worker/install-macos.sh
Gabriel Vidal 4975833352 fix(worker): installer prompts died with "reply: unbound variable"
ask() kept a local named like the caller's variable, so `printf -v reply`
filled the shadowing local and the caller's own `local reply` stayed unset —
fatal under set -u right after the first `y`. The helper now uses a private
name and the callers initialise their variable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:56:54 +02:00

280 lines
12 KiB
Bash

#!/usr/bin/env bash
#
# install-macos.sh — run this Mac as an ai-agent **worker**: a launchd user agent
# serving the same sidecar the homelab runs on its own host (spawn / resume /
# fork / interrupt `claude -p`), plus the transcript feed the hub pulls and the
# one-time pairing endpoint. Sessions run natively under your user, so they get
# this Mac's Keychain login and whatever its shell has (mise, gcloud, op, gh…)
# exactly like a terminal session. Any Mac — a personal one or a work seat.
#
# install-macos.sh install (or refresh) + print a pairing string
# install-macos.sh --pair print a new pairing string (re-pair / new hub)
# install-macos.sh --update git pull the checkout, reinstall deps, restart
# install-macos.sh --status launchd state + /health
# install-macos.sh --uninstall stop + remove the agent (state kept)
#
# Nothing is published beyond the tailnet: the listener binds the Mac's
# Tailscale address only. The hub always calls in; the worker never calls out.
#
# Bootstrap on a fresh Mac (clones the repo, then runs the installer from it):
# curl -fsSL https://git.gabvdl.xyz/gabrielvidal/ai-agent/raw/branch/main/worker/install-macos.sh | bash
#
# Where new runs start (the default cwd) is asked interactively: the directory
# you run the installer from, confirmed with `y`, or any path you type. Without
# a terminal (`curl | bash`) the current directory is taken as is. `--update`
# keeps whatever the installed agent already uses.
#
# The account is asked too — the hub-side id of this Mac's Claude login,
# proposed from `claude auth status` (a gmail login → `personal`, anything
# else → `work`).
#
# Env overrides (each skips its prompt): WORKER_CWD, WORKER_ACCOUNT,
# WORKER_PORT (8790), WORKER_BIND (the Tailscale IPv4), WORKER_SRC (clone dir
# for the bootstrap).
set -euo pipefail
REPO_URL="https://git.gabvdl.xyz/gabrielvidal/ai-agent.git"
LABEL="xyz.gabvdl.ai-agent-worker"
SHARE="$HOME/.local/share/ai-agent-worker"
SRC="${WORKER_SRC:-$SHARE/src}"
VENV="$SHARE/venv"
STATE="$HOME/.config/ai-agent-worker"
LOGS="$HOME/Library/Logs/ai-agent-worker"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
PORT="${WORKER_PORT:-8790}"
CWD="${WORKER_CWD:-}"
ACCOUNT="${WORKER_ACCOUNT:-}"
DOMAIN="gui/$(id -u)"
MODE="${1:-}"
die() { echo "error: $*" >&2; exit 1; }
say() { echo "==> $*"; }
[[ "$(uname -s)" == "Darwin" ]] || die "this installer is for macOS"
# ── locate the checkout (or bootstrap one) ─────────────────────────────────────
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)"
if [[ -n "$HERE" && -f "$HERE/../sidecar/sidecar.py" ]]; then
ROOT="$(cd "$HERE/.." && pwd)"
else
command -v git >/dev/null || die "git not found (xcode-select --install)"
if [[ -d "$SRC/.git" ]]; then
git -C "$SRC" pull --ff-only --quiet
else
say "cloning $REPO_URL → $SRC"
mkdir -p "$(dirname "$SRC")"
git clone --quiet --depth 1 "$REPO_URL" "$SRC"
fi
exec bash "$SRC/worker/install-macos.sh" "$@"
fi
SIDECAR="$ROOT/sidecar"
tailscale_bin() {
if command -v tailscale >/dev/null; then command -v tailscale
elif [[ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then
echo /Applications/Tailscale.app/Contents/MacOS/Tailscale
fi
}
py() { WORKER_STATE_DIR="$STATE" "$VENV/bin/python" "$@"; }
health() { curl -fsS --max-time 3 "http://$BIND:$PORT/health" 2>/dev/null; }
# A value the installed agent already runs with (its plist), "" if none.
installed() {
[[ -f "$PLIST" ]] || return 0
plutil -extract "EnvironmentVariables.$1" raw -o - "$PLIST" 2>/dev/null || true
}
# Ask on the terminal even under `curl … | bash`, where stdin is the script.
# The local must not be named like the caller's variable: `printf -v` would
# fill the shadowing local and leave the caller's unset (set -u then dies).
ask() { # ask <prompt> <var>
local _ask_reply=""
if [[ -r /dev/tty && -w /dev/tty ]]; then
read -r -p "$1" _ask_reply </dev/tty || true
fi
printf -v "$2" '%s' "$_ask_reply"
}
# Where new runs start: the directory this installer was launched from (the
# bootstrap `exec` keeps it), confirmed with `y`/Enter, or a path typed instead.
pick_cwd() {
[[ -n "$CWD" ]] && return
local here reply=""; here="$(installed SIDECAR_CWD)"
if [[ "$MODE" == "--update" && -n "$here" && -d "$here" ]]; then
CWD="$here"; say "keeping cwd $CWD (set WORKER_CWD to change)"; return
fi
[[ -n "$here" && -d "$here" ]] || here="$(pwd -P)"
while :; do
ask "Default working dir for new runs — $here ? [y / a path] " reply
case "$reply" in
""|y|Y|yes|YES) CWD="$here"; break ;;
*) reply="${reply/#\~/$HOME}"
if [[ -d "$reply" ]]; then CWD="$(cd "$reply" && pwd -P)"; break; fi
echo " no such directory: $reply" >&2 ;;
esac
done
}
# The email this Mac's `claude` is signed in with (Keychain), "" if unknown.
claude_login() {
"$CLAUDE_BIN" auth status 2>/dev/null \
| "$VENV/bin/python" -c 'import json,sys
try: print(json.load(sys.stdin).get("email") or "")
except Exception: print("")' 2>/dev/null || true
}
# The hub-side id of this Mac's Claude login (`personal`, `work`…). Kept across
# --update; otherwise asked, proposed from the login itself: a gmail address is
# Gabriel's own plan, anything else a company seat.
pick_account() {
[[ -n "$ACCOUNT" ]] && return
local prev guess email reply=""; prev="$(installed SIDECAR_DEFAULT_ACCOUNT)"
if [[ "$MODE" == "--update" && -n "$prev" ]]; then
ACCOUNT="$prev"; say "keeping account $ACCOUNT (set WORKER_ACCOUNT to change)"; return
fi
email="$(claude_login)"
case "$email" in
"") guess="${prev:-personal}" ;;
*@gmail.com|*@googlemail.com) guess="personal" ;;
*) guess="work" ;;
esac
while :; do
ask "Account this Mac's login (${email:-unknown}) is known by on the hub — $guess ? [y / an id] " reply
case "$reply" in
""|y|Y|yes|YES) ACCOUNT="$guess"; break ;;
*) reply="$(tr '[:upper:]' '[:lower:]' <<<"$reply")"
if [[ "$reply" =~ ^[a-z][a-z0-9_-]{0,31}$ ]]; then ACCOUNT="$reply"; break; fi
echo " an account id is [a-z][a-z0-9_-]{0,31}" >&2 ;;
esac
done
}
resolve_bind() {
BIND="${WORKER_BIND:-}"
if [[ -z "$BIND" ]]; then
local ts; ts="$(tailscale_bin)"
[[ -n "$ts" ]] || die "Tailscale not found — install it and log in to the tailnet"
BIND="$("$ts" ip -4 2>/dev/null | head -1 || true)"
fi
[[ "$BIND" =~ ^100\. ]] || die "no Tailscale IPv4 (got '${BIND:-nothing}') — is Tailscale up?"
}
pairing_string() {
local code email
code="$(py "$SIDECAR/pairing.py" new-code)"
# The login this Mac's `claude` already has (Keychain): the hub matches it to
# one of its accounts, so the pairing dialog needs no account pick.
email="$(claude_login)"
echo
echo "Paste this in the hub → Settings → Workers → Add worker (one use):"
echo
echo " $BIND:$PORT/$code${email:+/$email}"
echo
}
case "${1:-}" in
--uninstall)
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
say "removed $LABEL (state kept in $STATE — delete it to forget the pairing)"
exit 0 ;;
--status)
launchctl print "$DOMAIN/$LABEL" 2>/dev/null | grep -E "state|pid|last exit" || echo "not loaded"
resolve_bind; health && echo || echo "no /health on $BIND:$PORT"
exit 0 ;;
--update)
git -C "$ROOT" pull --ff-only
;;
--pair)
[[ -x "$VENV/bin/python" ]] || die "not installed yet — run without --pair"
resolve_bind
CLAUDE_BIN="$(command -v claude || echo claude)"
pairing_string
exit 0 ;;
"") ;;
*) die "unknown option: $1" ;;
esac
# ── prerequisites ──────────────────────────────────────────────────────────────
CLAUDE_BIN="$(command -v claude || true)"
[[ -n "$CLAUDE_BIN" ]] || die "claude not found on PATH — install Claude Code and log in first"
"$CLAUDE_BIN" auth status 2>/dev/null | grep -q '"loggedIn": *true' \
|| echo "warning: \`claude auth status\` doesn't say logged in — runs will fail until you /login" >&2
pick_cwd
[[ -d "$CWD" ]] || die "default cwd $CWD doesn't exist (set WORKER_CWD)"
resolve_bind
# ── venv ───────────────────────────────────────────────────────────────────────
say "venv + deps ($VENV)"
mkdir -p "$SHARE" "$STATE" "$LOGS/runs"
chmod 700 "$STATE"
# `command -v uv` alone is not enough on a mise-managed Mac: mise installs a
# `uv` shim that errors out ("No version is set for shim") when no global
# version is pinned, so only take the uv path when it actually runs.
if command -v uv >/dev/null && uv --version >/dev/null 2>&1; then
[[ -x "$VENV/bin/python" ]] || uv venv --quiet "$VENV"
uv pip install --quiet --python "$VENV/bin/python" -r "$SIDECAR/requirements.txt"
else
[[ -x "$VENV/bin/python" ]] || python3 -m venv "$VENV"
"$VENV/bin/pip" install --quiet -r "$SIDECAR/requirements.txt"
fi
pick_account
# ── the notify skill ───────────────────────────────────────────────────────────
# A session here reaches Gabriel through this repo's `notify` CLI (cli/, put on
# every run's PATH by the sidecar; it queues into the worker's outbox and the
# hub collects it). Its SKILL.md is linked into the user-level skills dir so
# `claude` knows the command whatever repo the run starts in.
SKILLS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"
mkdir -p "$SKILLS_DIR"
ln -sfn "$ROOT/.claude/skills/notify" "$SKILLS_DIR/notify"
say "linked $SKILLS_DIR/notify → $ROOT/.claude/skills/notify"
# ── launchd agent ──────────────────────────────────────────────────────────────
# PATH is the one this installer runs with (your login shell's), so a session
# finds the same tools (mise shims, gcloud, op, gh…) a terminal does.
say "writing $PLIST (bind $BIND:$PORT, cwd $CWD, account $ACCOUNT)"
mkdir -p "$(dirname "$PLIST")"
# plistlib, not sed: paths with spaces/&/| can't break the XML.
LABEL="$LABEL" PY="$VENV/bin/python" SIDECAR="$SIDECAR" BIND="$BIND" PORT="$PORT" \
CWD="$CWD" ACCOUNT="$ACCOUNT" CLAUDE_BIN="$CLAUDE_BIN" STATE="$STATE" LOGS="$LOGS" \
RUN_PATH="$(dirname "$CLAUDE_BIN"):$PATH" PLIST="$PLIST" \
"$VENV/bin/python" "$ROOT/worker/make_plist.py"
plutil -lint "$PLIST" >/dev/null
# bootout is asynchronous: bootstrapping while launchd is still tearing the
# old agent down fails with "Bootstrap failed: 5: Input/output error" and
# leaves the worker DOWN. Wait for the label to disappear, then retry the
# bootstrap a few times (a plist error would fail every time — die loudly).
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
for _ in $(seq 1 40); do
launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break
sleep 0.5
done
booted=0
for attempt in 1 2 3 4 5 6; do
if launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null; then booted=1; break; fi
# already loaded (a racing KeepAlive restart) counts as success
launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 && { booted=1; break; }
sleep $attempt
done
[[ "$booted" == 1 ]] || {
launchctl bootstrap "$DOMAIN" "$PLIST" || true # once more, loudly, for the error text
die "launchd refused to load $LABEL — see above; retry with: launchctl bootstrap $DOMAIN $PLIST"
}
launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true
say "waiting for http://$BIND:$PORT/health"
for _ in $(seq 1 30); do health >/dev/null && break; sleep 1; done
health >/dev/null || die "worker didn't come up — see $LOGS/worker.log"
echo " $(health)"
if [[ "$MODE" == "--update" ]] && py "$SIDECAR/pairing.py" status | grep -q '"paired": true'; then
say "updated; still paired"
exit 0
fi
pairing_string