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>
280 lines
12 KiB
Bash
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
|