|
| 1 | +#!/usr/bin/env bash |
| 2 | +# ops-mac — Unified macOS diagnose-and-fix dispatcher for the claude-ops plugin. |
| 3 | +# |
| 4 | +# Wraps the `macos-toolkit` CLI suite (machealth, netwhiz, pstop, macdog, |
| 5 | +# lanchr, macbroom, macctl, macfig, updater) behind ONE entrypoint, plus a |
| 6 | +# self-installer so the suite is provisioned on first use. The companion |
| 7 | +# skill (skills/ops-mac/SKILL.md) drives the UX, confirmations (Rule 5), and |
| 8 | +# mobile formatting (Rule 7); this script is the single source of truth for |
| 9 | +# probes, the aggregate audit, and the install path. |
| 10 | +# |
| 11 | +# Design goals: |
| 12 | +# - macOS only. Hard-guards on non-Darwin and exits cleanly. |
| 13 | +# - Idempotent install. `ensure` is safe to call repeatedly; it only does |
| 14 | +# work for missing pieces (plugin marketplace, plugin, brew tap, tools). |
| 15 | +# - Headless-safe. The toolkit's pretty TUI tables render EMPTY when stdout |
| 16 | +# is not a TTY; this script forces `--json` for machine consumers and |
| 17 | +# normalizes output so an agent always gets data. |
| 18 | +# - Hang-guarded. `machealth check`/`diagnose` can block forever on a stuck |
| 19 | +# Time Machine / iCloud probe; every machealth call is wrapped in a hard |
| 20 | +# `timeout` so the dispatcher never wedges. |
| 21 | +# - Read-only by default. Only `clean`/`fix`/`install`/`update` mutate state; |
| 22 | +# everything else is pure diagnostics. |
| 23 | +# - No secrets. Emits only health/diagnostic data, never credentials. |
| 24 | +# |
| 25 | +# Usage: |
| 26 | +# ops-mac # banner + tool inventory |
| 27 | +# ops-mac ensure # install macos-toolkit suite if missing |
| 28 | +# ops-mac audit [--json] # aggregate read-only baseline (all probes) |
| 29 | +# ops-mac health [--json] # machealth check (timeout-guarded) |
| 30 | +# ops-mac net [wifi|--json] # netwhiz network diagnostics |
| 31 | +# ops-mac disk [--json] # macbroom cache scan (safe categories) |
| 32 | +# ops-mac procs [--json] # pstop top processes |
| 33 | +# ops-mac security [--json] # macdog security/privacy audit |
| 34 | +# ops-mac launchd [--json] # lanchr doctor (launch agent health) |
| 35 | +# ops-mac power # macctl power/display/audio status |
| 36 | +# ops-mac defaults [args] # macfig hidden-preferences passthrough |
| 37 | +# ops-mac update # updater app-update check |
| 38 | +# ops-mac run <tool> [args...] # raw passthrough to any toolkit binary |
| 39 | + |
| 40 | +set -uo pipefail |
| 41 | + |
| 42 | +# ─── OS guard ──────────────────────────────────────────────────────────────── |
| 43 | +if [[ "$(uname -s)" != "Darwin" ]]; then |
| 44 | + echo "ops-mac: macOS only (detected $(uname -s)). For Linux/WSL use /ops:speedup." >&2 |
| 45 | + exit 3 |
| 46 | +fi |
| 47 | + |
| 48 | +# Machealth hang ceiling (seconds). Its parallel composite can block on a |
| 49 | +# stuck Time Machine / iCloud probe — never let that wedge the dispatcher. |
| 50 | +MACHEALTH_TIMEOUT="${OPS_MAC_MACHEALTH_TIMEOUT:-25}" |
| 51 | + |
| 52 | +TOOLS=(machealth netwhiz pstop macdog lanchr macbroom macctl macfig updater termail) |
| 53 | +WANT_JSON=0 |
| 54 | +[[ " $* " == *" --json "* ]] && WANT_JSON=1 |
| 55 | + |
| 56 | +# Resolve a `timeout` implementation (coreutils gtimeout on brew, or none). |
| 57 | +_timeout() { |
| 58 | + local secs="$1"; shift |
| 59 | + if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" |
| 60 | + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" |
| 61 | + else "$@"; fi |
| 62 | +} |
| 63 | + |
| 64 | +_have() { command -v "$1" >/dev/null 2>&1; } |
| 65 | + |
| 66 | +# Resolve the real claude binary (the interactive shell wraps it in a |
| 67 | +# _claude_dispatch function that isn't present in non-login shells). |
| 68 | +_claude_bin() { |
| 69 | + if [[ -x "$HOME/.local/bin/claude" ]]; then echo "$HOME/.local/bin/claude" |
| 70 | + elif command -v claude >/dev/null 2>&1; then command -v claude |
| 71 | + else echo ""; fi |
| 72 | +} |
| 73 | + |
| 74 | +# ─── inventory ─────────────────────────────────────────────────────────────── |
| 75 | +missing_tools() { |
| 76 | + local m=() |
| 77 | + for t in machealth netwhiz pstop macdog lanchr macbroom macctl macfig updater; do |
| 78 | + _have "$t" || m+=("$t") |
| 79 | + done |
| 80 | + printf '%s\n' "${m[@]}" |
| 81 | +} |
| 82 | + |
| 83 | +inventory() { |
| 84 | + if [[ "$WANT_JSON" == 1 ]]; then |
| 85 | + printf '{"installed":{' |
| 86 | + local first=1 |
| 87 | + for t in "${TOOLS[@]}"; do |
| 88 | + [[ $first == 1 ]] || printf ',' |
| 89 | + first=0 |
| 90 | + if _have "$t"; then printf '"%s":true' "$t"; else printf '"%s":false' "$t"; fi |
| 91 | + done |
| 92 | + printf '}}\n' |
| 93 | + return |
| 94 | + fi |
| 95 | + echo "OPS ► MAC — macos-toolkit dispatcher" |
| 96 | + echo "host: $(sw_vers -productName 2>/dev/null) $(sw_vers -productVersion 2>/dev/null) ($(uname -m))" |
| 97 | + echo "tools:" |
| 98 | + for t in "${TOOLS[@]}"; do |
| 99 | + if _have "$t"; then echo " ✓ $t"; else echo " ✗ $t (not installed — run: ops-mac ensure)"; fi |
| 100 | + done |
| 101 | +} |
| 102 | + |
| 103 | +# ─── self-installer ────────────────────────────────────────────────────────── |
| 104 | +ensure() { |
| 105 | + local cb; cb="$(_claude_bin)" |
| 106 | + echo "▸ ensuring macos-toolkit plugin + CLIs…" |
| 107 | + |
| 108 | + # 1. Plugin marketplace + plugin (best-effort; needs the claude CLI). |
| 109 | + if [[ -n "$cb" ]]; then |
| 110 | + if ! "$cb" plugin marketplace list 2>/dev/null | grep -qi "macos-toolkit"; then |
| 111 | + echo " + adding marketplace lu-zhengda/macos-toolkit" |
| 112 | + "$cb" plugin marketplace add https://github.com/lu-zhengda/macos-toolkit 2>&1 | tail -1 |
| 113 | + fi |
| 114 | + if ! "$cb" plugin list 2>/dev/null | grep -qi "macos-toolkit"; then |
| 115 | + echo " + installing plugin macos-toolkit@macos-toolkit" |
| 116 | + "$cb" plugin install macos-toolkit@macos-toolkit 2>&1 | tail -1 |
| 117 | + fi |
| 118 | + else |
| 119 | + echo " ! claude CLI not found — skipping plugin registration (CLIs still installable)" |
| 120 | + fi |
| 121 | + |
| 122 | + # 2. Homebrew tap + tools. |
| 123 | + if ! _have brew; then |
| 124 | + echo " ! Homebrew not found. Install from https://brew.sh then re-run: ops-mac ensure" >&2 |
| 125 | + return 4 |
| 126 | + fi |
| 127 | + if ! brew tap 2>/dev/null | grep -qi "lu-zhengda/tap"; then |
| 128 | + echo " + tapping lu-zhengda/tap" |
| 129 | + brew tap lu-zhengda/tap 2>&1 | tail -1 |
| 130 | + fi |
| 131 | + # Casks from this tap are untrusted until explicitly trusted. |
| 132 | + brew trust lu-zhengda/tap >/dev/null 2>&1 || true |
| 133 | + |
| 134 | + local need; need="$(missing_tools)" |
| 135 | + if [[ -z "$need" ]]; then |
| 136 | + echo " ✓ all CLIs already installed" |
| 137 | + else |
| 138 | + echo " + installing:" $need |
| 139 | + # shellcheck disable=SC2046 |
| 140 | + brew install $(for t in $need; do echo "lu-zhengda/tap/$t"; done) 2>&1 | grep -E "successfully installed|Error|Warning" | tail -20 |
| 141 | + fi |
| 142 | + echo "✔ ensure complete" |
| 143 | +} |
| 144 | + |
| 145 | +require_tool() { |
| 146 | + if ! _have "$1"; then |
| 147 | + echo "ops-mac: '$1' not installed. Run: ops-mac ensure" >&2 |
| 148 | + exit 5 |
| 149 | + fi |
| 150 | +} |
| 151 | + |
| 152 | +# ─── individual probes ─────────────────────────────────────────────────────── |
| 153 | +p_health() { |
| 154 | + require_tool machealth |
| 155 | + if [[ "$WANT_JSON" == 1 ]]; then |
| 156 | + _timeout "$MACHEALTH_TIMEOUT" machealth check 2>/dev/null \ |
| 157 | + || echo '{"error":"machealth timed out (likely a stuck Time Machine/iCloud probe)","probe":"machealth check","timeout_s":'"$MACHEALTH_TIMEOUT"'}' |
| 158 | + else |
| 159 | + _timeout "$MACHEALTH_TIMEOUT" machealth check --human 2>&1 \ |
| 160 | + || echo "machealth: timed out after ${MACHEALTH_TIMEOUT}s — likely a stuck Time Machine / iCloud probe. Try the other probes (security/launchd/disk/net) which are reliable." |
| 161 | + fi |
| 162 | +} |
| 163 | + |
| 164 | +p_net() { |
| 165 | + require_tool netwhiz |
| 166 | + case "${1:-info}" in |
| 167 | + wifi) netwhiz wifi 2>&1 ;; |
| 168 | + --json) netwhiz info 2>&1 ;; # netwhiz info is already structured-ish |
| 169 | + *) netwhiz info 2>&1 ;; |
| 170 | + esac |
| 171 | +} |
| 172 | + |
| 173 | +p_disk() { |
| 174 | + require_tool macbroom |
| 175 | + # Human table renders empty when headless → always go through --json there. |
| 176 | + if [[ "$WANT_JSON" == 1 || ! -t 1 ]]; then |
| 177 | + macbroom scan --caches --json 2>/dev/null || echo '{"error":"macbroom scan failed"}' |
| 178 | + else |
| 179 | + macbroom scan --caches 2>&1 |
| 180 | + fi |
| 181 | +} |
| 182 | + |
| 183 | +p_procs() { |
| 184 | + require_tool pstop |
| 185 | + if [[ "$WANT_JSON" == 1 ]]; then pstop top --n 10 --json 2>/dev/null || pstop top --n 10 2>&1 |
| 186 | + else pstop top --n 10 2>&1; fi |
| 187 | +} |
| 188 | + |
| 189 | +p_security() { |
| 190 | + require_tool macdog |
| 191 | + if [[ "$WANT_JSON" == 1 ]]; then macdog audit --json 2>/dev/null || macdog audit 2>&1 |
| 192 | + else macdog audit 2>&1; fi |
| 193 | +} |
| 194 | + |
| 195 | +p_launchd() { |
| 196 | + require_tool lanchr |
| 197 | + if [[ "$WANT_JSON" == 1 ]]; then lanchr doctor --json 2>/dev/null || lanchr doctor 2>&1 |
| 198 | + else lanchr doctor 2>&1; fi |
| 199 | +} |
| 200 | + |
| 201 | +p_power() { require_tool macctl; macctl power status 2>&1; } |
| 202 | +p_defaults() { require_tool macfig; shift || true; macfig "$@" 2>&1; } |
| 203 | +p_update() { require_tool updater; updater check 2>&1; } |
| 204 | + |
| 205 | +# ─── aggregate audit ───────────────────────────────────────────────────────── |
| 206 | +audit() { |
| 207 | + if [[ "$WANT_JSON" == 1 ]]; then |
| 208 | + # Compose one JSON object from the reliable probes. machealth is |
| 209 | + # timeout-guarded and may degrade to an error stub. |
| 210 | + local health sec disk |
| 211 | + health="$(WANT_JSON=1 p_health)" |
| 212 | + sec="$(macdog audit --json 2>/dev/null || echo 'null')" |
| 213 | + disk="$(macbroom scan --caches --json 2>/dev/null || echo 'null')" |
| 214 | + printf '{"generated_at":"%s","host":"%s %s","arch":"%s","health":%s,"security":%s,"disk":%s}\n' \ |
| 215 | + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| 216 | + "$(sw_vers -productName 2>/dev/null)" "$(sw_vers -productVersion 2>/dev/null)" \ |
| 217 | + "$(uname -m)" "${health:-null}" "${sec:-null}" "${disk:-null}" |
| 218 | + return |
| 219 | + fi |
| 220 | + |
| 221 | + echo "════════════════════════════════════════════" |
| 222 | + echo " OPS ► MAC AUDIT — $(sw_vers -productVersion 2>/dev/null) ($(uname -m)) — $(date -u +%H:%MZ)" |
| 223 | + echo "════════════════════════════════════════════" |
| 224 | + echo |
| 225 | + echo "── SECURITY (macdog) ──"; p_security; echo |
| 226 | + echo "── LAUNCH AGENTS (lanchr doctor) ──"; p_launchd 2>&1 | head -30; echo |
| 227 | + echo "── TOP PROCESSES (pstop) ──"; p_procs 2>&1 | head -8; echo |
| 228 | + echo "── NETWORK (netwhiz) ──"; p_net info 2>&1 | head -15; echo |
| 229 | + echo "── DISK RECLAIMABLE (macbroom caches) ──" |
| 230 | + if _have jq; then |
| 231 | + macbroom scan --caches --json 2>/dev/null \ |
| 232 | + | jq -r '.categories[]? | " \(.name): \((.size/1e9*100|round)/100) GB (\(.items) items, \(.risk))"' 2>/dev/null \ |
| 233 | + || echo " (run: ops-mac disk)" |
| 234 | + else |
| 235 | + echo " (install jq for sizes, or run: ops-mac disk)" |
| 236 | + fi |
| 237 | + echo |
| 238 | + echo "── SYSTEM HEALTH (machealth, ${MACHEALTH_TIMEOUT}s cap) ──"; p_health |
| 239 | + echo |
| 240 | + echo "Tip: this is read-only. Remediate with /ops:mac fix (firewall, stale daemons, cache clean)." |
| 241 | +} |
| 242 | + |
| 243 | +# ─── dispatch ──────────────────────────────────────────────────────────────── |
| 244 | +cmd="${1:-}"; [[ $# -gt 0 ]] && shift || true |
| 245 | +case "$cmd" in |
| 246 | + ""|inventory|status) inventory ;; |
| 247 | + ensure|install) ensure ;; |
| 248 | + audit) audit ;; |
| 249 | + health) p_health ;; |
| 250 | + net|network) p_net "${1:-info}" ;; |
| 251 | + disk) p_disk ;; |
| 252 | + procs|processes) p_procs ;; |
| 253 | + security|sec) p_security ;; |
| 254 | + launchd|launch) p_launchd ;; |
| 255 | + power) p_power ;; |
| 256 | + defaults) p_defaults "$@" ;; |
| 257 | + update|updates) p_update ;; |
| 258 | + run) t="${1:-}"; shift || true; require_tool "$t"; "$t" "$@" ;; |
| 259 | + -h|--help|help) |
| 260 | + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' |
| 261 | + ;; |
| 262 | + *) |
| 263 | + echo "ops-mac: unknown command '$cmd'. Try: ensure | audit | health | net | disk | procs | security | launchd | power | update | run <tool>" >&2 |
| 264 | + exit 2 |
| 265 | + ;; |
| 266 | +esac |
0 commit comments