Skip to content

Commit f8c679d

Browse files
committed
fix(hud): sync hooks/lib in _install_statusline (#1490)
The v5.6.0 refactor extracted 11 hud_*.py modules + tiny_actor_presets into hooks/lib/, but _install_statusline still ran a single shutil.copy on codingbuddy-hud.py and never touched the sibling lib/ directory. End-state on every upgraded user machine: ~/.claude/hud/codingbuddy-hud.py ← updated ~/.claude/hud/lib/ ← MISSING The script tries `from hud_buddy import BUDDY_FACE` first; the ImportError trips its outer try/except, which prints the bare fallback `◕‿◕ CodingBuddy`. Every Wave 1/2/3 status line feature shipped in v5.6.0 / v5.6.1 was therefore invisible to all users. Fix: - Replace shutil.copy(...) with _atomic_sync_with_lib(source, hud_dir) so the script and the entire sibling lib/ are synced as a single unit on every session start. - Write ~/.claude/hud/.version stamp so health_check (next commit) can detect drift. - Honour CODINGBUDDY_HUD_DEBUG=1 to surface installer errors on stderr (default still silent so session start is never blocked). Test coverage (test_session_start_hud.py): - TestSyncHudAssets (8 tests): copies all 12 required modules, excludes __pycache__/*.pyc/.pytest_cache/test_*.py, replaces stale renamed modules, idempotent re-invocation, writes version stamp, gracefully skips lib when absent, preserves settings.json update. - TestHudInstallE2ERegressionGate (4 parametrized scenarios): runs the installed script as a real subprocess and asserts the output is NOT '◕‿◕ CodingBuddy'. Scenarios cover clean install, partial (current v5.6.1 user state), stale lib (renamed module), and fresh idempotent re-run. This is the single regression gate that would have caught the v5.6.0 / v5.6.1 ship. Total: 6 prior + 14 new = 20 statusline tests. All green. Refs #1490
1 parent e72a786 commit f8c679d

2 files changed

Lines changed: 340 additions & 8 deletions

File tree

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -620,19 +620,45 @@ def _find_hud_source() -> Optional[Path]:
620620

621621

622622
def _install_statusline(home: Path, settings_file: Path) -> None:
623-
"""Install codingbuddy statusLine (#1089)."""
624-
# 1. Find and copy HUD script
623+
"""Install codingbuddy statusLine (#1089, fix #1490).
624+
625+
v5.6.2: now syncs ``hooks/lib`` alongside the script via
626+
:func:`_atomic_sync_with_lib`. Previous versions only copied the
627+
single ``codingbuddy-hud.py`` file, leaving ``~/.claude/hud/lib``
628+
empty or stale. Once Wave 1/2/3 modules were extracted to
629+
``hooks/lib`` in v5.6.0 every statusLine import failed and the
630+
outer ``try/except`` in ``codingbuddy-hud.py`` rendered only
631+
``◕‿◕ CodingBuddy``.
632+
633+
Set ``CODINGBUDDY_HUD_DEBUG=1`` to surface install errors on
634+
stderr; without the env var, errors bubble to ``main()``'s outer
635+
silent except so session start is never blocked.
636+
"""
637+
# 1. Find HUD source
625638
source = _find_hud_source()
626639
if not source:
640+
if os.environ.get("CODINGBUDDY_HUD_DEBUG"):
641+
print("[hud] _install_statusline: source not found", file=sys.stderr)
627642
return
628643

629644
hud_dir = home / ".claude" / "hud"
630-
hud_dir.mkdir(parents=True, exist_ok=True)
631-
target = hud_dir / HUD_FILENAME
632-
shutil.copy(source, target)
633-
target.chmod(0o755)
634645

635-
# 2. Update settings.json
646+
# 2. Atomic sync (script + lib/) — replaces previous shutil.copy-only path
647+
try:
648+
_atomic_sync_with_lib(source, hud_dir)
649+
except Exception as exc:
650+
if os.environ.get("CODINGBUDDY_HUD_DEBUG"):
651+
print(f"[hud] _atomic_sync_with_lib failed: {exc}", file=sys.stderr)
652+
raise # bubble to main()'s outer except
653+
654+
# 3. Write version stamp for health_check / diagnostics
655+
try:
656+
version = _get_plugin_version()
657+
(hud_dir / ".version").write_text(version, encoding="utf-8")
658+
except Exception:
659+
pass # stamp is best-effort
660+
661+
# 4. Update settings.json
636662
settings = _read_settings_file(settings_file) if settings_file.exists() else {}
637663

638664
current_sl = settings.get("statusLine", {}).get("command", "")

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

Lines changed: 307 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
"""Tests for statusLine auto-install in session-start (#1089, #1092)."""
1+
"""Tests for statusLine auto-install in session-start (#1089, #1092, #1490)."""
22
import json
33
import os
4+
import shutil
5+
import subprocess
46
import sys
7+
from pathlib import Path
8+
from unittest import mock
59

610
import pytest
711

@@ -53,6 +57,80 @@ def hud_source(home_dir):
5357
return src
5458

5559

60+
# ----- v5.6.2 (#1490) fixtures -----
61+
62+
63+
HUD_REQUIRED_LIB_MODULES = [
64+
"hud_buddy.py",
65+
"hud_cache_savings.py",
66+
"hud_context_bar.py",
67+
"hud_helpers.py",
68+
"hud_layout.py",
69+
"hud_rainbow.py",
70+
"hud_rate_limits.py",
71+
"hud_session.py",
72+
"hud_state.py",
73+
"hud_velocity.py",
74+
"hud_version.py",
75+
"tiny_actor_presets.py",
76+
]
77+
78+
79+
@pytest.fixture
80+
def hud_source_with_lib(tmp_path):
81+
"""Synthetic HUD source dir with lib/ containing all 12 required modules."""
82+
hooks = tmp_path / "src_hooks"
83+
hooks.mkdir()
84+
(hooks / "codingbuddy-hud.py").write_text("#!/usr/bin/env python3\nprint('stub')")
85+
lib = hooks / "lib"
86+
lib.mkdir()
87+
for name in HUD_REQUIRED_LIB_MODULES:
88+
(lib / name).write_text(f"# {name} stub")
89+
return hooks / "codingbuddy-hud.py"
90+
91+
92+
@pytest.fixture
93+
def hud_source_with_lib_and_caches(tmp_path):
94+
"""HUD source with lib/ + __pycache__ + .pytest_cache + test_*.py."""
95+
hooks = tmp_path / "src_hooks"
96+
hooks.mkdir()
97+
(hooks / "codingbuddy-hud.py").write_text("# stub")
98+
lib = hooks / "lib"
99+
lib.mkdir()
100+
for name in HUD_REQUIRED_LIB_MODULES:
101+
(lib / name).write_text(f"# {name} stub")
102+
# Pollutants that must NOT be copied
103+
pycache = lib / "__pycache__"
104+
pycache.mkdir()
105+
(pycache / "x.cpython-39.pyc").write_text("compiled")
106+
(lib / "stale.pyc").write_text("compiled")
107+
pcache = lib / ".pytest_cache"
108+
pcache.mkdir()
109+
(pcache / "v").write_text("cache")
110+
(lib / "test_hud_buddy.py").write_text("def test_x(): pass")
111+
return hooks / "codingbuddy-hud.py"
112+
113+
114+
@pytest.fixture
115+
def hud_source_no_lib(tmp_path):
116+
"""HUD source without sibling lib/."""
117+
hooks = tmp_path / "src_hooks"
118+
hooks.mkdir()
119+
src = hooks / "codingbuddy-hud.py"
120+
src.write_text("# stub")
121+
return src
122+
123+
124+
@pytest.fixture
125+
def real_plugin_hud_source():
126+
"""Path to the real packages/claude-code-plugin/hooks/codingbuddy-hud.py.
127+
128+
Used by E2E render smoke tests — exercises the real import chain.
129+
"""
130+
here = Path(__file__).resolve()
131+
return here.parents[1] / "hooks" / "codingbuddy-hud.py"
132+
133+
56134
class TestInstallStatusline:
57135
def test_installs_hud_script_to_claude_hud_dir(self, home_dir, settings_file, hud_source, monkeypatch):
58136
monkeypatch.setenv("CLAUDE_PLUGIN_DIR", str(hud_source.parent.parent))
@@ -133,3 +211,231 @@ def test_returns_none_when_not_found(self, monkeypatch):
133211
# This may return None or a valid path depending on the test machine
134212
# Just verify it doesn't crash
135213
session_start._find_hud_source()
214+
215+
216+
# ============================================================================
217+
# v5.6.2 (#1490) — _install_statusline must sync hooks/lib alongside script.
218+
# Prior versions only ran shutil.copy on the script, leaving lib/ empty
219+
# or stale and causing every Wave 1/2/3 import to fall back to the
220+
# '◕‿◕ CodingBuddy' face.
221+
# ============================================================================
222+
223+
224+
class TestSyncHudAssets:
225+
"""Unit tests verifying _install_statusline now syncs the lib dir."""
226+
227+
def test_copies_lib_directory(
228+
self, home_dir, settings_file, hud_source_with_lib, monkeypatch
229+
):
230+
monkeypatch.setattr(
231+
session_start, "_find_hud_source", lambda: hud_source_with_lib
232+
)
233+
session_start._install_statusline(home_dir, settings_file)
234+
target_lib = home_dir / ".claude" / "hud" / "lib"
235+
assert target_lib.is_dir()
236+
237+
def test_copies_all_required_hud_modules(
238+
self, home_dir, settings_file, hud_source_with_lib, monkeypatch
239+
):
240+
monkeypatch.setattr(
241+
session_start, "_find_hud_source", lambda: hud_source_with_lib
242+
)
243+
session_start._install_statusline(home_dir, settings_file)
244+
target_lib = home_dir / ".claude" / "hud" / "lib"
245+
for name in HUD_REQUIRED_LIB_MODULES:
246+
assert (target_lib / name).is_file(), f"missing {name} in target lib"
247+
248+
def test_excludes_pycache_pyc_pytest_cache_and_test_files(
249+
self,
250+
home_dir,
251+
settings_file,
252+
hud_source_with_lib_and_caches,
253+
monkeypatch,
254+
):
255+
monkeypatch.setattr(
256+
session_start,
257+
"_find_hud_source",
258+
lambda: hud_source_with_lib_and_caches,
259+
)
260+
session_start._install_statusline(home_dir, settings_file)
261+
target_lib = home_dir / ".claude" / "hud" / "lib"
262+
assert (target_lib / "hud_buddy.py").is_file() # real module copied
263+
assert not (target_lib / "__pycache__").exists()
264+
assert not (target_lib / ".pytest_cache").exists()
265+
assert not list(target_lib.glob("*.pyc"))
266+
assert not list(target_lib.glob("test_*.py"))
267+
268+
def test_replaces_stale_lib_modules(
269+
self, home_dir, settings_file, hud_source_with_lib, monkeypatch
270+
):
271+
"""A pre-existing renamed module from a prior version must be removed."""
272+
target_lib = home_dir / ".claude" / "hud" / "lib"
273+
target_lib.mkdir(parents=True)
274+
(target_lib / "hud_obsolete_v5_5.py").write_text("# stale renamed module")
275+
monkeypatch.setattr(
276+
session_start, "_find_hud_source", lambda: hud_source_with_lib
277+
)
278+
session_start._install_statusline(home_dir, settings_file)
279+
assert not (target_lib / "hud_obsolete_v5_5.py").exists()
280+
assert (target_lib / "hud_buddy.py").exists()
281+
282+
def test_idempotent_double_invocation(
283+
self, home_dir, settings_file, hud_source_with_lib, monkeypatch
284+
):
285+
monkeypatch.setattr(
286+
session_start, "_find_hud_source", lambda: hud_source_with_lib
287+
)
288+
session_start._install_statusline(home_dir, settings_file)
289+
first = sorted(
290+
p.name for p in (home_dir / ".claude" / "hud" / "lib").iterdir()
291+
)
292+
session_start._install_statusline(home_dir, settings_file)
293+
second = sorted(
294+
p.name for p in (home_dir / ".claude" / "hud" / "lib").iterdir()
295+
)
296+
assert first == second
297+
298+
def test_writes_version_stamp(
299+
self, home_dir, settings_file, hud_source_with_lib, monkeypatch
300+
):
301+
monkeypatch.setattr(
302+
session_start, "_find_hud_source", lambda: hud_source_with_lib
303+
)
304+
monkeypatch.setattr(
305+
session_start, "_get_plugin_version", lambda: "5.6.2"
306+
)
307+
session_start._install_statusline(home_dir, settings_file)
308+
stamp = home_dir / ".claude" / "hud" / ".version"
309+
assert stamp.exists()
310+
assert stamp.read_text(encoding="utf-8") == "5.6.2"
311+
312+
def test_no_lib_in_source_silently_skips(
313+
self, home_dir, settings_file, hud_source_no_lib, monkeypatch
314+
):
315+
monkeypatch.setattr(
316+
session_start, "_find_hud_source", lambda: hud_source_no_lib
317+
)
318+
session_start._install_statusline(home_dir, settings_file)
319+
assert (home_dir / ".claude" / "hud" / "codingbuddy-hud.py").exists()
320+
assert not (home_dir / ".claude" / "hud" / "lib").exists()
321+
322+
def test_settings_still_updated_after_lib_sync(
323+
self, home_dir, settings_file, hud_source_with_lib, monkeypatch
324+
):
325+
"""Lib sync must not regress the settings.json update behavior."""
326+
monkeypatch.setattr(
327+
session_start, "_find_hud_source", lambda: hud_source_with_lib
328+
)
329+
session_start._install_statusline(home_dir, settings_file)
330+
data = json.loads(settings_file.read_text())
331+
assert "codingbuddy-hud" in data["statusLine"]["command"]
332+
333+
334+
class TestHudInstallE2ERegressionGate:
335+
"""🔴 The single regression gate that would have caught v5.6.0/v5.6.1.
336+
337+
Simulates a user receiving cache 5.6.2 and starting a fresh Claude
338+
Code session in 4 different starting states, then runs the installed
339+
script as a real subprocess and asserts the output is NOT the
340+
fallback face.
341+
342+
Scenarios:
343+
- clean : ~/.claude/hud absent
344+
- partial : script present, lib absent (current v5.6.1 user state)
345+
- stale : lib has obsolete modules from a prior version
346+
- fresh : already populated by a prior install (idempotency)
347+
"""
348+
349+
@pytest.mark.parametrize("scenario", ["clean", "partial", "stale", "fresh"])
350+
def test_install_then_render_full_status_line(
351+
self, tmp_path, real_plugin_hud_source, scenario
352+
):
353+
if not real_plugin_hud_source.exists():
354+
pytest.skip(
355+
f"real plugin source not found at {real_plugin_hud_source}"
356+
)
357+
358+
# Build a fake "home" that mimics the user's machine.
359+
home = tmp_path / "fake_home"
360+
home.mkdir()
361+
settings_file = home / ".claude" / "settings.json"
362+
settings_file.parent.mkdir(parents=True)
363+
settings_file.write_text("{}")
364+
365+
hud_dir = home / ".claude" / "hud"
366+
367+
if scenario == "partial":
368+
# Mimic v5.6.1 user: script present, lib absent.
369+
hud_dir.mkdir(parents=True)
370+
shutil.copy(real_plugin_hud_source, hud_dir / "codingbuddy-hud.py")
371+
elif scenario == "stale":
372+
hud_dir.mkdir(parents=True)
373+
shutil.copy(real_plugin_hud_source, hud_dir / "codingbuddy-hud.py")
374+
stale_lib = hud_dir / "lib"
375+
stale_lib.mkdir()
376+
(stale_lib / "hud_obsolete_v5_5.py").write_text("# stale")
377+
elif scenario == "fresh":
378+
# Pre-populate by running the installer once.
379+
with mock.patch.object(
380+
session_start,
381+
"_find_hud_source",
382+
return_value=real_plugin_hud_source,
383+
):
384+
session_start._install_statusline(home, settings_file)
385+
386+
# The actual install under test
387+
with mock.patch.object(
388+
session_start,
389+
"_find_hud_source",
390+
return_value=real_plugin_hud_source,
391+
):
392+
session_start._install_statusline(home, settings_file)
393+
394+
installed_script = hud_dir / "codingbuddy-hud.py"
395+
installed_lib = hud_dir / "lib"
396+
397+
# Post-condition: script + lib + 12 modules
398+
assert installed_script.exists()
399+
assert installed_lib.is_dir()
400+
for name in HUD_REQUIRED_LIB_MODULES:
401+
assert (installed_lib / name).exists(), (
402+
f"scenario={scenario}: missing {name} in target lib"
403+
)
404+
# Stale module gone
405+
if scenario == "stale":
406+
assert not (installed_lib / "hud_obsolete_v5_5.py").exists()
407+
408+
# 🔴 The render gate
409+
stdin_payload = json.dumps(
410+
{
411+
"session_id": "regression-gate",
412+
"model": {"display_name": "Opus 4.6"},
413+
"cost": {
414+
"total_cost_usd": 0.42,
415+
"total_duration_ms": 120000,
416+
},
417+
}
418+
)
419+
result = subprocess.run(
420+
["python3", str(installed_script)],
421+
input=stdin_payload,
422+
capture_output=True,
423+
text=True,
424+
timeout=10,
425+
)
426+
assert result.returncode == 0, (
427+
f"scenario={scenario} crashed: stderr={result.stderr!r}"
428+
)
429+
out = result.stdout
430+
431+
assert out.strip() != "◕‿◕ CodingBuddy", (
432+
f"FALLBACK FACE REGRESSION (#1490) — scenario={scenario}\n"
433+
f"stdout: {out!r}\n"
434+
f"stderr: {result.stderr!r}\n"
435+
f"installed lib contents: "
436+
f"{sorted(p.name for p in installed_lib.iterdir())}"
437+
)
438+
439+
assert "CB v" in out, f"version segment missing: {out!r}"
440+
assert "Opus 4.6" in out, f"model segment missing: {out!r}"
441+
assert "$0.42" in out, f"cost segment missing: {out!r}"

0 commit comments

Comments
 (0)