fleet-doctor.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. #!/usr/bin/env bash
  2. # fleet-doctor.sh - preflight + staleness verifier for the fleet-worker skill.
  3. #
  4. # Two modes (SKILL-RESOURCE-PROTOCOL sec 7):
  5. # --offline (default): structural / internal-consistency only, NO network.
  6. # Asserts the launcher parses, and that the model + endpoint
  7. # baked into the launcher are also documented in SKILL.md
  8. # (a drift tripwire - bump the launcher, forget the doc -> fail).
  9. # Safe for PR CI.
  10. # --live: pings the configured Anthropic-compatible endpoint with the
  11. # configured model. 200 = model still resolves. No key /
  12. # unreachable / rate-limited = UNAVAILABLE (advisory, never a
  13. # build failure). A 404 = DRIFT (endpoint/model path gone).
  14. #
  15. # Both modes also print non-fatal PREFLIGHT advisories: whether `claude` is on
  16. # PATH, and whether the host ~/.claude.json carries an oauthAccount (the cause of
  17. # the 401 trap that CLAUDE_CONFIG_DIR isolation fixes - see spec sec 4).
  18. #
  19. # Usage: fleet-doctor.sh [--offline|--live] [--json] [-q]
  20. # Output: stdout = data only (TSV rows, or a --json envelope)
  21. # Stderr: panel framing / human status (term.sh)
  22. # Exit: 0 ok; 2 usage; 4 launcher malformed; 5 missing dep / launcher absent;
  23. # 7 unavailable (live: no key / endpoint unreachable);
  24. # 10 drift (offline: doc/launcher mismatch | live: model 404)
  25. #
  26. # Examples:
  27. # fleet-doctor.sh --offline
  28. # FLEET_WORKER_KEYRING_SERVICE=mysvc FLEET_WORKER_KEYRING_KEY=glm fleet-doctor.sh --live
  29. # fleet-doctor.sh --offline --json | jq '.data[] | select(.status!="ok")'
  30. set -uo pipefail
  31. EXIT_OK=0; EXIT_USAGE=2; EXIT_MALFORMED=4; EXIT_MISSING_DEP=5
  32. EXIT_UNAVAILABLE=7; EXIT_DRIFT=10
  33. # Terminal design system (skills/_lib/term.sh). Framing rides stderr (term_init 2);
  34. # data rows / --json stay plain on stdout. Degrade gracefully if the lib is gone.
  35. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  36. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2; __HAVE_TERM=1
  37. else __HAVE_TERM=0; TERM_DOT="|"; fi
  38. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  39. LAUNCHER="$SCRIPT_DIR/fleet-worker"
  40. SKILL_MD="$SCRIPT_DIR/../SKILL.md"
  41. ASSET_SETTINGS="$SCRIPT_DIR/../assets/worker-settings.json"
  42. MODE="offline"; JSON=0; QUIET=0
  43. while [ $# -gt 0 ]; do
  44. case "$1" in
  45. --offline) MODE="offline" ;;
  46. --live) MODE="live" ;;
  47. --json) JSON=1 ;;
  48. -q|--quiet) QUIET=1 ;;
  49. -h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"; exit "$EXIT_OK" ;;
  50. -*) echo "fleet-doctor.sh: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
  51. *) echo "fleet-doctor.sh: unexpected argument: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
  52. esac
  53. shift
  54. done
  55. command -v grep >/dev/null 2>&1 || { echo "fleet-doctor.sh: grep required" >&2; exit "$EXIT_MISSING_DEP"; }
  56. HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
  57. [ "$JSON" -eq 1 ] && [ "$HAS_JQ" -eq 0 ] && {
  58. echo '{"error":{"code":"PRECONDITION","message":"jq required for --json"}}'
  59. echo "fleet-doctor.sh: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
  60. # Panel framing on the human stream (TTY or FORCE_COLOR); piped/quiet -> tagged lines.
  61. PANEL=0
  62. if [ "$__HAVE_TERM" -eq 1 ] && [ "$QUIET" -eq 0 ] && { [ -t 2 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi
  63. __PANEL_OPEN=0
  64. popen() {
  65. [ "$PANEL" -eq 1 ] && [ "$__PANEL_OPEN" -eq 0 ] || return 0
  66. { term_panel_open claude "fleet-worker doctor ${TERM_DOT} ${MODE}"; term_panel_vert; } >&2
  67. __PANEL_OPEN=1
  68. }
  69. emit() { [ "$QUIET" -eq 1 ] && return; printf '%s\n' "$1" >&2; }
  70. # prow <mark> <legacy-prefix> <label> - panel status row, or the legacy tagged line.
  71. prow() {
  72. if [ "$PANEL" -eq 1 ]; then popen; term_status_row "$1" "$3" >&2
  73. else emit " $2 $3"; fi
  74. }
  75. declare -a JSON_OBJS=()
  76. declare -a TEXT_ROWS=()
  77. add_row() { # check status detail
  78. TEXT_ROWS+=("$1 $2 $3")
  79. [ "$HAS_JQ" -eq 1 ] && JSON_OBJS+=("$(jq -cn --arg c "$1" --arg s "$2" --arg d "$3" \
  80. '{check:$c, status:$s, detail:$d}')")
  81. }
  82. [ "$PANEL" -eq 1 ] && popen || emit "=== fleet-doctor (${MODE}) ==="
  83. drift=0; malformed=0; missing=0; unavailable=0
  84. # -- Preflight advisories (never change exit code) --------------------------
  85. if command -v claude >/dev/null 2>&1; then
  86. prow ok "[ok]" "claude on PATH"; add_row claude-on-path ok "found"
  87. else
  88. prow warn "[advisory]" "claude (Claude Code) not on PATH - workers cannot run here"
  89. add_row claude-on-path advisory "not found"
  90. fi
  91. __hostcfg="${HOME:-}/.claude.json"
  92. if [ -f "$__hostcfg" ] && grep -q '"oauthAccount"' "$__hostcfg" 2>/dev/null; then
  93. prow warn "[advisory]" "host ~/.claude.json has oauthAccount - workers MUST use an isolated CLAUDE_CONFIG_DIR (spec sec 4)"
  94. add_row host-oauth-trap advisory "oauthAccount present"
  95. else
  96. prow ok "[ok]" "no host oauthAccount trap detected"; add_row host-oauth-trap ok "clean"
  97. fi
  98. # -- Launcher presence + syntax ---------------------------------------------
  99. if [ ! -f "$LAUNCHER" ]; then
  100. prow bad "[MISSING]" "launcher not found: $LAUNCHER"; add_row launcher-present missing "$LAUNCHER"
  101. missing=1
  102. elif ! bash -n "$LAUNCHER" 2>/dev/null; then
  103. prow bad "[MALFORMED]" "launcher has a bash syntax error"; add_row launcher-syntax malformed "bash -n failed"
  104. malformed=1
  105. else
  106. prow ok "[ok]" "launcher present and parses"; add_row launcher-syntax ok "bash -n clean"
  107. fi
  108. # -- Asset settings is valid JSON -------------------------------------------
  109. if [ -f "$ASSET_SETTINGS" ]; then
  110. if [ "$HAS_JQ" -eq 1 ] && ! jq -e . "$ASSET_SETTINGS" >/dev/null 2>&1; then
  111. prow bad "[MALFORMED]" "worker-settings.json is not valid JSON"; add_row asset-settings malformed "invalid"
  112. malformed=1
  113. else
  114. prow ok "[ok]" "worker-settings.json present"; add_row asset-settings ok "valid"
  115. fi
  116. fi
  117. # -- Extract launcher defaults (model / small model / endpoint) -------------
  118. def_model=""; def_small=""; def_url=""
  119. if [ -f "$LAUNCHER" ]; then
  120. def_model="$(grep -oE 'FLEET_WORKER_MODEL:-[A-Za-z0-9._-]+' "$LAUNCHER" | head -1 | sed 's/^FLEET_WORKER_MODEL:-//')"
  121. def_small="$(grep -oE 'FLEET_WORKER_SMALL_MODEL:-[A-Za-z0-9._-]+' "$LAUNCHER" | head -1 | sed 's/^FLEET_WORKER_SMALL_MODEL:-//')"
  122. def_url="$(grep -oE 'FLEET_WORKER_BASE_URL:-[^}\"]+' "$LAUNCHER" | head -1 | sed 's/^FLEET_WORKER_BASE_URL:-//')"
  123. fi
  124. # -- Offline: launcher defaults must be documented in SKILL.md (drift tripwire)
  125. if [ "$MODE" = "offline" ]; then
  126. if [ -f "$SKILL_MD" ]; then
  127. for pair in "model:$def_model" "small-model:$def_small" "endpoint:$def_url"; do
  128. label="${pair%%:*}"; val="${pair#*:}"
  129. [ -z "$val" ] && continue
  130. if grep -qF -- "$val" "$SKILL_MD"; then
  131. prow ok "[ok]" "SKILL.md documents $label ($val)"; add_row "doc-$label" ok "$val"
  132. else
  133. prow bad "[DRIFT]" "launcher $label '$val' is NOT documented in SKILL.md"
  134. add_row "doc-$label" drift "$val"; drift=1
  135. fi
  136. done
  137. else
  138. prow warn "[advisory]" "SKILL.md not found beside scripts/ - skipping doc-consistency"
  139. add_row skill-md advisory "not found"
  140. fi
  141. fi
  142. # -- Live: does the configured model still resolve at the endpoint? ---------
  143. if [ "$MODE" = "live" ]; then
  144. command -v curl >/dev/null 2>&1 || { echo "fleet-doctor.sh: curl required for --live" >&2; exit "$EXIT_MISSING_DEP"; }
  145. url="${FLEET_WORKER_BASE_URL:-${def_url:-https://api.z.ai/api/anthropic}}"
  146. model="${FLEET_WORKER_MODEL:-${def_model:-GLM-5.2}}"
  147. # Resolve key without echoing it.
  148. key=""
  149. if [ -n "${ANTHROPIC_AUTH_TOKEN:-}" ]; then key="$ANTHROPIC_AUTH_TOKEN"
  150. elif [ -n "${FLEET_WORKER_KEYRING_SERVICE:-}" ] && [ -n "${FLEET_WORKER_KEYRING_KEY:-}" ] && command -v keyring >/dev/null 2>&1; then
  151. key="$(keyring get "$FLEET_WORKER_KEYRING_SERVICE" "$FLEET_WORKER_KEYRING_KEY" 2>/dev/null | tr -d '\r\n')"
  152. elif [ -n "${ZHIPU_API_KEY:-}" ]; then key="$ZHIPU_API_KEY"
  153. elif [ -n "${GLM_API_KEY:-}" ]; then key="$GLM_API_KEY"
  154. fi
  155. if [ -z "$key" ]; then
  156. prow warn "[unavailable]" "no API key resolved - cannot run --live (advisory)"
  157. add_row live-ping unavailable "no key"; unavailable=1
  158. else
  159. body="$(printf '{"model":"%s","max_tokens":1,"messages":[{"role":"user","content":"ping"}]}' "$model")"
  160. code="$(curl -sS -o /dev/null -m 25 -w '%{http_code}' -X POST "${url%/}/v1/messages" \
  161. -H "content-type: application/json" -H "anthropic-version: 2023-06-01" \
  162. -H "x-api-key: ${key}" --data "$body" 2>/dev/null || echo 000)"
  163. case "$code" in
  164. 200)
  165. prow ok "[ok]" "model $model resolves at ${url} (HTTP 200)"; add_row live-ping ok "$model" ;;
  166. 404)
  167. prow bad "[DRIFT 404]" "model/endpoint not found: $model @ ${url}"; add_row live-ping drift "$model"; drift=1 ;;
  168. 000|401|403|408|429|5??)
  169. prow warn "[unavailable]" "endpoint unreachable / not authorized / overloaded (HTTP $code)"
  170. add_row live-ping unavailable "HTTP $code"; unavailable=1 ;;
  171. *)
  172. prow warn "[unavailable]" "unexpected response (HTTP $code) - treating as advisory"
  173. add_row live-ping unavailable "HTTP $code"; unavailable=1 ;;
  174. esac
  175. fi
  176. fi
  177. # -- Panel footer -----------------------------------------------------------
  178. if [ "$PANEL" -eq 1 ] && [ "$__PANEL_OPEN" -eq 1 ]; then
  179. ph_state="healthy"; ph_text="all checks pass"
  180. if [ "$missing" -eq 1 ] || [ "$malformed" -eq 1 ]; then ph_state="critical"; ph_text="skill broken"
  181. elif [ "$drift" -eq 1 ]; then ph_state="critical"; ph_text="drift detected"
  182. elif [ "$unavailable" -eq 1 ]; then ph_state="warning"; ph_text="endpoint advisory"; fi
  183. { term_panel_vert
  184. term_panel_close "--live to ping endpoint ${TERM_DOT} --json for data" "$(term_health "$ph_state" "$ph_text")"
  185. } >&2
  186. fi
  187. # -- Output (data on stdout) ------------------------------------------------
  188. if [ "$JSON" -eq 1 ]; then
  189. printf '%s\n' "${JSON_OBJS[@]:-}" | jq -s \
  190. --arg mode "$MODE" \
  191. '{data: map(select(length>0)),
  192. meta: {mode:$mode, count:(map(select(length>0))|length),
  193. schema:"claude-mods.fleet-worker.doctor/v1"}}'
  194. else
  195. for row in "${TEXT_ROWS[@]:-}"; do [ -n "$row" ] && printf '%s\n' "$row"; done
  196. fi
  197. # -- Exit (precedence: broken > drift > unavailable) ------------------------
  198. [ "$missing" -eq 1 ] && exit "$EXIT_MISSING_DEP"
  199. [ "$malformed" -eq 1 ] && exit "$EXIT_MALFORMED"
  200. [ "$drift" -eq 1 ] && exit "$EXIT_DRIFT"
  201. [ "$unavailable" -eq 1 ] && exit "$EXIT_UNAVAILABLE"
  202. exit "$EXIT_OK"