@geekbeer/minion 3.42.3 → 3.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1353 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Minion Agent CLI (macOS) - @geekbeer/minion
4
+ # CLI tool for setting up and managing the minion agent on macOS via launchd.
5
+ #
6
+ # Usage:
7
+ # sudo minion-cli-mac setup --user <USERNAME> # System setup (root): user, sudoers, Screen Sharing, plists
8
+ # minion-cli-mac configure [options] # Connect to HQ, deploy skills, restart agent (run as agent user)
9
+ # sudo minion-cli-mac uninstall [--keep-data] # Remove agent and services (root)
10
+ # minion-cli-mac start # Bootstrap agent LaunchAgents
11
+ # minion-cli-mac stop # Bootout agent LaunchAgents
12
+ # minion-cli-mac restart # Kickstart agent LaunchAgent
13
+ # minion-cli-mac status # Get current status
14
+ # minion-cli-mac health # Health check
15
+ # minion-cli-mac daemons # Daemon status
16
+ # minion-cli-mac diagnose # Run full service diagnostics
17
+ # minion-cli-mac set-status busy "Running X" # Set status and task
18
+ #
19
+ # Setup options:
20
+ # --user <USERNAME> Target user for the agent (required)
21
+ #
22
+ # Configure options:
23
+ # --hq-url <URL> HQ server URL (required)
24
+ # --minion-id <UUID> Minion ID (required)
25
+ # --api-token <TOKEN> API token (required)
26
+ # --setup-tunnel Set up cloudflared tunnel as a LaunchAgent
27
+
28
+ set -euo pipefail
29
+
30
+ # Set MINION_TRACE=1 to enable shell tracing (every command printed to stderr
31
+ # with a line number prefix). Useful when setup hangs and the standard output
32
+ # does not pinpoint the culprit.
33
+ #
34
+ # To capture setup output to a log file while preserving live terminal output,
35
+ # use macOS's built-in `script` command (it allocates a pty so output stays
36
+ # line-buffered):
37
+ #
38
+ # script -q /tmp/minion-setup.log sudo minion-cli-mac setup --user minion
39
+ #
40
+ # Avoid `... | tee log` — piping converts stdout to a pipe, which can buffer
41
+ # output and make setup appear frozen even when it is making progress.
42
+ if [ "${MINION_TRACE:-0}" = "1" ]; then
43
+ PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '
44
+ set -x
45
+ fi
46
+
47
+ # ============================================================
48
+ # Globals & helpers
49
+ # ============================================================
50
+
51
+ # Default target user is whoever invoked the script, refined per subcommand.
52
+ if [ "$(id -u)" -eq 0 ]; then
53
+ # When running via sudo, prefer SUDO_USER over root for ergonomics.
54
+ TARGET_USER="${SUDO_USER:-root}"
55
+ else
56
+ TARGET_USER="$(whoami)"
57
+ fi
58
+ TARGET_HOME="$(eval echo "~${TARGET_USER}")"
59
+ RUN_AS=""
60
+
61
+ # Use sudo only when not running as root
62
+ SUDO=""
63
+ if [ "$(id -u)" -ne 0 ] && command -v sudo &>/dev/null; then
64
+ SUDO="sudo"
65
+ fi
66
+
67
+ # Resolve CLI version from package.json
68
+ CLI_DIR="$(cd "$(dirname "$0")" && pwd)"
69
+ CLI_VERSION="$(node -p "require('${CLI_DIR}/../package.json').version" 2>/dev/null || echo "unknown")"
70
+
71
+ AGENT_URL="${MINION_AGENT_URL:-http://localhost:8080}"
72
+
73
+ # Brew prefix detection (Apple Silicon vs Intel)
74
+ detect_brew_prefix() {
75
+ if command -v brew &>/dev/null; then
76
+ brew --prefix 2>/dev/null
77
+ return
78
+ fi
79
+ if [ -d /opt/homebrew ]; then echo "/opt/homebrew"; return; fi
80
+ if [ -d /usr/local/Homebrew ]; then echo "/usr/local"; return; fi
81
+ echo ""
82
+ }
83
+ BREW_PREFIX="$(detect_brew_prefix)"
84
+
85
+ # Resolve a user's home directory using dscl (macOS-native; getent does not exist).
86
+ resolve_user_home() {
87
+ local user="$1"
88
+ dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}'
89
+ }
90
+
91
+ # Check whether a user exists.
92
+ user_exists() {
93
+ dscl . -read "/Users/$1" >/dev/null 2>&1
94
+ }
95
+
96
+ # Get a user's UID via dscl (since Linux's `id -u <user>` exists on macOS too,
97
+ # but dscl is the canonical macOS lookup that works for any user, including
98
+ # pre-creation validation — wrap both for robustness).
99
+ resolve_uid() {
100
+ local user="$1"
101
+ id -u "$user" 2>/dev/null || dscl . -read "/Users/${user}" UniqueID 2>/dev/null | awk '{print $2}'
102
+ }
103
+
104
+ # Generate a random alphanumeric string of length $1.
105
+ #
106
+ # We deliberately avoid `tr -dc 'A-Za-z0-9' </dev/urandom | head -c N` which
107
+ # is the typical Linux idiom but hangs intermittently on macOS: BSD `tr`
108
+ # blocks in read() on /dev/urandom and does not always honor SIGPIPE when
109
+ # `head` exits, leaving the script stuck. Reading a fixed number of bytes
110
+ # upfront and base64-encoding sidesteps the SIGPIPE timing entirely.
111
+ gen_random_string() {
112
+ local len="$1"
113
+ # base64 of 48 bytes = 64 chars; that's plenty even after stripping
114
+ # +/= and newlines for any practical len <= 48.
115
+ LC_ALL=C dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | LC_ALL=C tr -d '+/=\n' | cut -c1-"$len"
116
+ }
117
+
118
+ # Require root for system management commands.
119
+ require_root() {
120
+ if [ "$(id -u)" -ne 0 ]; then
121
+ echo "Error: 'minion-cli-mac $1' requires root privileges."
122
+ echo "Usage: sudo minion-cli-mac $1"
123
+ exit 1
124
+ fi
125
+ }
126
+
127
+ # Detect LAN IPv4 address (best-effort)
128
+ detect_lan_ip() {
129
+ ipconfig getifaddr en0 2>/dev/null \
130
+ || ipconfig getifaddr en1 2>/dev/null \
131
+ || ifconfig 2>/dev/null | awk '/inet / && $2 != "127.0.0.1" {print $2; exit}'
132
+ }
133
+
134
+ # Auto-load .env so that API_TOKEN etc. are available in interactive shells
135
+ ENV_FILE="${TARGET_HOME}/.minion/.env"
136
+ if [ -f "$ENV_FILE" ] && [ -r "$ENV_FILE" ]; then
137
+ while IFS='=' read -r key value; do
138
+ [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
139
+ if [ -z "${!key:-}" ]; then
140
+ export "$key=$value"
141
+ fi
142
+ done < "$ENV_FILE"
143
+ fi
144
+
145
+ # ============================================================
146
+ # launchctl helpers
147
+ # ============================================================
148
+
149
+ # Path to a user's LaunchAgent plist.
150
+ plist_path() {
151
+ local user="$1"
152
+ local label="$2"
153
+ echo "$(resolve_user_home "$user")/Library/LaunchAgents/${label}.plist"
154
+ }
155
+
156
+ # Domain target for `launchctl` (gui/<uid>/<label>).
157
+ launchd_target() {
158
+ local user="$1"
159
+ local label="$2"
160
+ local uid
161
+ uid="$(resolve_uid "$user")"
162
+ echo "gui/${uid}/${label}"
163
+ }
164
+
165
+ # Bootstrap a LaunchAgent for a target user (idempotent).
166
+ bootstrap_launch_agent() {
167
+ local user="$1"
168
+ local label="$2"
169
+ local plist
170
+ plist="$(plist_path "$user" "$label")"
171
+ local uid
172
+ uid="$(resolve_uid "$user")"
173
+
174
+ if [ ! -f "$plist" ]; then
175
+ echo " WARNING: plist not found: $plist"
176
+ return 1
177
+ fi
178
+
179
+ # `launchctl bootstrap gui/<uid>` requires that GUI session to exist (i.e.,
180
+ # the user must be logged in). If not logged in, the plist still loads on
181
+ # next login because it lives in ~/Library/LaunchAgents.
182
+ if [ "$(id -u)" -eq 0 ]; then
183
+ sudo -u "$user" launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || return 1
184
+ else
185
+ launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || return 1
186
+ fi
187
+ }
188
+
189
+ # Bootout (unload) a LaunchAgent for a target user (idempotent).
190
+ bootout_launch_agent() {
191
+ local user="$1"
192
+ local label="$2"
193
+ local target
194
+ target="$(launchd_target "$user" "$label")"
195
+ if [ "$(id -u)" -eq 0 ]; then
196
+ sudo -u "$user" launchctl bootout "$target" 2>/dev/null || true
197
+ else
198
+ launchctl bootout "$target" 2>/dev/null || true
199
+ fi
200
+ }
201
+
202
+ # Kickstart-restart a LaunchAgent.
203
+ kickstart_launch_agent() {
204
+ local user="$1"
205
+ local label="$2"
206
+ local target
207
+ target="$(launchd_target "$user" "$label")"
208
+ if [ "$(id -u)" -eq 0 ]; then
209
+ sudo -u "$user" launchctl kickstart -k "$target"
210
+ else
211
+ launchctl kickstart -k "$target"
212
+ fi
213
+ }
214
+
215
+ # ============================================================
216
+ # Admin-context helper (Homebrew refuses to run as root)
217
+ # ============================================================
218
+ #
219
+ # Setup runs via `sudo`, so $UID is 0. But Homebrew explicitly refuses to
220
+ # operate as root (it would give every build script unrestricted system
221
+ # access). We must drop back to the user who invoked sudo — which is also
222
+ # the user who installed Homebrew.
223
+
224
+ # Resolve the non-root admin user who invoked the script.
225
+ resolve_admin_user() {
226
+ if [ "$(id -u)" -ne 0 ]; then
227
+ whoami
228
+ return
229
+ fi
230
+ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
231
+ echo "$SUDO_USER"
232
+ return
233
+ fi
234
+ # Fall back to the brew binary's owner (whoever installed Homebrew)
235
+ if command -v brew &>/dev/null; then
236
+ stat -f%Su "$(command -v brew)" 2>/dev/null && return
237
+ fi
238
+ echo ""
239
+ }
240
+
241
+ # Run a command as the admin (non-root) user. Fails loudly if no admin user
242
+ # can be resolved (e.g., logged in directly as root with no Homebrew).
243
+ run_as_admin() {
244
+ local admin
245
+ admin="$(resolve_admin_user)"
246
+ if [ -z "$admin" ] || [ "$admin" = "root" ]; then
247
+ echo "ERROR: cannot determine the non-root user to run '$*' as." >&2
248
+ echo " Setup must be invoked via sudo from a regular admin account" >&2
249
+ echo " (e.g., 'sudo minion-cli-mac setup --user minion')." >&2
250
+ exit 1
251
+ fi
252
+ if [ "$admin" = "$(whoami)" ]; then
253
+ "$@"
254
+ else
255
+ sudo -u "$admin" -H "$@"
256
+ fi
257
+ }
258
+
259
+ # Run a command as the target user. The `-H` flag is critical: without it,
260
+ # sudo preserves HOME from the caller (e.g. /Users/yunoda when setup is run
261
+ # via `sudo minion-cli-mac`), causing tools like pipx and the claude
262
+ # installer to write into the wrong user's home directory.
263
+ run_as_target() {
264
+ sudo -u "$TARGET_USER" -H "$@"
265
+ }
266
+
267
+ # Service control helper for default agent label.
268
+ LABEL_AGENT="com.geekbeer.minion"
269
+ LABEL_TMUX_INIT="com.geekbeer.tmux-init"
270
+ LABEL_WEBSOCKIFY="com.geekbeer.websockify" # legacy; kept for uninstall path
271
+ LABEL_VNC_PROXY="com.geekbeer.vnc-proxy"
272
+ LABEL_CLOUDFLARED="com.geekbeer.cloudflared"
273
+
274
+ svc_control() {
275
+ local action="$1"
276
+ local user="${2:-$TARGET_USER}"
277
+ case "$action" in
278
+ start) bootstrap_launch_agent "$user" "$LABEL_AGENT" ;;
279
+ stop) bootout_launch_agent "$user" "$LABEL_AGENT" ;;
280
+ restart) kickstart_launch_agent "$user" "$LABEL_AGENT" ;;
281
+ *) echo "Unknown svc_control action: $action"; exit 1 ;;
282
+ esac
283
+ }
284
+
285
+ # ============================================================
286
+ # Plist generation
287
+ # ============================================================
288
+
289
+ # Write a LaunchAgent plist file. Args:
290
+ # $1 = path
291
+ # $2 = body (XML, between <dict>...</dict>)
292
+ write_plist() {
293
+ local path="$1"
294
+ local body="$2"
295
+ $SUDO mkdir -p "$(dirname "$path")"
296
+ $SUDO tee "$path" > /dev/null <<PLISTEOF
297
+ <?xml version="1.0" encoding="UTF-8"?>
298
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
299
+ <plist version="1.0">
300
+ <dict>
301
+ ${body}
302
+ </dict>
303
+ </plist>
304
+ PLISTEOF
305
+ }
306
+
307
+ # ============================================================
308
+ # setup subcommand
309
+ # ============================================================
310
+ do_setup() {
311
+ require_root setup
312
+
313
+ local CLI_USER=""
314
+ while [[ $# -gt 0 ]]; do
315
+ case "$1" in
316
+ --user) CLI_USER="$2"; shift 2 ;;
317
+ --non-interactive) shift ;;
318
+ *) echo "Unknown option: $1"; echo "Usage: sudo minion-cli-mac setup --user <USERNAME>"; exit 1 ;;
319
+ esac
320
+ done
321
+
322
+ if [ -z "$CLI_USER" ]; then
323
+ echo "ERROR: --user flag is required."
324
+ echo "Usage: sudo minion-cli-mac setup --user <USERNAME>"
325
+ echo ""
326
+ echo "If the user doesn't exist yet, the script will create it for you."
327
+ exit 1
328
+ fi
329
+
330
+ TARGET_USER="$CLI_USER"
331
+
332
+ echo "========================================="
333
+ echo " @geekbeer/minion macOS Setup"
334
+ echo "========================================="
335
+ echo "Target user: $TARGET_USER"
336
+ echo "macOS: $(sw_vers -productVersion 2>/dev/null || echo unknown)"
337
+ echo "Arch: $(uname -m)"
338
+ echo "Brew prefix: ${BREW_PREFIX:-NOT FOUND}"
339
+ echo ""
340
+
341
+ if [ -z "$BREW_PREFIX" ] || ! command -v brew &>/dev/null; then
342
+ echo "ERROR: Homebrew is not installed."
343
+ echo ""
344
+ echo "Install it first:"
345
+ echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
346
+ echo ""
347
+ echo "Then re-run setup."
348
+ exit 1
349
+ fi
350
+
351
+ local TOTAL_STEPS=14
352
+
353
+ # Step 1: Create target user if missing
354
+ echo "[1/${TOTAL_STEPS}] Verifying target user '$TARGET_USER'..."
355
+ if user_exists "$TARGET_USER"; then
356
+ # Verify the existing user has a usable password (else auto-login + VNC will fail)
357
+ if dscl . -read "/Users/${TARGET_USER}" Password 2>/dev/null | grep -q '^Password: \*$'; then
358
+ echo " -> User exists but has NO PASSWORD set."
359
+ echo " macOS auto-login and Screen Sharing both require a password."
360
+ echo " Set one with: sudo dscl . -passwd /Users/${TARGET_USER}"
361
+ echo " Then re-run setup."
362
+ exit 1
363
+ fi
364
+ echo " -> User already exists"
365
+ else
366
+ echo " -> User does not exist; creating..."
367
+ # Prompt for a password: macOS auto-login + Screen Sharing both REQUIRE a real
368
+ # password. sysadminctl's `-password -` (read from stdin) is unreliable inside
369
+ # sudo subshells, so we prompt via /dev/tty and pass the password as a literal.
370
+ if [ ! -t 0 ] && [ ! -r /dev/tty ]; then
371
+ echo " ERROR: cannot prompt for password (non-interactive shell)."
372
+ echo " Run setup from an interactive terminal, or pre-create the user:"
373
+ echo " sudo sysadminctl -addUser ${TARGET_USER} -fullName 'Minion Agent' -password 'YOUR_PASSWORD'"
374
+ exit 1
375
+ fi
376
+
377
+ local PW="" PW2=""
378
+ while :; do
379
+ printf " Set initial password for '%s' (required for auto-login + VNC): " "$TARGET_USER" > /dev/tty
380
+ IFS= read -r -s PW < /dev/tty
381
+ echo "" > /dev/tty
382
+ printf " Confirm: " > /dev/tty
383
+ IFS= read -r -s PW2 < /dev/tty
384
+ echo "" > /dev/tty
385
+ if [ -z "$PW" ]; then
386
+ echo " ERROR: password cannot be empty. Try again." > /dev/tty
387
+ continue
388
+ fi
389
+ if [ "$PW" != "$PW2" ]; then
390
+ echo " ERROR: passwords do not match. Try again." > /dev/tty
391
+ continue
392
+ fi
393
+ break
394
+ done
395
+
396
+ if ! sysadminctl -addUser "$TARGET_USER" -fullName "Minion Agent" -password "$PW" 2>&1; then
397
+ unset PW PW2
398
+ echo " ERROR: sysadminctl failed to create user"
399
+ exit 1
400
+ fi
401
+ unset PW PW2
402
+
403
+ # Sanity check: confirm the user was actually created with a password
404
+ if ! user_exists "$TARGET_USER"; then
405
+ echo " ERROR: user creation appeared to succeed but '$TARGET_USER' does not exist"
406
+ exit 1
407
+ fi
408
+ if dscl . -read "/Users/${TARGET_USER}" Password 2>/dev/null | grep -q '^Password: \*$'; then
409
+ echo " ERROR: user was created but has no password set (sysadminctl issue)"
410
+ echo " Set it manually: sudo dscl . -passwd /Users/${TARGET_USER}"
411
+ exit 1
412
+ fi
413
+ echo " -> User '$TARGET_USER' created"
414
+ fi
415
+
416
+ TARGET_HOME="$(resolve_user_home "$TARGET_USER")"
417
+ if [ -z "$TARGET_HOME" ]; then
418
+ echo "ERROR: Failed to resolve home directory for '$TARGET_USER'"
419
+ exit 1
420
+ fi
421
+ echo " Home: $TARGET_HOME"
422
+
423
+ # FileVault warning
424
+ if fdesetup status 2>/dev/null | grep -q "FileVault is On"; then
425
+ echo ""
426
+ echo " ⚠️ FileVault is ENABLED. macOS does NOT permit auto-login when"
427
+ echo " FileVault is on, which means the minion user will not log in"
428
+ echo " automatically at boot — and Screen Sharing requires a logged-in"
429
+ echo " GUI session. Disable FileVault, or accept that the agent will"
430
+ echo " only run after manual login."
431
+ echo ""
432
+ fi
433
+
434
+ # Step 2: Install Homebrew dependencies (as the admin who invoked sudo;
435
+ # Homebrew refuses to run as root)
436
+ echo "[2/${TOTAL_STEPS}] Installing dependencies via Homebrew..."
437
+ local ADMIN_USER
438
+ ADMIN_USER="$(resolve_admin_user)"
439
+ echo " Running brew as: $ADMIN_USER"
440
+ # - cloudflared: bundled here so `configure --setup-tunnel` works without
441
+ # bouncing back to admin (the minion user can't run brew install).
442
+ # NOTE: websockify and noVNC web assets are NO LONGER required as of v3.47.8.
443
+ # VNC is bridged by mac/vnc-auth-proxy.js (Node, bundled with the
444
+ # package), which performs RFB auth against macOS Screen Sharing
445
+ # using a password stored only in ~/.minion/.env. The HQ-side noVNC
446
+ # supplies the web assets.
447
+ local BREW_PKGS=(tmux ttyd jq node cloudflared)
448
+ for pkg in "${BREW_PKGS[@]}"; do
449
+ if run_as_admin brew list --formula "$pkg" >/dev/null 2>&1; then
450
+ echo " -> $pkg already installed"
451
+ else
452
+ echo " -> installing $pkg..."
453
+ run_as_admin brew install "$pkg"
454
+ fi
455
+ done
456
+
457
+ # Step 3: Install Claude Code (as target user)
458
+ echo "[3/${TOTAL_STEPS}] Installing Claude Code (as $TARGET_USER)..."
459
+ if run_as_target bash -lc 'command -v claude' &>/dev/null; then
460
+ echo " -> Claude Code already installed"
461
+ else
462
+ run_as_target bash -lc 'curl -fsSL https://claude.ai/install.sh | bash' || {
463
+ echo " WARNING: Claude Code installation may have failed"
464
+ }
465
+ fi
466
+ echo " IMPORTANT: Authenticate later by logging in via Screen Sharing and running: claude"
467
+
468
+ # Step 4: Install Gemini CLI (run as admin to avoid root-owned files in
469
+ # Homebrew Node's /opt/homebrew/lib/node_modules/, which is brew-owned)
470
+ echo "[4/${TOTAL_STEPS}] Installing Gemini CLI..."
471
+ if command -v gemini &>/dev/null; then
472
+ echo " -> Gemini CLI already installed"
473
+ else
474
+ run_as_admin npm install -g @google/gemini-cli || echo " WARNING: Gemini CLI install failed; continuing"
475
+ fi
476
+
477
+ # Step 5: Create data directory under target user's home
478
+ echo "[5/${TOTAL_STEPS}] Creating ${TARGET_HOME}/.minion/..."
479
+ run_as_target mkdir -p "${TARGET_HOME}/.minion" "${TARGET_HOME}/.minion/logs" "${TARGET_HOME}/files"
480
+ echo " -> ${TARGET_HOME}/.minion/ ready"
481
+
482
+ # Step 6: Ensure Homebrew shellenv is loaded for the target user's login shells.
483
+ # Freshly-created macOS users get an empty $PATH (no Homebrew bin), so the
484
+ # `minion-cli-mac configure` step in the next phase fails with "command not
485
+ # found" unless the user logs in via a shell that has been bootstrapped.
486
+ # zsh is the default login shell on modern macOS, so .zprofile is sufficient.
487
+ echo "[6/${TOTAL_STEPS}] Configuring shell PATH for ${TARGET_USER}..."
488
+ local ZPROFILE="${TARGET_HOME}/.zprofile"
489
+ run_as_target touch "$ZPROFILE"
490
+ if run_as_target grep -qF "brew shellenv" "$ZPROFILE"; then
491
+ echo " -> brew shellenv already present in $ZPROFILE"
492
+ else
493
+ run_as_target tee -a "$ZPROFILE" > /dev/null <<EOF
494
+
495
+ # Added by minion-cli-mac setup — exposes Homebrew + npm-global bins
496
+ eval "\$(${BREW_PREFIX}/bin/brew shellenv)"
497
+ EOF
498
+ echo " -> appended brew shellenv to $ZPROFILE"
499
+ fi
500
+
501
+ # Step 7: Generate default .env
502
+ # Includes a randomly-generated VNC_PASSWORD that is shared between the
503
+ # macOS Screen Sharing config (set in Step 11) and the Node-based VNC auth
504
+ # proxy (mac/vnc-auth-proxy.js). The password never leaves this machine —
505
+ # HQ does not need it because the proxy authenticates locally on its behalf.
506
+ echo "[7/${TOTAL_STEPS}] Generating .env file..."
507
+ echo " ENV_PATH: ${TARGET_HOME}/.minion/.env"
508
+ if [ ! -f "${TARGET_HOME}/.minion/.env" ]; then
509
+ echo " (.env does not exist; creating fresh)"
510
+ echo " -> generating VNC password..."
511
+ local VNC_PW
512
+ VNC_PW="$(gen_random_string 24)"
513
+ if [ -z "$VNC_PW" ]; then
514
+ echo " ERROR: gen_random_string produced empty value; aborting"
515
+ exit 1
516
+ fi
517
+ echo " -> VNC password generated (${#VNC_PW} chars)"
518
+ echo " -> writing .env via tee (as ${TARGET_USER})..."
519
+ # Pre-create the directory so `tee` doesn't trip on a missing path.
520
+ run_as_target mkdir -p "${TARGET_HOME}/.minion"
521
+ run_as_target tee "${TARGET_HOME}/.minion/.env" > /dev/null <<EEOF
522
+ # Minion Agent Configuration
523
+ # Generated by minion-cli-mac setup
524
+
525
+ AGENT_PORT=8080
526
+ MINION_USER=${TARGET_USER}
527
+ REFLECTION_TIME=03:00
528
+
529
+ # VNC password used by mac/vnc-auth-proxy.js to authenticate with
530
+ # macOS Screen Sharing. Never sent to HQ.
531
+ VNC_PASSWORD=${VNC_PW}
532
+ EEOF
533
+ echo " -> tee complete; setting mode 0600..."
534
+ run_as_target chmod 600 "${TARGET_HOME}/.minion/.env"
535
+ echo " -> ${TARGET_HOME}/.minion/.env created (mode 0600, VNC password generated)"
536
+ else
537
+ echo " (.env exists; checking for VNC_PASSWORD line)"
538
+ if ! run_as_target grep -q '^VNC_PASSWORD=' "${TARGET_HOME}/.minion/.env"; then
539
+ echo " -> VNC_PASSWORD missing; generating and appending"
540
+ local VNC_PW
541
+ VNC_PW="$(gen_random_string 24)"
542
+ run_as_target tee -a "${TARGET_HOME}/.minion/.env" > /dev/null <<EEOF
543
+
544
+ # VNC password used by mac/vnc-auth-proxy.js (added by setup upgrade)
545
+ VNC_PASSWORD=${VNC_PW}
546
+ EEOF
547
+ echo " -> appended VNC_PASSWORD to existing .env"
548
+ else
549
+ echo " -> ${TARGET_HOME}/.minion/.env already exists, preserving"
550
+ fi
551
+ run_as_target chmod 600 "${TARGET_HOME}/.minion/.env"
552
+ fi
553
+
554
+ # Step 8: Configure sudoers for agent user
555
+ echo "[8/${TOTAL_STEPS}] Configuring sudoers..."
556
+ local NPM_BIN BREW_BIN LAUNCHCTL_BIN
557
+ NPM_BIN="$(command -v npm)"
558
+ BREW_BIN="${BREW_PREFIX}/bin/brew"
559
+ LAUNCHCTL_BIN="/bin/launchctl"
560
+ local SUDOERS_FILE="/etc/sudoers.d/minion-agent"
561
+
562
+ {
563
+ echo "Defaults:${TARGET_USER} passwd_tries=0"
564
+ echo ""
565
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${NPM_BIN} install -g @geekbeer/minion@latest"
566
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${NPM_BIN} install -g @geekbeer/minion@latest --registry *"
567
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${BREW_BIN} install *"
568
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${BREW_BIN} upgrade *"
569
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${LAUNCHCTL_BIN} kickstart -k *"
570
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${LAUNCHCTL_BIN} bootstrap *"
571
+ echo "${TARGET_USER} ALL=(root) NOPASSWD: ${LAUNCHCTL_BIN} bootout *"
572
+ } > "$SUDOERS_FILE"
573
+ chmod 440 "$SUDOERS_FILE"
574
+ if visudo -c -f "$SUDOERS_FILE" >/dev/null; then
575
+ echo " -> $SUDOERS_FILE created and validated"
576
+ else
577
+ echo " ERROR: sudoers file failed validation; removing"
578
+ rm -f "$SUDOERS_FILE"
579
+ exit 1
580
+ fi
581
+
582
+ # Step 9: Locate installed @geekbeer/minion server.js
583
+ echo "[9/${TOTAL_STEPS}] Locating @geekbeer/minion installation..."
584
+ local NPM_ROOT SERVER_PATH
585
+ NPM_ROOT="$(npm root -g)"
586
+ SERVER_PATH="${NPM_ROOT}/@geekbeer/minion/mac/server.js"
587
+ if [ ! -f "$SERVER_PATH" ]; then
588
+ echo " ERROR: server.js not found at $SERVER_PATH"
589
+ echo " Run: sudo npm install -g @geekbeer/minion"
590
+ exit 1
591
+ fi
592
+ echo " -> $SERVER_PATH"
593
+
594
+ local NODE_BIN VNC_PROXY_PATH
595
+ NODE_BIN="$(command -v node)"
596
+ VNC_PROXY_PATH="${NPM_ROOT}/@geekbeer/minion/mac/vnc-auth-proxy.js"
597
+ if [ ! -f "$VNC_PROXY_PATH" ]; then
598
+ echo " ERROR: vnc-auth-proxy.js not found at $VNC_PROXY_PATH"
599
+ echo " Re-install the package: sudo npm install -g @geekbeer/minion@latest"
600
+ exit 1
601
+ fi
602
+
603
+ # Step 10: Generate LaunchAgent plists
604
+ echo "[10/${TOTAL_STEPS}] Generating LaunchAgent plists..."
605
+ local LA_DIR="${TARGET_HOME}/Library/LaunchAgents"
606
+ run_as_target mkdir -p "$LA_DIR"
607
+
608
+ # 9a: tmux-init plist (one-shot at login: ensures `main` session exists)
609
+ write_plist "${LA_DIR}/${LABEL_TMUX_INIT}.plist" " <key>Label</key>
610
+ <string>${LABEL_TMUX_INIT}</string>
611
+ <key>RunAtLoad</key>
612
+ <true/>
613
+ <key>KeepAlive</key>
614
+ <false/>
615
+ <key>ProgramArguments</key>
616
+ <array>
617
+ <string>/bin/bash</string>
618
+ <string>-lc</string>
619
+ <string>tmux has-session -t main 2>/dev/null || tmux new-session -d -s main; tmux set-option -g mouse on</string>
620
+ </array>
621
+ <key>EnvironmentVariables</key>
622
+ <dict>
623
+ <key>PATH</key>
624
+ <string>${BREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
625
+ </dict>
626
+ <key>StandardOutPath</key>
627
+ <string>${TARGET_HOME}/.minion/logs/tmux-init.out.log</string>
628
+ <key>StandardErrorPath</key>
629
+ <string>${TARGET_HOME}/.minion/logs/tmux-init.err.log</string>"
630
+ echo " -> ${LABEL_TMUX_INIT}.plist"
631
+
632
+ # 9b: agent plist
633
+ write_plist "${LA_DIR}/${LABEL_AGENT}.plist" " <key>Label</key>
634
+ <string>${LABEL_AGENT}</string>
635
+ <key>RunAtLoad</key>
636
+ <true/>
637
+ <key>KeepAlive</key>
638
+ <dict>
639
+ <key>SuccessfulExit</key>
640
+ <false/>
641
+ </dict>
642
+ <key>ThrottleInterval</key>
643
+ <integer>10</integer>
644
+ <key>ProgramArguments</key>
645
+ <array>
646
+ <string>${NODE_BIN}</string>
647
+ <string>${SERVER_PATH}</string>
648
+ </array>
649
+ <key>WorkingDirectory</key>
650
+ <string>${TARGET_HOME}/.minion</string>
651
+ <key>EnvironmentVariables</key>
652
+ <dict>
653
+ <key>HOME</key>
654
+ <string>${TARGET_HOME}</string>
655
+ <key>PATH</key>
656
+ <string>${BREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${TARGET_HOME}/.local/bin:${TARGET_HOME}/.claude/bin</string>
657
+ <key>MINION_USER</key>
658
+ <string>${TARGET_USER}</string>
659
+ </dict>
660
+ <key>StandardOutPath</key>
661
+ <string>${TARGET_HOME}/.minion/logs/agent.out.log</string>
662
+ <key>StandardErrorPath</key>
663
+ <string>${TARGET_HOME}/.minion/logs/agent.err.log</string>"
664
+ echo " -> ${LABEL_AGENT}.plist"
665
+
666
+ # 9c: vnc-proxy plist (Node-based RFB auth proxy, replaces websockify as of v3.48.0).
667
+ # --openssl-legacy-provider is REQUIRED: VNC Auth (RFB security type 2) uses
668
+ # DES, which OpenSSL 3 (shipped with Node 17+) removed from the default
669
+ # provider. Without this flag the proxy fails at challenge encryption.
670
+ write_plist "${LA_DIR}/${LABEL_VNC_PROXY}.plist" " <key>Label</key>
671
+ <string>${LABEL_VNC_PROXY}</string>
672
+ <key>RunAtLoad</key>
673
+ <true/>
674
+ <key>KeepAlive</key>
675
+ <true/>
676
+ <key>ThrottleInterval</key>
677
+ <integer>5</integer>
678
+ <key>ProgramArguments</key>
679
+ <array>
680
+ <string>${NODE_BIN}</string>
681
+ <string>--openssl-legacy-provider</string>
682
+ <string>${VNC_PROXY_PATH}</string>
683
+ </array>
684
+ <key>EnvironmentVariables</key>
685
+ <dict>
686
+ <key>HOME</key>
687
+ <string>${TARGET_HOME}</string>
688
+ <key>PATH</key>
689
+ <string>${BREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin</string>
690
+ </dict>
691
+ <key>StandardOutPath</key>
692
+ <string>${TARGET_HOME}/.minion/logs/vnc-proxy.out.log</string>
693
+ <key>StandardErrorPath</key>
694
+ <string>${TARGET_HOME}/.minion/logs/vnc-proxy.err.log</string>"
695
+ echo " -> ${LABEL_VNC_PROXY}.plist"
696
+
697
+ # Migrate from old websockify plist if present (upgrade path from < 3.47.8).
698
+ # Bootout the old service and remove its plist so the user is not left with
699
+ # two services racing for port 6080.
700
+ if [ -f "${LA_DIR}/${LABEL_WEBSOCKIFY}.plist" ]; then
701
+ echo " -> migrating: removing legacy ${LABEL_WEBSOCKIFY}.plist"
702
+ bootout_launch_agent "$TARGET_USER" "$LABEL_WEBSOCKIFY" 2>/dev/null || true
703
+ rm -f "${LA_DIR}/${LABEL_WEBSOCKIFY}.plist"
704
+ fi
705
+
706
+ # Set ownership on plists
707
+ chown -R "${TARGET_USER}:staff" "${TARGET_HOME}/Library/LaunchAgents"
708
+
709
+ # Step 11: Enable native Screen Sharing + set VNC password for the proxy
710
+ echo "[11/${TOTAL_STEPS}] Enabling native macOS Screen Sharing..."
711
+ launchctl enable system/com.apple.screensharing 2>/dev/null || true
712
+ launchctl kickstart -k system/com.apple.screensharing 2>/dev/null || true
713
+ echo " -> Screen Sharing daemon enabled (port 5900)"
714
+
715
+ # Configure VNC password via Apple Remote Desktop's `kickstart` (NOT launchctl
716
+ # kickstart — same name, different binary). This enables VNC Auth security
717
+ # type 2 on screensharingd, which the bundled mac/vnc-auth-proxy.js then
718
+ # uses to authenticate. Runs as root (we are root).
719
+ local VNC_PW_FROM_ENV=""
720
+ if [ -f "${TARGET_HOME}/.minion/.env" ]; then
721
+ VNC_PW_FROM_ENV="$(awk -F'=' '/^VNC_PASSWORD=/{print $2; exit}' "${TARGET_HOME}/.minion/.env")"
722
+ fi
723
+ local ARD_KICKSTART="/System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart"
724
+ if [ -n "$VNC_PW_FROM_ENV" ] && [ -x "$ARD_KICKSTART" ]; then
725
+ # macOS Screen Sharing truncates VNC password to 8 chars; the proxy
726
+ # sends the same truncated value, so they match.
727
+ "$ARD_KICKSTART" -configure -clientopts \
728
+ -setvnclegacy -vnclegacy yes \
729
+ -setvncpw -vncpw "$VNC_PW_FROM_ENV" >/dev/null 2>&1 \
730
+ && echo " -> VNC password installed via ARD kickstart" \
731
+ || echo " WARNING: ARD kickstart failed; VNC may require manual System Settings configuration"
732
+ else
733
+ echo " WARNING: VNC_PASSWORD not found in .env or ARD kickstart unavailable; VNC may not authenticate"
734
+ fi
735
+
736
+ # Note: System Settings -> General -> Sharing -> Screen Sharing toggle still
737
+ # requires manual user action on modern macOS (Ventura+) due to TCC. The
738
+ # final summary instructs the user to verify this.
739
+
740
+ # Add target user to access_screensharing group
741
+ if dseditgroup -o read com.apple.access_screensharing >/dev/null 2>&1; then
742
+ if dseditgroup -o checkmember -m "$TARGET_USER" com.apple.access_screensharing >/dev/null 2>&1; then
743
+ echo " -> User '$TARGET_USER' is already in com.apple.access_screensharing"
744
+ else
745
+ dseditgroup -o edit -a "$TARGET_USER" -t user com.apple.access_screensharing
746
+ echo " -> Added '$TARGET_USER' to com.apple.access_screensharing"
747
+ fi
748
+ else
749
+ # Group doesn't exist (rare); fall back to dscl
750
+ dscl . append /Groups/com.apple.access_screensharing GroupMembership "$TARGET_USER" 2>/dev/null || true
751
+ echo " -> Added '$TARGET_USER' to com.apple.access_screensharing (via dscl)"
752
+ fi
753
+
754
+ # Step 12: Disable screen lock, screen saver, and display sleep.
755
+ # VNC is the primary way humans observe and intervene with the agent. Without
756
+ # these settings macOS locks the screen and blanks the display after a few
757
+ # minutes, requiring a password (which the human operator may not have) to
758
+ # resume work. The agent runs unattended on a dedicated machine, so the
759
+ # security trade-off is acceptable; physical access to the machine is the
760
+ # real boundary.
761
+ echo "[12/${TOTAL_STEPS}] Disabling screen lock and display sleep..."
762
+
763
+ # 12a: Disable "Require password after sleep or screen saver begins".
764
+ # `defaults` (vs `sysadminctl -screenLock`) avoids needing the user's
765
+ # password and writes to the same per-user preference domain.
766
+ run_as_target defaults write com.apple.screensaver askForPassword -int 0 \
767
+ && echo " -> askForPassword=0 (no password after sleep/screensaver)" \
768
+ || echo " WARNING: failed to write com.apple.screensaver askForPassword"
769
+ run_as_target defaults write com.apple.screensaver askForPasswordDelay -int 0 2>/dev/null || true
770
+
771
+ # 12b: Disable screensaver activation entirely (idleTime=0 means never).
772
+ # Must use -currentHost; the screensaver domain is host-scoped.
773
+ run_as_target defaults -currentHost write com.apple.screensaver idleTime -int 0 \
774
+ && echo " -> screensaver idleTime=0 (never activate)" \
775
+ || echo " WARNING: failed to disable screensaver"
776
+
777
+ # 12c: Disable display sleep system-wide. Requires root (we are root here).
778
+ # -a applies to all power sources (AC, battery, UPS).
779
+ if pmset -a displaysleep 0 >/dev/null 2>&1; then
780
+ echo " -> pmset displaysleep=0 (display never sleeps)"
781
+ else
782
+ echo " WARNING: pmset displaysleep=0 failed"
783
+ fi
784
+
785
+ # Step 13: Bootstrap LaunchAgents (best-effort; only works if user is logged in)
786
+ echo "[13/${TOTAL_STEPS}] Bootstrapping LaunchAgents..."
787
+ local TARGET_UID
788
+ TARGET_UID="$(resolve_uid "$TARGET_USER")"
789
+ # Detect whether the target user has an active GUI session
790
+ if launchctl print "gui/${TARGET_UID}" >/dev/null 2>&1; then
791
+ for label in "$LABEL_TMUX_INIT" "$LABEL_AGENT" "$LABEL_VNC_PROXY"; do
792
+ if bootstrap_launch_agent "$TARGET_USER" "$label"; then
793
+ echo " -> bootstrapped: $label"
794
+ else
795
+ echo " -> already bootstrapped or failed: $label (will load on next login)"
796
+ fi
797
+ done
798
+ else
799
+ echo " -> User '$TARGET_USER' is not currently logged in to a GUI session."
800
+ echo " LaunchAgents will load automatically on next login."
801
+ fi
802
+
803
+ # Step 14: Deploy bundled skills, rules
804
+ echo "[14/${TOTAL_STEPS}] Deploying bundled skills and rules..."
805
+ local BUNDLED_SKILLS_DIR="${NPM_ROOT}/@geekbeer/minion/skills"
806
+ local CLAUDE_SKILLS_DIR="${TARGET_HOME}/.claude/skills"
807
+ if [ -d "$BUNDLED_SKILLS_DIR" ]; then
808
+ run_as_target mkdir -p "$CLAUDE_SKILLS_DIR"
809
+ for skill_dir in "$BUNDLED_SKILLS_DIR"/*/; do
810
+ [ -d "$skill_dir" ] || continue
811
+ local skill_name
812
+ skill_name="$(basename "$skill_dir")"
813
+ run_as_target cp -r "$skill_dir" "${CLAUDE_SKILLS_DIR}/${skill_name}"
814
+ echo " -> Deployed skill: $skill_name"
815
+ done
816
+ else
817
+ echo " -> No bundled skills found"
818
+ fi
819
+
820
+ local BUNDLED_RULES_DIR="${NPM_ROOT}/@geekbeer/minion/rules"
821
+ local CLAUDE_RULES_DIR="${TARGET_HOME}/.claude/rules"
822
+ if [ -d "$BUNDLED_RULES_DIR" ]; then
823
+ run_as_target mkdir -p "$CLAUDE_RULES_DIR"
824
+ run_as_target cp "$BUNDLED_RULES_DIR/core.md" "$CLAUDE_RULES_DIR/core.md"
825
+ echo " -> Deployed rules: core.md"
826
+ fi
827
+
828
+ # Final summary & manual steps
829
+ echo ""
830
+ echo "========================================="
831
+ echo " Setup Complete!"
832
+ echo "========================================="
833
+ echo ""
834
+ echo "Agent user: $TARGET_USER ($TARGET_HOME)"
835
+ echo "Plists: $LA_DIR/"
836
+ echo ""
837
+ echo "Manual steps remaining:"
838
+ echo " 1. (Required) Enable Screen Sharing in System Settings:"
839
+ echo " System Settings -> General -> Sharing -> Screen Sharing -> ON"
840
+ echo " (launchctl enable is silently ignored on Ventura+ due to TCC.)"
841
+ echo " Verify with: lsof -nP -iTCP:5900 -sTCP:LISTEN"
842
+ echo ""
843
+ echo " 2. (Required for production / persistent VNC) Set auto-login for '$TARGET_USER':"
844
+ echo " System Settings -> Users & Groups -> Automatically log in as -> $TARGET_USER"
845
+ echo " (LaunchAgents only run while the user has a GUI session."
846
+ echo " Auto-login requires FileVault to be OFF.)"
847
+ echo ""
848
+ echo " 3. Switch to the agent user with a login shell so brew/npm bins are on PATH:"
849
+ echo " sudo su - $TARGET_USER"
850
+ echo ""
851
+ echo " 4. Connect to HQ (as $TARGET_USER, no sudo needed):"
852
+ echo " minion-cli-mac configure \\"
853
+ echo " --hq-url <HQ_URL> \\"
854
+ echo " --minion-id <MINION_ID> \\"
855
+ echo " --api-token <API_TOKEN>"
856
+ echo ""
857
+ echo "Health check: minion-cli-mac health"
858
+ echo "Diagnose: minion-cli-mac diagnose"
859
+ echo ""
860
+ }
861
+
862
+ # ============================================================
863
+ # configure subcommand
864
+ # ============================================================
865
+ do_configure() {
866
+ local HQ_URL=""
867
+ local MINION_ID=""
868
+ local API_TOKEN=""
869
+ local SETUP_TUNNEL=false
870
+
871
+ while [[ $# -gt 0 ]]; do
872
+ case "$1" in
873
+ --hq-url) HQ_URL="$2"; shift 2 ;;
874
+ --minion-id) MINION_ID="$2"; shift 2 ;;
875
+ --api-token) API_TOKEN="$2"; shift 2 ;;
876
+ --setup-tunnel) SETUP_TUNNEL=true; shift ;;
877
+ --non-interactive) shift ;;
878
+ *) echo "Unknown option: $1"; exit 1 ;;
879
+ esac
880
+ done
881
+
882
+ if [ -z "$HQ_URL" ] || [ -z "$MINION_ID" ] || [ -z "$API_TOKEN" ]; then
883
+ echo "ERROR: --hq-url, --minion-id, --api-token are all required"
884
+ exit 1
885
+ fi
886
+
887
+ # configure runs as the agent user (no sudo). Resolve TARGET_USER from .env if needed.
888
+ local CFG_USER CFG_HOME
889
+ CFG_USER="$(whoami)"
890
+ CFG_HOME="$HOME"
891
+
892
+ if [ ! -f "${CFG_HOME}/.minion/.env" ]; then
893
+ echo "ERROR: ${CFG_HOME}/.minion/.env not found."
894
+ echo "Run 'sudo minion-cli-mac setup --user $CFG_USER' first (from an admin account)."
895
+ exit 1
896
+ fi
897
+
898
+ local TOTAL_STEPS=4
899
+ if [ "$SETUP_TUNNEL" = true ]; then TOTAL_STEPS=5; fi
900
+
901
+ echo "========================================="
902
+ echo " @geekbeer/minion Configure (macOS)"
903
+ echo "========================================="
904
+ echo "User: $CFG_USER"
905
+ echo "HQ: $HQ_URL"
906
+ echo "Minion ID: $MINION_ID"
907
+ [ "$SETUP_TUNNEL" = true ] && echo "Tunnel: Enabled"
908
+ echo ""
909
+
910
+ # Step 1: Update .env
911
+ echo "[1/${TOTAL_STEPS}] Writing HQ credentials to .env..."
912
+ local TMP_ENV
913
+ TMP_ENV="$(mktemp)"
914
+ {
915
+ echo "# Minion Agent Configuration"
916
+ echo "# Configured by minion-cli-mac configure"
917
+ echo ""
918
+ echo "HQ_URL=${HQ_URL}"
919
+ echo "API_TOKEN=${API_TOKEN}"
920
+ echo "MINION_ID=${MINION_ID}"
921
+ if [ -f "${CFG_HOME}/.minion/.env" ]; then
922
+ while IFS='=' read -r key value; do
923
+ [[ -z "$key" || "$key" == \#* ]] && continue
924
+ case "$key" in
925
+ HQ_URL|API_TOKEN|MINION_ID) ;;
926
+ *) echo "${key}=${value}" ;;
927
+ esac
928
+ done < "${CFG_HOME}/.minion/.env"
929
+ fi
930
+ } > "$TMP_ENV"
931
+ mv "$TMP_ENV" "${CFG_HOME}/.minion/.env"
932
+ echo " -> ${CFG_HOME}/.minion/.env updated"
933
+
934
+ # Step 2: Deploy bundled skills/rules
935
+ echo "[2/${TOTAL_STEPS}] Deploying bundled assets..."
936
+ local NPM_ROOT
937
+ NPM_ROOT="$(npm root -g 2>/dev/null || echo "")"
938
+ local BUNDLED_SKILLS_DIR="${NPM_ROOT}/@geekbeer/minion/skills"
939
+ local CLAUDE_SKILLS_DIR="${CFG_HOME}/.claude/skills"
940
+ if [ -d "$BUNDLED_SKILLS_DIR" ]; then
941
+ mkdir -p "$CLAUDE_SKILLS_DIR"
942
+ for skill_dir in "$BUNDLED_SKILLS_DIR"/*/; do
943
+ [ -d "$skill_dir" ] || continue
944
+ local skill_name
945
+ skill_name="$(basename "$skill_dir")"
946
+ cp -r "$skill_dir" "${CLAUDE_SKILLS_DIR}/${skill_name}"
947
+ echo " -> Deployed skill: $skill_name"
948
+ done
949
+ fi
950
+
951
+ local BUNDLED_RULES_DIR="${NPM_ROOT}/@geekbeer/minion/rules"
952
+ local CLAUDE_RULES_DIR="${CFG_HOME}/.claude/rules"
953
+ if [ -d "$BUNDLED_RULES_DIR" ]; then
954
+ mkdir -p "$CLAUDE_RULES_DIR"
955
+ cp "$BUNDLED_RULES_DIR/core.md" "$CLAUDE_RULES_DIR/core.md"
956
+ echo " -> Deployed rules: core.md"
957
+ fi
958
+
959
+ # Step 3 (optional): Cloudflare Tunnel
960
+ local CURRENT_STEP=3
961
+ if [ "$SETUP_TUNNEL" = true ]; then
962
+ echo "[${CURRENT_STEP}/${TOTAL_STEPS}] Setting up Cloudflare Tunnel..."
963
+ if ! command -v cloudflared &>/dev/null; then
964
+ echo " ERROR: cloudflared is not installed."
965
+ echo " The minion user cannot install brew packages."
966
+ echo " Re-run setup as admin to install cloudflared:"
967
+ echo " sudo minion-cli-mac setup --user $CFG_USER"
968
+ exit 1
969
+ fi
970
+
971
+ local TUNNEL_DATA
972
+ TUNNEL_DATA="$(curl -sfL -H "Authorization: Bearer ${API_TOKEN}" "${HQ_URL}/api/minion/tunnel-credentials" 2>&1)" || true
973
+
974
+ if [ -z "$TUNNEL_DATA" ] || ! echo "$TUNNEL_DATA" | jq -e '.tunnel_id' > /dev/null 2>&1; then
975
+ echo " ERROR: Failed to fetch tunnel credentials from HQ; skipping tunnel setup"
976
+ else
977
+ local TUNNEL_ID CREDS_JSON CONFIG_YML
978
+ TUNNEL_ID="$(echo "$TUNNEL_DATA" | jq -r '.tunnel_id')"
979
+ CREDS_JSON="$(echo "$TUNNEL_DATA" | jq -r '.credentials_json')"
980
+ CONFIG_YML="$(echo "$TUNNEL_DATA" | jq -r '.config_yml')"
981
+
982
+ mkdir -p "${CFG_HOME}/.cloudflared"
983
+ echo "$CREDS_JSON" > "${CFG_HOME}/.cloudflared/${TUNNEL_ID}.json"
984
+ chmod 600 "${CFG_HOME}/.cloudflared/${TUNNEL_ID}.json"
985
+ echo "$CONFIG_YML" > "${CFG_HOME}/.cloudflared/config.yml"
986
+
987
+ local CLOUDFLARED_BIN
988
+ CLOUDFLARED_BIN="$(command -v cloudflared)"
989
+ local LA_DIR="${CFG_HOME}/Library/LaunchAgents"
990
+ mkdir -p "$LA_DIR"
991
+ cat > "${LA_DIR}/${LABEL_CLOUDFLARED}.plist" <<CFEOF
992
+ <?xml version="1.0" encoding="UTF-8"?>
993
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
994
+ <plist version="1.0">
995
+ <dict>
996
+ <key>Label</key>
997
+ <string>${LABEL_CLOUDFLARED}</string>
998
+ <key>RunAtLoad</key>
999
+ <true/>
1000
+ <key>KeepAlive</key>
1001
+ <true/>
1002
+ <key>ProgramArguments</key>
1003
+ <array>
1004
+ <string>${CLOUDFLARED_BIN}</string>
1005
+ <string>tunnel</string>
1006
+ <string>--config</string>
1007
+ <string>${CFG_HOME}/.cloudflared/config.yml</string>
1008
+ <string>run</string>
1009
+ </array>
1010
+ <key>StandardOutPath</key>
1011
+ <string>${CFG_HOME}/.minion/logs/cloudflared.out.log</string>
1012
+ <key>StandardErrorPath</key>
1013
+ <string>${CFG_HOME}/.minion/logs/cloudflared.err.log</string>
1014
+ </dict>
1015
+ </plist>
1016
+ CFEOF
1017
+ bootstrap_launch_agent "$CFG_USER" "$LABEL_CLOUDFLARED" || true
1018
+ echo " -> cloudflared LaunchAgent installed and bootstrapped"
1019
+ fi
1020
+ CURRENT_STEP=$((CURRENT_STEP + 1))
1021
+ fi
1022
+
1023
+ # Step N-1: Restart agent
1024
+ echo "[$((TOTAL_STEPS - 1))/${TOTAL_STEPS}] Restarting agent..."
1025
+ if launchctl print "$(launchd_target "$CFG_USER" "$LABEL_AGENT")" >/dev/null 2>&1; then
1026
+ kickstart_launch_agent "$CFG_USER" "$LABEL_AGENT" || true
1027
+ echo " -> minion-agent restarted (launchd)"
1028
+ else
1029
+ bootstrap_launch_agent "$CFG_USER" "$LABEL_AGENT" || true
1030
+ echo " -> minion-agent bootstrapped (launchd)"
1031
+ fi
1032
+
1033
+ # Step N: Health check
1034
+ echo "[${TOTAL_STEPS}/${TOTAL_STEPS}] Verifying agent health..."
1035
+ local HEALTH_OK=false
1036
+ for i in $(seq 1 5); do
1037
+ if curl -sf http://localhost:8080/api/health > /dev/null 2>&1; then
1038
+ HEALTH_OK=true
1039
+ break
1040
+ fi
1041
+ sleep 2
1042
+ done
1043
+ if [ "$HEALTH_OK" = true ]; then
1044
+ echo " -> Agent is healthy"
1045
+ else
1046
+ echo " WARNING: Health check failed after 5 attempts"
1047
+ echo " Logs: tail -f ${CFG_HOME}/.minion/logs/agent.err.log"
1048
+ fi
1049
+
1050
+ # Notify HQ with machine specs
1051
+ echo ""
1052
+ echo "Notifying HQ..."
1053
+ local LAN_IP HOSTNAME_VAL
1054
+ LAN_IP="$(detect_lan_ip)"
1055
+ HOSTNAME_VAL="$(hostname 2>/dev/null || echo "")"
1056
+
1057
+ local CPU_MODEL CPU_CORES MEMORY_MB DISK_GB OS_INFO ARCH_INFO
1058
+ CPU_MODEL="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)"
1059
+ CPU_CORES="$(sysctl -n hw.ncpu 2>/dev/null || echo 0)"
1060
+ local MEMORY_BYTES
1061
+ MEMORY_BYTES="$(sysctl -n hw.memsize 2>/dev/null || echo 0)"
1062
+ MEMORY_MB="$(( MEMORY_BYTES / 1024 / 1024 ))"
1063
+ DISK_GB="$(df -g / 2>/dev/null | awk 'NR==2 {print $2}')"
1064
+ OS_INFO="macOS $(sw_vers -productVersion 2>/dev/null || echo unknown)"
1065
+ ARCH_INFO="$(uname -m 2>/dev/null || echo unknown)"
1066
+
1067
+ local MACHINE_SPECS
1068
+ MACHINE_SPECS=$(cat <<SPECEOF
1069
+ {"cpu_model":"${CPU_MODEL}","cpu_cores":${CPU_CORES},"memory_mb":${MEMORY_MB},"disk_gb":${DISK_GB:-0},"os":"${OS_INFO}","arch":"${ARCH_INFO}"}
1070
+ SPECEOF
1071
+ )
1072
+
1073
+ local BODY
1074
+ if [ -n "$LAN_IP" ]; then
1075
+ BODY="{\"internal_ip_address\":\"${LAN_IP}\",\"ip_address\":\"${LAN_IP}\",\"machine_specs\":${MACHINE_SPECS}}"
1076
+ else
1077
+ BODY="{\"internal_ip_address\":\"${HOSTNAME_VAL}\",\"machine_specs\":${MACHINE_SPECS}}"
1078
+ fi
1079
+
1080
+ local NOTIFY_RESPONSE
1081
+ NOTIFY_RESPONSE="$(curl -sfL -X POST "${HQ_URL}/api/minion/setup-complete" \
1082
+ -H "Content-Type: application/json" \
1083
+ -H "Authorization: Bearer ${API_TOKEN}" \
1084
+ -d "$BODY" 2>&1)" || true
1085
+
1086
+ if echo "$NOTIFY_RESPONSE" | grep -q '"success":true' 2>/dev/null; then
1087
+ echo " -> HQ notified successfully${LAN_IP:+ (LAN IP: ${LAN_IP})}"
1088
+ else
1089
+ echo " -> Skipped (heartbeat will notify HQ within 30s)"
1090
+ fi
1091
+
1092
+ echo ""
1093
+ echo "========================================="
1094
+ echo " Configure Complete!"
1095
+ echo "========================================="
1096
+ echo ""
1097
+ echo "Useful commands:"
1098
+ echo " minion-cli-mac status # Agent status"
1099
+ echo " minion-cli-mac health # Health check"
1100
+ echo " minion-cli-mac restart # Restart agent"
1101
+ echo " tail -f ${CFG_HOME}/.minion/logs/agent.err.log # Agent logs"
1102
+ echo ""
1103
+ }
1104
+
1105
+ # ============================================================
1106
+ # uninstall subcommand
1107
+ # ============================================================
1108
+ do_uninstall() {
1109
+ require_root uninstall
1110
+
1111
+ local KEEP_DATA=false
1112
+ local CLI_USER=""
1113
+ while [[ $# -gt 0 ]]; do
1114
+ case "$1" in
1115
+ --keep-data) KEEP_DATA=true; shift ;;
1116
+ --user) CLI_USER="$2"; shift 2 ;;
1117
+ *) echo "Unknown option: $1"; exit 1 ;;
1118
+ esac
1119
+ done
1120
+
1121
+ # If --user not provided, try to read from any existing /etc/sudoers.d/minion-agent
1122
+ if [ -z "$CLI_USER" ] && [ -f /etc/sudoers.d/minion-agent ]; then
1123
+ CLI_USER="$(grep -m1 '^Defaults:' /etc/sudoers.d/minion-agent 2>/dev/null | sed 's/Defaults://; s/[[:space:]].*//')"
1124
+ fi
1125
+ if [ -z "$CLI_USER" ]; then
1126
+ echo "ERROR: cannot determine target user; pass --user <USERNAME>"
1127
+ exit 1
1128
+ fi
1129
+
1130
+ local UN_HOME
1131
+ UN_HOME="$(resolve_user_home "$CLI_USER" 2>/dev/null || echo "")"
1132
+ if [ -z "$UN_HOME" ]; then
1133
+ echo " WARNING: User '$CLI_USER' does not exist; will only clean system-level state"
1134
+ fi
1135
+
1136
+ echo "========================================="
1137
+ echo " @geekbeer/minion Uninstall (macOS)"
1138
+ echo "========================================="
1139
+ echo ""
1140
+ echo "Target user: $CLI_USER"
1141
+ echo "Target home: ${UN_HOME:-<missing>}"
1142
+ if [ "$KEEP_DATA" = true ]; then
1143
+ echo " --keep-data: ${UN_HOME}/.minion will be preserved."
1144
+ else
1145
+ echo " ${UN_HOME}/.minion will be deleted."
1146
+ fi
1147
+ echo ""
1148
+ echo -n "Type 'yes' to continue: "
1149
+ read -r CONFIRM
1150
+ if [ "$CONFIRM" != "yes" ]; then
1151
+ echo "Uninstall cancelled."
1152
+ exit 0
1153
+ fi
1154
+
1155
+ # Step 1: Bootout LaunchAgents
1156
+ echo "[1/4] Removing LaunchAgents..."
1157
+ if [ -n "$UN_HOME" ]; then
1158
+ for label in "$LABEL_AGENT" "$LABEL_TMUX_INIT" "$LABEL_VNC_PROXY" "$LABEL_WEBSOCKIFY" "$LABEL_CLOUDFLARED"; do
1159
+ bootout_launch_agent "$CLI_USER" "$label"
1160
+ local plist="${UN_HOME}/Library/LaunchAgents/${label}.plist"
1161
+ if [ -f "$plist" ]; then
1162
+ rm -f "$plist"
1163
+ echo " -> Removed $plist"
1164
+ fi
1165
+ done
1166
+ fi
1167
+
1168
+ # Step 2: Remove sudoers
1169
+ echo "[2/4] Removing sudoers configuration..."
1170
+ if [ -f /etc/sudoers.d/minion-agent ]; then
1171
+ rm -f /etc/sudoers.d/minion-agent
1172
+ echo " -> Removed /etc/sudoers.d/minion-agent"
1173
+ fi
1174
+
1175
+ # Step 3: Remove data dir & cloudflared config
1176
+ echo "[3/4] Removing user data..."
1177
+ if [ -n "$UN_HOME" ]; then
1178
+ if [ "$KEEP_DATA" = false ] && [ -d "${UN_HOME}/.minion" ]; then
1179
+ rm -rf "${UN_HOME}/.minion"
1180
+ echo " -> Removed ${UN_HOME}/.minion"
1181
+ fi
1182
+ if [ -d "${UN_HOME}/.cloudflared" ]; then
1183
+ rm -rf "${UN_HOME}/.cloudflared"
1184
+ echo " -> Removed ${UN_HOME}/.cloudflared"
1185
+ fi
1186
+ fi
1187
+
1188
+ # Step 4: Remove user from screen sharing group (do NOT delete user)
1189
+ echo "[4/4] Removing user from com.apple.access_screensharing..."
1190
+ dseditgroup -o edit -d "$CLI_USER" -t user com.apple.access_screensharing 2>/dev/null || true
1191
+ echo " -> Done"
1192
+
1193
+ echo ""
1194
+ echo "Uninstall complete."
1195
+ echo " • Native Screen Sharing service is left enabled (it is system-managed)."
1196
+ echo " • The '$CLI_USER' user account was NOT deleted; remove with:"
1197
+ echo " sudo sysadminctl -deleteUser $CLI_USER"
1198
+ echo " • Brew packages (tmux, ttyd, websockify, cloudflared, etc.) were NOT removed."
1199
+ }
1200
+
1201
+ # ============================================================
1202
+ # Main dispatch
1203
+ # ============================================================
1204
+ case "${1:-}" in
1205
+ --version|-v)
1206
+ echo "@geekbeer/minion (macOS) v${CLI_VERSION}"
1207
+ ;;
1208
+
1209
+ setup)
1210
+ shift
1211
+ do_setup "$@"
1212
+ ;;
1213
+
1214
+ configure|reconfigure)
1215
+ shift
1216
+ do_configure "$@"
1217
+ ;;
1218
+
1219
+ uninstall)
1220
+ shift
1221
+ do_uninstall "$@"
1222
+ ;;
1223
+
1224
+ status)
1225
+ curl -s "$AGENT_URL/api/status" | jq .
1226
+ ;;
1227
+
1228
+ set-status)
1229
+ STATUS="${2:-online}"
1230
+ TASK="${3:-}"
1231
+ if [ -n "$TASK" ]; then
1232
+ curl -s -X POST "$AGENT_URL/api/status" \
1233
+ -H "Content-Type: application/json" \
1234
+ -d "{\"status\": \"$STATUS\", \"current_task\": \"$TASK\"}" | jq .
1235
+ else
1236
+ curl -s -X POST "$AGENT_URL/api/status" \
1237
+ -H "Content-Type: application/json" \
1238
+ -d "{\"status\": \"$STATUS\", \"current_task\": null}" | jq .
1239
+ fi
1240
+ ;;
1241
+
1242
+ health)
1243
+ curl -s "$AGENT_URL/api/health" | jq .
1244
+ ;;
1245
+
1246
+ daemons)
1247
+ curl -s "$AGENT_URL/api/daemons/status" | jq .
1248
+ ;;
1249
+
1250
+ diagnose)
1251
+ echo "Running diagnostics..."
1252
+ echo ""
1253
+ RESULT=$(curl -s --max-time 15 "$AGENT_URL/api/diagnose" 2>/dev/null) || {
1254
+ echo "FAIL: Cannot reach minion agent at $AGENT_URL"
1255
+ echo " Is the agent running? Try: minion-cli-mac start"
1256
+ exit 1
1257
+ }
1258
+ SUMMARY=$(echo "$RESULT" | jq -r '.summary // "UNKNOWN"')
1259
+ VERSION=$(echo "$RESULT" | jq -r '.version // "?"')
1260
+ PLATFORM=$(echo "$RESULT" | jq -r '.platform // "?"')
1261
+ echo "=== Minion Diagnostics (v${VERSION}, ${PLATFORM}) ==="
1262
+ echo ""
1263
+ for CHECK in agent hq tunnel vnc terminal llm env; do
1264
+ OK=$(echo "$RESULT" | jq -r ".checks.${CHECK}.ok")
1265
+ DETAILS=$(echo "$RESULT" | jq -r ".checks.${CHECK}.details // \"\"")
1266
+ CHECK_UPPER=$(echo "$CHECK" | tr '[:lower:]' '[:upper:]')
1267
+ if [ "$OK" = "true" ]; then
1268
+ printf " \033[32m[PASS]\033[0m %-10s %s\n" "$CHECK_UPPER" "$DETAILS"
1269
+ else
1270
+ printf " \033[31m[FAIL]\033[0m %-10s %s\n" "$CHECK_UPPER" "$DETAILS"
1271
+ fi
1272
+ done
1273
+ echo ""
1274
+ if [ "$SUMMARY" = "ALL OK" ]; then
1275
+ echo -e "\033[32m$SUMMARY\033[0m"
1276
+ else
1277
+ echo -e "\033[33m$SUMMARY\033[0m"
1278
+ fi
1279
+ ;;
1280
+
1281
+ start)
1282
+ svc_control start "${TARGET_USER}"
1283
+ echo "minion-agent started (launchd, user=$TARGET_USER)"
1284
+ ;;
1285
+
1286
+ stop)
1287
+ svc_control stop "${TARGET_USER}"
1288
+ echo "minion-agent stopped (launchd, user=$TARGET_USER)"
1289
+ ;;
1290
+
1291
+ restart)
1292
+ svc_control restart "${TARGET_USER}"
1293
+ echo "minion-agent restarted (launchd, user=$TARGET_USER)"
1294
+ ;;
1295
+
1296
+ skill)
1297
+ shift
1298
+ SKILL_CMD="${1:-}"
1299
+ shift || true
1300
+ case "$SKILL_CMD" in
1301
+ push)
1302
+ SKILL_NAME="${1:-}"
1303
+ if [ -z "$SKILL_NAME" ]; then echo "Usage: minion-cli-mac skill push <skill-name>"; exit 1; fi
1304
+ curl -s -X POST "$AGENT_URL/api/skills/push/$SKILL_NAME" -H "Authorization: Bearer $API_TOKEN" | jq .
1305
+ ;;
1306
+ fetch)
1307
+ SKILL_NAME="${1:-}"
1308
+ if [ -z "$SKILL_NAME" ]; then echo "Usage: minion-cli-mac skill fetch <skill-name>"; exit 1; fi
1309
+ curl -s -X POST "$AGENT_URL/api/skills/fetch/$SKILL_NAME" -H "Authorization: Bearer $API_TOKEN" | jq .
1310
+ ;;
1311
+ list)
1312
+ FLAG="${1:-}"
1313
+ if [ "$FLAG" = "--local" ]; then
1314
+ curl -s "$AGENT_URL/api/list-skills" -H "Authorization: Bearer $API_TOKEN" | jq .
1315
+ else
1316
+ curl -s "$AGENT_URL/api/skills/remote" -H "Authorization: Bearer $API_TOKEN" | jq .
1317
+ fi
1318
+ ;;
1319
+ *)
1320
+ echo "Usage: minion-cli-mac skill <push|fetch|list>"
1321
+ ;;
1322
+ esac
1323
+ ;;
1324
+
1325
+ *)
1326
+ echo "Minion Agent CLI (@geekbeer/minion macOS) v${CLI_VERSION}"
1327
+ echo ""
1328
+ echo "Usage:"
1329
+ echo " sudo minion-cli-mac setup --user <USERNAME> # System setup (root): user, sudoers, Screen Sharing, plists"
1330
+ echo " minion-cli-mac configure [options] # Connect to HQ (run as agent user)"
1331
+ echo " sudo minion-cli-mac uninstall [options] # Remove agent and services (root)"
1332
+ echo " minion-cli-mac start # Bootstrap agent LaunchAgent"
1333
+ echo " minion-cli-mac stop # Bootout agent LaunchAgent"
1334
+ echo " minion-cli-mac restart # Kickstart agent LaunchAgent"
1335
+ echo " minion-cli-mac status # Get current status"
1336
+ echo " minion-cli-mac health # Health check"
1337
+ echo " minion-cli-mac diagnose # Run full service diagnostics"
1338
+ echo " minion-cli-mac set-status <status> [task] # Set status and optional task"
1339
+ echo " minion-cli-mac skill <push|fetch|list> # Manage skills with HQ"
1340
+ echo " minion-cli-mac --version # Show version"
1341
+ echo ""
1342
+ echo "Configure options:"
1343
+ echo " --hq-url <URL> HQ server URL (required)"
1344
+ echo " --minion-id <UUID> Minion ID (required)"
1345
+ echo " --api-token <TOKEN> API token (required)"
1346
+ echo " --setup-tunnel Set up cloudflared tunnel"
1347
+ echo ""
1348
+ echo "Status values: online, offline, busy"
1349
+ echo ""
1350
+ echo "Environment:"
1351
+ echo " MINION_AGENT_URL Agent URL (default: http://localhost:8080)"
1352
+ ;;
1353
+ esac