- liveness falls back to psutil where there is no /proc (macOS) - GET /feed + /feed/file: the hub pulls transcripts it has no mount for - POST /pair trades a one-time code for the worker's bearer; /unpair drops it - /health reports worker identity + permission mode - worker/install-macos.sh: venv, launchd agent bound to the Tailscale IP, prints the pairing string (addr/code/claude login) - Dockerfile copies every sidecar module (claude_cli was missing, so the in-container runner could not import) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
167 lines
7.1 KiB
Bash
167 lines
7.1 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
|
|
# the Keychain login, mise, gcloud, op, gh… exactly like a terminal session.
|
|
#
|
|
# 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
|
|
#
|
|
# Env overrides: WORKER_PORT (8790), WORKER_CWD (~/projects/orus-monorepo),
|
|
# WORKER_ACCOUNT (work — the account id this Mac's login is known by),
|
|
# 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:-$HOME/projects/orus-monorepo}"
|
|
ACCOUNT="${WORKER_ACCOUNT:-work}"
|
|
DOMAIN="gui/$(id -u)"
|
|
|
|
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; }
|
|
|
|
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_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("")' || true)"
|
|
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
|
|
[[ -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"
|
|
if command -v uv >/dev/null; 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
|
|
|
|
# ── launchd agent ──────────────────────────────────────────────────────────────
|
|
# PATH is the one this installer runs with (your login shell's), so a session
|
|
# finds the same mise shims, gcloud, op and gh a terminal does.
|
|
say "writing $PLIST (bind $BIND:$PORT, cwd $CWD)"
|
|
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
|
|
|
|
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
|
|
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 [[ "${1:-}" == "--update" ]] && py "$SIDECAR/pairing.py" status | grep -q '"paired": true'; then
|
|
say "updated; still paired"
|
|
exit 0
|
|
fi
|
|
pairing_string
|