Skip to content

Commit 5b43401

Browse files
committed
feat(health): add check_hud_installation diagnostic (#1490)
Adds check #11 to HealthChecker so the v5.6.0/v5.6.1 failure mode is detectable from the user's existing health check workflow without having to read installer source. What it verifies: 1. ~/.claude/hud/codingbuddy-hud.py is present 2. ~/.claude/hud/lib/ exists and contains the seven critical modules: hud_buddy, hud_state, hud_helpers, tiny_actor_presets, hud_version, hud_rate_limits, hud_layout 3. Subprocess render smoke test catches the case where every file looks present but imports still fail at runtime (permission issues, partial copy, .pyc clash). The smoke test fails if the script returns the bare '◕‿◕ CodingBuddy' fallback. 4. ~/.claude/hud/.version stamp is reported in the PASS message so users can confirm which plugin version installed the assets. Coverage (test_health_check.py, +5 cases): - test_fail_when_script_missing - test_fail_when_lib_directory_missing - test_fail_when_required_modules_missing — names the missing module - test_fail_when_smoke_test_returns_fallback — uses a stub script that always prints the fallback face - test_pass_with_real_plugin_install — end-to-end: runs the real _install_statusline against the real codingbuddy-hud.py source in tmpdir, then asserts check_hud_installation returns PASS run_all now returns 11 results (was 10); test renamed accordingly. Refs #1490
1 parent 60f7c7f commit 5b43401

2 files changed

Lines changed: 214 additions & 3 deletions

File tree

packages/claude-code-plugin/hooks/lib/health_check.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,113 @@ def check_standalone_readiness(self) -> Dict[str, str]:
246246
)
247247
return _result("standalone_readiness", "WARN", f"Standalone not ready: {', '.join(issues)}")
248248

249+
# ------------------------------------------------------------------
250+
# Check 11: HUD asset installation (#1490 prevention)
251+
# ------------------------------------------------------------------
252+
def check_hud_installation(self) -> Dict[str, str]:
253+
"""Verify HUD asset installation integrity.
254+
255+
Detects the v5.6.0/v5.6.1 failure mode where ``~/.claude/hud/lib``
256+
is missing or stale and the statusLine renders only the
257+
fallback ``◕‿◕ CodingBuddy`` face.
258+
259+
Performs three layers of verification:
260+
1. Script presence at ``~/.claude/hud/codingbuddy-hud.py``
261+
2. Lib directory presence + the seven critical modules:
262+
``hud_buddy``, ``hud_state``, ``hud_helpers``,
263+
``tiny_actor_presets``, ``hud_version``, ``hud_rate_limits``,
264+
``hud_layout``
265+
3. A subprocess render smoke test that catches the case where
266+
everything looks present but imports still fail at runtime
267+
(e.g. permission issues, partial copy)
268+
"""
269+
import subprocess
270+
271+
hud_dir = os.path.join(self._claude_dir, "hud")
272+
script = os.path.join(hud_dir, "codingbuddy-hud.py")
273+
lib = os.path.join(hud_dir, "lib")
274+
stamp = os.path.join(hud_dir, ".version")
275+
276+
if not os.path.isfile(script):
277+
return _result(
278+
"hud_installation",
279+
"FAIL",
280+
"HUD script missing at ~/.claude/hud/codingbuddy-hud.py",
281+
)
282+
283+
if not os.path.isdir(lib):
284+
return _result(
285+
"hud_installation",
286+
"FAIL",
287+
"HUD lib/ directory missing — statusLine renders fallback only",
288+
)
289+
290+
required_modules = [
291+
"hud_buddy.py",
292+
"hud_state.py",
293+
"hud_helpers.py",
294+
"tiny_actor_presets.py",
295+
"hud_version.py",
296+
"hud_rate_limits.py",
297+
"hud_layout.py",
298+
]
299+
missing = [
300+
m for m in required_modules if not os.path.isfile(os.path.join(lib, m))
301+
]
302+
if missing:
303+
return _result(
304+
"hud_installation",
305+
"FAIL",
306+
f"HUD lib missing modules: {', '.join(missing)}",
307+
)
308+
309+
# Subprocess render smoke — catches runtime import failures.
310+
try:
311+
r = subprocess.run(
312+
["python3", script],
313+
input='{"session_id":"healthcheck","model":{"display_name":"Test"}}',
314+
capture_output=True,
315+
text=True,
316+
timeout=5,
317+
)
318+
if r.stdout.strip() == "◕‿◕ CodingBuddy":
319+
return _result(
320+
"hud_installation",
321+
"FAIL",
322+
"HUD smoke test produced fallback face — lib import failing at runtime",
323+
)
324+
except subprocess.TimeoutExpired:
325+
return _result(
326+
"hud_installation",
327+
"WARN",
328+
"HUD smoke test timed out (5s)",
329+
)
330+
except Exception as e:
331+
return _result(
332+
"hud_installation",
333+
"WARN",
334+
f"HUD smoke test crashed: {e}",
335+
)
336+
337+
version_msg = ""
338+
if os.path.isfile(stamp):
339+
try:
340+
with open(stamp, "r", encoding="utf-8") as f:
341+
version_msg = f" (v{f.read().strip()})"
342+
except OSError:
343+
pass
344+
345+
return _result(
346+
"hud_installation",
347+
"PASS",
348+
f"HUD assets installed and rendering full status line{version_msg}",
349+
)
350+
249351
# ------------------------------------------------------------------
250352
# Aggregate
251353
# ------------------------------------------------------------------
252354
def run_all(self) -> List[Dict[str, str]]:
253-
"""Run all 10 diagnostic checks and return results."""
355+
"""Run all 11 diagnostic checks and return results."""
254356
return [
255357
self.check_hooks_json(),
256358
self.check_hook_files(),
@@ -262,6 +364,7 @@ def run_all(self) -> List[Dict[str, str]]:
262364
self.check_mcp_connection(),
263365
self.check_runtime_mode(),
264366
self.check_standalone_readiness(),
367+
self.check_hud_installation(),
265368
]
266369

267370
@staticmethod

packages/claude-code-plugin/tests/test_health_check.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,10 @@ def test_missing_ai_rules_passes_with_fallback(self, env):
310310
class TestRunAll:
311311
"""run_all() returns all 10 check results."""
312312

313-
def test_returns_10_results(self, env):
313+
def test_returns_11_results(self, env):
314314
checker = _make_checker(env)
315315
results = checker.run_all()
316-
assert len(results) == 10
316+
assert len(results) == 11 # bumped from 10 in v5.6.2 (#1490)
317317

318318
def test_each_check_has_required_keys(self, env):
319319
checker = _make_checker(env)
@@ -342,3 +342,111 @@ def test_format_report_shows_failures(self, env):
342342
results = checker.run_all()
343343
report = checker.format_report(results)
344344
assert "FAIL" in report
345+
346+
347+
# ============================================================================
348+
# v5.6.2 (#1490) — check_hud_installation diagnostic
349+
# ============================================================================
350+
351+
352+
HUD_REQUIRED_LIB_MODULES = [
353+
"hud_buddy.py",
354+
"hud_state.py",
355+
"hud_helpers.py",
356+
"tiny_actor_presets.py",
357+
"hud_version.py",
358+
"hud_rate_limits.py",
359+
"hud_layout.py",
360+
]
361+
362+
363+
def _populate_minimal_hud(home: "os.PathLike", lib_modules=None) -> None:
364+
"""Populate ~/.claude/hud with a stub script and (optionally) lib stubs.
365+
366+
The stub script always prints the fallback face so the smoke test
367+
has predictable output. Pass lib_modules=None to omit the lib dir.
368+
"""
369+
from pathlib import Path
370+
h = Path(home) / ".claude" / "hud"
371+
h.mkdir(parents=True, exist_ok=True)
372+
(h / "codingbuddy-hud.py").write_text(
373+
"#!/usr/bin/env python3\nprint('\u25d5\u203f\u25d5 CodingBuddy')\n"
374+
)
375+
os.chmod(str(h / "codingbuddy-hud.py"), 0o755)
376+
if lib_modules is not None:
377+
lib = h / "lib"
378+
lib.mkdir(exist_ok=True)
379+
for name in lib_modules:
380+
(lib / name).write_text(f"# {name} stub")
381+
382+
383+
class TestCheckHudInstallation:
384+
"""Check 11: HUD asset installation integrity (#1490)."""
385+
386+
def test_fail_when_script_missing(self, env):
387+
checker = _make_checker(env)
388+
# env fixture creates ~/.claude but no hud/ dir
389+
result = checker.check_hud_installation()
390+
assert result["status"] == "FAIL"
391+
assert "script missing" in result["message"].lower()
392+
393+
def test_fail_when_lib_directory_missing(self, env):
394+
_populate_minimal_hud(env, lib_modules=None) # script only
395+
checker = _make_checker(env)
396+
result = checker.check_hud_installation()
397+
assert result["status"] == "FAIL"
398+
assert "lib/" in result["message"] or "lib" in result["message"].lower()
399+
400+
def test_fail_when_required_modules_missing(self, env):
401+
# Create lib but only with a subset of modules
402+
_populate_minimal_hud(env, lib_modules=["hud_buddy.py", "hud_state.py"])
403+
checker = _make_checker(env)
404+
result = checker.check_hud_installation()
405+
assert result["status"] == "FAIL"
406+
assert "missing modules" in result["message"]
407+
# The specific missing names must be reported
408+
assert "tiny_actor_presets.py" in result["message"]
409+
410+
def test_fail_when_smoke_test_returns_fallback(self, env):
411+
# Populate everything but stub script always prints fallback
412+
_populate_minimal_hud(env, lib_modules=HUD_REQUIRED_LIB_MODULES)
413+
checker = _make_checker(env)
414+
result = checker.check_hud_installation()
415+
assert result["status"] == "FAIL"
416+
assert "fallback" in result["message"].lower()
417+
418+
def test_pass_with_real_plugin_install(self, env, monkeypatch):
419+
"""End-to-end: install real HUD via _install_statusline, then check.
420+
421+
This is the green-path regression gate — if check_hud_installation
422+
ever stops returning PASS for a freshly-installed real plugin,
423+
we know either the installer or the diagnostic regressed.
424+
"""
425+
from pathlib import Path
426+
import importlib.util as importutil
427+
428+
# Bootstrap session-start.py import
429+
repo_hooks = Path(__file__).resolve().parents[1] / "hooks"
430+
real_hud_source = repo_hooks / "codingbuddy-hud.py"
431+
if not real_hud_source.exists():
432+
pytest.skip(f"real HUD source not found at {real_hud_source}")
433+
434+
spec = importutil.spec_from_file_location(
435+
"session_start_for_health", str(repo_hooks / "session-start.py")
436+
)
437+
session_start = importutil.module_from_spec(spec)
438+
spec.loader.exec_module(session_start)
439+
440+
# Force the installer to use the real source
441+
monkeypatch.setattr(
442+
session_start, "_find_hud_source", lambda: real_hud_source
443+
)
444+
settings_file = env / ".claude" / "settings.json"
445+
session_start._install_statusline(env, settings_file)
446+
447+
checker = _make_checker(env)
448+
result = checker.check_hud_installation()
449+
assert result["status"] == "PASS", (
450+
f"expected PASS, got {result}"
451+
)
452+
assert "rendering full status line" in result["message"]

0 commit comments

Comments
 (0)