From 9ac3d32fd427d334c1208a4445ab572c5cd44a56 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 01:52:14 +0900 Subject: [PATCH 01/10] feat(hooks): add _atomic_sync_with_lib helper (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the canonical install primitive for asset+lib syncs used by both the UserPromptSubmit hook installer and the HUD statusLine installer. The helper guarantees: 1. The script file is copied and chmod 0755. 2. The sibling lib/ directory is **atomically replaced** (rmtree + copytree) so renamed or removed modules from prior plugin versions cannot linger in the target directory. 3. __pycache__, *.pyc, *.pyo, .pytest_cache, test_*.py, and *.egg-info are excluded from the runtime lib so sys.path stays clean. Why rmtree-then-copytree (not dirs_exist_ok=True): copytree's dirs_exist_ok mode only writes; it does not remove files that existed before but are gone now. A renamed module (e.g. hud_old.py → hud_new.py) would remain in the target lib and could be imported first, causing subtle regressions. session-start runs once per Claude Code session, so the rmtree cost is negligible. This commit only introduces the helper and migrates _install_hook_with_lib to use it (no behavior change for the UserPromptSubmit hook beyond stale-safe lib sync). The HUD installer migration follows in the next commit, gated on its own test suite. TDD: 9 RED tests in tests/test_atomic_sync_with_lib.py written first, then GREEN by implementing the helper. Existing TestHookLibCopy/TestRegisterHookInSettings/TestEnsureMcpJson regression suites all still pass (34 prior tests + 9 new = 43). Refs #1490 --- .../claude-code-plugin/hooks/session-start.py | 106 +++++++-- .../tests/test_atomic_sync_with_lib.py | 201 ++++++++++++++++++ 2 files changed, 290 insertions(+), 17 deletions(-) create mode 100644 packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py diff --git a/packages/claude-code-plugin/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index 46f81992..4733d85f 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -397,35 +397,107 @@ def register_hook_in_settings(settings_file: Path) -> bool: return True -def _install_hook_with_lib( - source_file: Path, hooks_dir: Path, target_file: Path +# Asset sync ignore patterns shared by both installers (#1490). +# Why these patterns: +# __pycache__ / *.pyc / *.pyo — compiled artifacts; never needed at runtime +# .pytest_cache — local test runner state; can pollute sys.path +# test_*.py — test files must not enter the runtime lib +# *.egg-info — packaging metadata +_HUD_SYNC_IGNORE_PATTERNS: Tuple[str, ...] = ( + "__pycache__", + "*.pyc", + "*.pyo", + ".pytest_cache", + "test_*.py", + "*.egg-info", +) + + +def _atomic_sync_with_lib( + source_script: Path, + target_dir: Path, + extra_ignore: Optional[Tuple[str, ...]] = None, ) -> None: - """Copy hook file AND its lib/ dependencies to the target hooks directory. - - Copies the hook script and, if present, the sibling lib/ directory - so that runtime imports (e.g. hud_state) work from ~/.claude/hooks/. + """Atomically install a script + its sibling ``lib/`` directory. + + Replaces the prior pattern of ``shutil.copy`` (script-only) and + ``shutil.copytree(dirs_exist_ok=True)`` (additive lib copy). Both + were vulnerable to the v5.6.0/v5.6.1 HUD installer regression + (#1490) where renamed/removed modules from prior plugin versions + remained in the target directory and caused import failures. + + Behavior: + 1. ``mkdir -p target_dir`` + 2. Copy ``source_script`` to ``target_dir/`` + and ``chmod 0o755`` + 3. If ``source_script.parent / "lib"`` exists, **rmtree** any + existing ``target_dir/lib`` and then ``copytree`` the source + lib so renamed modules cannot linger. + + Why rmtree-then-copytree (and not ``dirs_exist_ok=True``): + ``dirs_exist_ok=True`` only writes; it does not remove files + that existed before but are gone now. A renamed module + (e.g. ``hud_old.py`` → ``hud_new.py``) would remain in the + target lib and could be imported first, causing subtle + regressions. session-start runs once per Claude Code session, + so the cost of the additional rmtree is negligible. Args: - source_file: Path to the source hook script. - hooks_dir: Target directory (e.g. ~/.claude/hooks/). - target_file: Full target path for the hook script. + source_script: Path to the script file to install. Its parent + directory is searched for a sibling ``lib/`` to mirror. + target_dir: Destination directory. Created if missing. + extra_ignore: Additional ignore-pattern tuple appended to the + shared :data:`_HUD_SYNC_IGNORE_PATTERNS` list. """ - hooks_dir.mkdir(parents=True, exist_ok=True) - shutil.copy(source_file, target_file) - target_file.chmod(0o755) + target_dir.mkdir(parents=True, exist_ok=True) + + # 1. Script + target_script = target_dir / source_script.name + shutil.copy(source_script, target_script) + target_script.chmod(0o755) - # Copy lib/ directory alongside the hook (#1102) - source_lib = source_file.parent / "lib" + # 2. Lib (atomic replace) + source_lib = source_script.parent / "lib" if source_lib.is_dir(): - target_lib = hooks_dir / "lib" + target_lib = target_dir / "lib" + if target_lib.exists(): + shutil.rmtree(target_lib) + ignore_patterns = _HUD_SYNC_IGNORE_PATTERNS + if extra_ignore: + ignore_patterns = ignore_patterns + tuple(extra_ignore) shutil.copytree( source_lib, target_lib, - dirs_exist_ok=True, - ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ignore=shutil.ignore_patterns(*ignore_patterns), ) +def _install_hook_with_lib( + source_file: Path, hooks_dir: Path, target_file: Path +) -> None: + """Copy hook file AND its lib/ dependencies to the target hooks directory. + + v5.6.2 (#1490): now delegates to :func:`_atomic_sync_with_lib` so + renamed/removed modules from prior plugin versions are purged on + every sync. The hook script is then renamed in place to + ``HOOK_FILENAME`` because Claude Code's settings.json points at + that canonical name (``codingbuddy-mode-detect.py``) rather than + the source name (``user-prompt-submit.py``). + + Args: + source_file: Path to the source hook script. + hooks_dir: Target directory (e.g. ``~/.claude/hooks/``). + target_file: Full target path for the hook script. + """ + _atomic_sync_with_lib(source_file, hooks_dir) + synced = hooks_dir / source_file.name + if synced != target_file: + if target_file.exists(): + target_file.unlink() + synced.rename(target_file) + target_file.chmod(0o755) + + CODINGBUDDY_MCP_ENTRY = { "command": "codingbuddy", "args": ["mcp"], diff --git a/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py b/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py new file mode 100644 index 00000000..6744c69e --- /dev/null +++ b/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py @@ -0,0 +1,201 @@ +"""Tests for _atomic_sync_with_lib helper (#1490). + +This helper is the canonical install primitive for both: + - _install_statusline (HUD) + - _install_hook_with_lib (UserPromptSubmit hook) + +Why this helper exists +---------------------- +Prior to v5.6.2, _install_statusline only copied the script file +(not its sibling lib/ directory), and _install_hook_with_lib used +copytree(dirs_exist_ok=True) which left renamed/removed modules +from prior plugin versions stranded. Both bugs caused statusLine +to render only the fallback face once Wave 1/2/3 modules were +extracted to lib/ in v5.6.0. + +This helper guarantees: + 1. Script is copied and made executable. + 2. lib/ is atomically replaced (rmtree + copytree) so stale + modules cannot linger. + 3. Pyc, pycache, pytest cache, and test_*.py files are excluded + so the runtime sys.path stays clean. +""" +import os +import sys +import shutil +import importlib.util as importutil +from pathlib import Path + +import pytest + +# Bootstrap session-start.py import (hyphenated filename) +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +if _hooks_dir not in sys.path: + sys.path.insert(0, _hooks_dir) +_lib_dir = os.path.join(_hooks_dir, "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +_spec = importutil.spec_from_file_location( + "session_start", os.path.join(_hooks_dir, "session-start.py") +) +session_start = importutil.module_from_spec(_spec) +_spec.loader.exec_module(session_start) + + +# ----- fixtures ----- + + +@pytest.fixture +def fake_source_with_lib(tmp_path): + """Create a synthetic source layout: hooks/script.py + hooks/lib/*.py.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + script = hooks / "script.py" + script.write_text("# fake script") + lib = hooks / "lib" + lib.mkdir() + (lib / "mod_a.py").write_text("VAL_A = 1") + (lib / "mod_b.py").write_text("VAL_B = 2") + (lib / "shared_helper.py").write_text("# helper") + return script + + +@pytest.fixture +def fake_source_with_lib_and_caches(tmp_path): + """Same as above but with __pycache__, .pytest_cache, *.pyc, test_*.py.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + script = hooks / "script.py" + script.write_text("# fake script") + lib = hooks / "lib" + lib.mkdir() + (lib / "mod_a.py").write_text("VAL_A = 1") + + # Pollutants that must NOT be copied + pycache = lib / "__pycache__" + pycache.mkdir() + (pycache / "mod_a.cpython-39.pyc").write_text("compiled") + (lib / "mod_a.pyc").write_text("compiled") + pytest_cache = lib / ".pytest_cache" + pytest_cache.mkdir() + (pytest_cache / "v").write_text("cache") + (lib / "test_mod_a.py").write_text("def test_x(): pass") + + return script + + +@pytest.fixture +def fake_source_no_lib(tmp_path): + """Source script without a sibling lib/ directory.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + script = hooks / "script.py" + script.write_text("# standalone") + return script + + +@pytest.fixture +def target_dir(tmp_path): + """Empty target directory.""" + return tmp_path / "target" + + +# ----- tests ----- + + +class TestAtomicSyncWithLib: + """RED tests for the new helper. All must fail before GREEN.""" + + def test_helper_exists(self): + """The helper must be exposed on the session_start module.""" + assert hasattr(session_start, "_atomic_sync_with_lib"), ( + "session-start.py must export _atomic_sync_with_lib" + ) + + def test_creates_target_dir_and_copies_script( + self, fake_source_with_lib, target_dir + ): + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + assert (target_dir / "script.py").is_file() + + def test_target_script_is_executable(self, fake_source_with_lib, target_dir): + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + target = target_dir / "script.py" + assert os.access(str(target), os.X_OK), "script must be 0755" + + def test_copies_lib_directory(self, fake_source_with_lib, target_dir): + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + target_lib = target_dir / "lib" + assert target_lib.is_dir() + assert (target_lib / "mod_a.py").is_file() + assert (target_lib / "mod_b.py").is_file() + assert (target_lib / "shared_helper.py").is_file() + + def test_no_lib_in_source_silently_skips_lib( + self, fake_source_no_lib, target_dir + ): + """Helper must not crash when source has no sibling lib/.""" + session_start._atomic_sync_with_lib(fake_source_no_lib, target_dir) + assert (target_dir / "script.py").is_file() + assert not (target_dir / "lib").exists() + + def test_excludes_pycache_pyc_pytest_cache_and_test_files( + self, fake_source_with_lib_and_caches, target_dir + ): + session_start._atomic_sync_with_lib( + fake_source_with_lib_and_caches, target_dir + ) + target_lib = target_dir / "lib" + # Real module copied + assert (target_lib / "mod_a.py").is_file() + # Pollutants NOT copied + assert not (target_lib / "__pycache__").exists() + assert not (target_lib / ".pytest_cache").exists() + assert not list(target_lib.glob("*.pyc")) + assert not list(target_lib.glob("test_*.py")) + + def test_replaces_stale_lib_modules( + self, fake_source_with_lib, target_dir + ): + """Modules present in target lib but absent in source must be removed.""" + target_lib = target_dir / "lib" + target_lib.mkdir(parents=True) + (target_lib / "obsolete_renamed.py").write_text("# stale from prior version") + (target_lib / "mod_a.py").write_text("OLD_VAL = 0") # outdated content + + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + + # Stale module gone + assert not (target_lib / "obsolete_renamed.py").exists() + # Real module replaced with current content + assert (target_lib / "mod_a.py").read_text() == "VAL_A = 1" + + def test_idempotent_double_invocation( + self, fake_source_with_lib, target_dir + ): + """Two consecutive invocations leave the same target state.""" + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + first_lib = sorted(p.name for p in (target_dir / "lib").iterdir()) + first_script = (target_dir / "script.py").read_text() + + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + second_lib = sorted(p.name for p in (target_dir / "lib").iterdir()) + second_script = (target_dir / "script.py").read_text() + + assert first_lib == second_lib + assert first_script == second_script + + def test_extra_ignore_argument_is_honored( + self, fake_source_with_lib, target_dir + ): + """Caller can pass extra ignore patterns.""" + session_start._atomic_sync_with_lib( + fake_source_with_lib, + target_dir, + extra_ignore=("shared_helper.py",), + ) + target_lib = target_dir / "lib" + assert (target_lib / "mod_a.py").is_file() + assert not (target_lib / "shared_helper.py").exists() From 9c89f0e6a093ff7bc0084bc4da8cb3463c01d55f Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 01:55:03 +0900 Subject: [PATCH 02/10] fix(hud): sync hooks/lib in _install_statusline (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../claude-code-plugin/hooks/session-start.py | 40 ++- .../tests/test_session_start_hud.py | 308 +++++++++++++++++- 2 files changed, 340 insertions(+), 8 deletions(-) diff --git a/packages/claude-code-plugin/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index 4733d85f..316460d5 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -620,19 +620,45 @@ def _find_hud_source() -> Optional[Path]: def _install_statusline(home: Path, settings_file: Path) -> None: - """Install codingbuddy statusLine (#1089).""" - # 1. Find and copy HUD script + """Install codingbuddy statusLine (#1089, fix #1490). + + v5.6.2: now syncs ``hooks/lib`` alongside the script via + :func:`_atomic_sync_with_lib`. Previous versions only copied the + single ``codingbuddy-hud.py`` file, leaving ``~/.claude/hud/lib`` + empty or stale. Once Wave 1/2/3 modules were extracted to + ``hooks/lib`` in v5.6.0 every statusLine import failed and the + outer ``try/except`` in ``codingbuddy-hud.py`` rendered only + ``◕‿◕ CodingBuddy``. + + Set ``CODINGBUDDY_HUD_DEBUG=1`` to surface install errors on + stderr; without the env var, errors bubble to ``main()``'s outer + silent except so session start is never blocked. + """ + # 1. Find HUD source source = _find_hud_source() if not source: + if os.environ.get("CODINGBUDDY_HUD_DEBUG"): + print("[hud] _install_statusline: source not found", file=sys.stderr) return hud_dir = home / ".claude" / "hud" - hud_dir.mkdir(parents=True, exist_ok=True) - target = hud_dir / HUD_FILENAME - shutil.copy(source, target) - target.chmod(0o755) - # 2. Update settings.json + # 2. Atomic sync (script + lib/) — replaces previous shutil.copy-only path + try: + _atomic_sync_with_lib(source, hud_dir) + except Exception as exc: + if os.environ.get("CODINGBUDDY_HUD_DEBUG"): + print(f"[hud] _atomic_sync_with_lib failed: {exc}", file=sys.stderr) + raise # bubble to main()'s outer except + + # 3. Write version stamp for health_check / diagnostics + try: + version = _get_plugin_version() + (hud_dir / ".version").write_text(version, encoding="utf-8") + except Exception: + pass # stamp is best-effort + + # 4. Update settings.json settings = _read_settings_file(settings_file) if settings_file.exists() else {} current_sl = settings.get("statusLine", {}).get("command", "") diff --git a/packages/claude-code-plugin/tests/test_session_start_hud.py b/packages/claude-code-plugin/tests/test_session_start_hud.py index 6dd7ade6..4a923e97 100644 --- a/packages/claude-code-plugin/tests/test_session_start_hud.py +++ b/packages/claude-code-plugin/tests/test_session_start_hud.py @@ -1,7 +1,11 @@ -"""Tests for statusLine auto-install in session-start (#1089, #1092).""" +"""Tests for statusLine auto-install in session-start (#1089, #1092, #1490).""" import json import os +import shutil +import subprocess import sys +from pathlib import Path +from unittest import mock import pytest @@ -53,6 +57,80 @@ def hud_source(home_dir): return src +# ----- v5.6.2 (#1490) fixtures ----- + + +HUD_REQUIRED_LIB_MODULES = [ + "hud_buddy.py", + "hud_cache_savings.py", + "hud_context_bar.py", + "hud_helpers.py", + "hud_layout.py", + "hud_rainbow.py", + "hud_rate_limits.py", + "hud_session.py", + "hud_state.py", + "hud_velocity.py", + "hud_version.py", + "tiny_actor_presets.py", +] + + +@pytest.fixture +def hud_source_with_lib(tmp_path): + """Synthetic HUD source dir with lib/ containing all 12 required modules.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + (hooks / "codingbuddy-hud.py").write_text("#!/usr/bin/env python3\nprint('stub')") + lib = hooks / "lib" + lib.mkdir() + for name in HUD_REQUIRED_LIB_MODULES: + (lib / name).write_text(f"# {name} stub") + return hooks / "codingbuddy-hud.py" + + +@pytest.fixture +def hud_source_with_lib_and_caches(tmp_path): + """HUD source with lib/ + __pycache__ + .pytest_cache + test_*.py.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + (hooks / "codingbuddy-hud.py").write_text("# stub") + lib = hooks / "lib" + lib.mkdir() + for name in HUD_REQUIRED_LIB_MODULES: + (lib / name).write_text(f"# {name} stub") + # Pollutants that must NOT be copied + pycache = lib / "__pycache__" + pycache.mkdir() + (pycache / "x.cpython-39.pyc").write_text("compiled") + (lib / "stale.pyc").write_text("compiled") + pcache = lib / ".pytest_cache" + pcache.mkdir() + (pcache / "v").write_text("cache") + (lib / "test_hud_buddy.py").write_text("def test_x(): pass") + return hooks / "codingbuddy-hud.py" + + +@pytest.fixture +def hud_source_no_lib(tmp_path): + """HUD source without sibling lib/.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + src = hooks / "codingbuddy-hud.py" + src.write_text("# stub") + return src + + +@pytest.fixture +def real_plugin_hud_source(): + """Path to the real packages/claude-code-plugin/hooks/codingbuddy-hud.py. + + Used by E2E render smoke tests — exercises the real import chain. + """ + here = Path(__file__).resolve() + return here.parents[1] / "hooks" / "codingbuddy-hud.py" + + class TestInstallStatusline: def test_installs_hud_script_to_claude_hud_dir(self, home_dir, settings_file, hud_source, monkeypatch): monkeypatch.setenv("CLAUDE_PLUGIN_DIR", str(hud_source.parent.parent)) @@ -133,3 +211,231 @@ def test_returns_none_when_not_found(self, monkeypatch): # This may return None or a valid path depending on the test machine # Just verify it doesn't crash session_start._find_hud_source() + + +# ============================================================================ +# v5.6.2 (#1490) — _install_statusline must sync hooks/lib alongside script. +# Prior versions only ran shutil.copy on the script, leaving lib/ empty +# or stale and causing every Wave 1/2/3 import to fall back to the +# '◕‿◕ CodingBuddy' face. +# ============================================================================ + + +class TestSyncHudAssets: + """Unit tests verifying _install_statusline now syncs the lib dir.""" + + def test_copies_lib_directory( + self, home_dir, settings_file, hud_source_with_lib, monkeypatch + ): + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_with_lib + ) + session_start._install_statusline(home_dir, settings_file) + target_lib = home_dir / ".claude" / "hud" / "lib" + assert target_lib.is_dir() + + def test_copies_all_required_hud_modules( + self, home_dir, settings_file, hud_source_with_lib, monkeypatch + ): + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_with_lib + ) + session_start._install_statusline(home_dir, settings_file) + target_lib = home_dir / ".claude" / "hud" / "lib" + for name in HUD_REQUIRED_LIB_MODULES: + assert (target_lib / name).is_file(), f"missing {name} in target lib" + + def test_excludes_pycache_pyc_pytest_cache_and_test_files( + self, + home_dir, + settings_file, + hud_source_with_lib_and_caches, + monkeypatch, + ): + monkeypatch.setattr( + session_start, + "_find_hud_source", + lambda: hud_source_with_lib_and_caches, + ) + session_start._install_statusline(home_dir, settings_file) + target_lib = home_dir / ".claude" / "hud" / "lib" + assert (target_lib / "hud_buddy.py").is_file() # real module copied + assert not (target_lib / "__pycache__").exists() + assert not (target_lib / ".pytest_cache").exists() + assert not list(target_lib.glob("*.pyc")) + assert not list(target_lib.glob("test_*.py")) + + def test_replaces_stale_lib_modules( + self, home_dir, settings_file, hud_source_with_lib, monkeypatch + ): + """A pre-existing renamed module from a prior version must be removed.""" + target_lib = home_dir / ".claude" / "hud" / "lib" + target_lib.mkdir(parents=True) + (target_lib / "hud_obsolete_v5_5.py").write_text("# stale renamed module") + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_with_lib + ) + session_start._install_statusline(home_dir, settings_file) + assert not (target_lib / "hud_obsolete_v5_5.py").exists() + assert (target_lib / "hud_buddy.py").exists() + + def test_idempotent_double_invocation( + self, home_dir, settings_file, hud_source_with_lib, monkeypatch + ): + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_with_lib + ) + session_start._install_statusline(home_dir, settings_file) + first = sorted( + p.name for p in (home_dir / ".claude" / "hud" / "lib").iterdir() + ) + session_start._install_statusline(home_dir, settings_file) + second = sorted( + p.name for p in (home_dir / ".claude" / "hud" / "lib").iterdir() + ) + assert first == second + + def test_writes_version_stamp( + self, home_dir, settings_file, hud_source_with_lib, monkeypatch + ): + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_with_lib + ) + monkeypatch.setattr( + session_start, "_get_plugin_version", lambda: "5.6.2" + ) + session_start._install_statusline(home_dir, settings_file) + stamp = home_dir / ".claude" / "hud" / ".version" + assert stamp.exists() + assert stamp.read_text(encoding="utf-8") == "5.6.2" + + def test_no_lib_in_source_silently_skips( + self, home_dir, settings_file, hud_source_no_lib, monkeypatch + ): + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_no_lib + ) + session_start._install_statusline(home_dir, settings_file) + assert (home_dir / ".claude" / "hud" / "codingbuddy-hud.py").exists() + assert not (home_dir / ".claude" / "hud" / "lib").exists() + + def test_settings_still_updated_after_lib_sync( + self, home_dir, settings_file, hud_source_with_lib, monkeypatch + ): + """Lib sync must not regress the settings.json update behavior.""" + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: hud_source_with_lib + ) + session_start._install_statusline(home_dir, settings_file) + data = json.loads(settings_file.read_text()) + assert "codingbuddy-hud" in data["statusLine"]["command"] + + +class TestHudInstallE2ERegressionGate: + """🔴 The single regression gate that would have caught v5.6.0/v5.6.1. + + Simulates a user receiving cache 5.6.2 and starting a fresh Claude + Code session in 4 different starting states, then runs the installed + script as a real subprocess and asserts the output is NOT the + fallback face. + + Scenarios: + - clean : ~/.claude/hud absent + - partial : script present, lib absent (current v5.6.1 user state) + - stale : lib has obsolete modules from a prior version + - fresh : already populated by a prior install (idempotency) + """ + + @pytest.mark.parametrize("scenario", ["clean", "partial", "stale", "fresh"]) + def test_install_then_render_full_status_line( + self, tmp_path, real_plugin_hud_source, scenario + ): + if not real_plugin_hud_source.exists(): + pytest.skip( + f"real plugin source not found at {real_plugin_hud_source}" + ) + + # Build a fake "home" that mimics the user's machine. + home = tmp_path / "fake_home" + home.mkdir() + settings_file = home / ".claude" / "settings.json" + settings_file.parent.mkdir(parents=True) + settings_file.write_text("{}") + + hud_dir = home / ".claude" / "hud" + + if scenario == "partial": + # Mimic v5.6.1 user: script present, lib absent. + hud_dir.mkdir(parents=True) + shutil.copy(real_plugin_hud_source, hud_dir / "codingbuddy-hud.py") + elif scenario == "stale": + hud_dir.mkdir(parents=True) + shutil.copy(real_plugin_hud_source, hud_dir / "codingbuddy-hud.py") + stale_lib = hud_dir / "lib" + stale_lib.mkdir() + (stale_lib / "hud_obsolete_v5_5.py").write_text("# stale") + elif scenario == "fresh": + # Pre-populate by running the installer once. + with mock.patch.object( + session_start, + "_find_hud_source", + return_value=real_plugin_hud_source, + ): + session_start._install_statusline(home, settings_file) + + # The actual install under test + with mock.patch.object( + session_start, + "_find_hud_source", + return_value=real_plugin_hud_source, + ): + session_start._install_statusline(home, settings_file) + + installed_script = hud_dir / "codingbuddy-hud.py" + installed_lib = hud_dir / "lib" + + # Post-condition: script + lib + 12 modules + assert installed_script.exists() + assert installed_lib.is_dir() + for name in HUD_REQUIRED_LIB_MODULES: + assert (installed_lib / name).exists(), ( + f"scenario={scenario}: missing {name} in target lib" + ) + # Stale module gone + if scenario == "stale": + assert not (installed_lib / "hud_obsolete_v5_5.py").exists() + + # 🔴 The render gate + stdin_payload = json.dumps( + { + "session_id": "regression-gate", + "model": {"display_name": "Opus 4.6"}, + "cost": { + "total_cost_usd": 0.42, + "total_duration_ms": 120000, + }, + } + ) + result = subprocess.run( + ["python3", str(installed_script)], + input=stdin_payload, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, ( + f"scenario={scenario} crashed: stderr={result.stderr!r}" + ) + out = result.stdout + + assert out.strip() != "◕‿◕ CodingBuddy", ( + f"FALLBACK FACE REGRESSION (#1490) — scenario={scenario}\n" + f"stdout: {out!r}\n" + f"stderr: {result.stderr!r}\n" + f"installed lib contents: " + f"{sorted(p.name for p in installed_lib.iterdir())}" + ) + + assert "CB v" in out, f"version segment missing: {out!r}" + assert "Opus 4.6" in out, f"model segment missing: {out!r}" + assert "$0.42" in out, f"cost segment missing: {out!r}" From f6e7c557859a75a52a2283e65289330ebc25800e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 01:56:19 +0900 Subject: [PATCH 03/10] test(hooks): regression gate for _install_hook_with_lib stale-safe sync (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sister-bug to the HUD installer fix: _install_hook_with_lib used copytree(dirs_exist_ok=True), which writes new files but never removes files that existed before but are gone now. A renamed module (e.g. mode_engine.py → mode_engine_v2.py) would leave the old file in ~/.claude/hooks/lib/ indefinitely, where Python's import system could pick it up first. Commit 1 already migrated _install_hook_with_lib to delegate to _atomic_sync_with_lib (which performs an atomic rmtree + copytree). This commit adds the explicit regression gate so future refactors cannot reintroduce the stale-write pattern unnoticed. Coverage (test_install_hook_with_lib.py, 4 cases): - test_replaces_stale_lib_modules — stale renamed module purged - test_excludes_pycache_pyc_pytest_cache_and_test_files — pollutants from source lib never enter target lib - test_renames_source_to_target_filename — user-prompt-submit.py source lands at codingbuddy-mode-detect.py target, source name does NOT linger - test_idempotent_double_invocation — two consecutive installs yield identical state Refs #1490 --- .../tests/test_install_hook_with_lib.py | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 packages/claude-code-plugin/tests/test_install_hook_with_lib.py diff --git a/packages/claude-code-plugin/tests/test_install_hook_with_lib.py b/packages/claude-code-plugin/tests/test_install_hook_with_lib.py new file mode 100644 index 00000000..415d3e40 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_install_hook_with_lib.py @@ -0,0 +1,145 @@ +"""Sister-bug regression gate for _install_hook_with_lib (#1490). + +The UserPromptSubmit hook installer (_install_hook_with_lib) had the +same class of bug as _install_statusline: it used +``shutil.copytree(dirs_exist_ok=True)`` for the lib/ sync, which writes +new files but never removes files that existed before but are gone +now. A renamed module (e.g. ``mode_engine.py`` → ``mode_engine_v2.py``) +would leave the old file in ``~/.claude/hooks/lib/`` indefinitely, +where Python's import system could pick it up first. + +v5.6.2 routes both installers through ``_atomic_sync_with_lib`` which +performs an atomic ``rmtree + copytree`` on the lib directory. This +test suite is the regression gate ensuring stale modules are purged +and the hook script rename (``user-prompt-submit.py`` → +``codingbuddy-mode-detect.py``) still works. +""" +import os +import sys +import importlib.util as importutil +from pathlib import Path + +import pytest + +# Bootstrap session-start.py import (hyphenated filename). +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +if _hooks_dir not in sys.path: + sys.path.insert(0, _hooks_dir) +_lib_dir = os.path.join(_hooks_dir, "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +_spec = importutil.spec_from_file_location( + "session_start", os.path.join(_hooks_dir, "session-start.py") +) +session_start = importutil.module_from_spec(_spec) +_spec.loader.exec_module(session_start) + + +@pytest.fixture +def fake_hook_source(tmp_path): + """Synthetic UserPromptSubmit source: hooks/user-prompt-submit.py + hooks/lib/.""" + hooks = tmp_path / "src_hooks" + hooks.mkdir() + src = hooks / "user-prompt-submit.py" + src.write_text("#!/usr/bin/env python3\nprint('mode-detect stub')") + lib = hooks / "lib" + lib.mkdir() + # Mirror a few real lib modules used by user-prompt-submit.py + for name in [ + "mode_engine.py", + "runtime_mode.py", + "tiny_actor_presets.py", + "council_animator.py", + "agent_memory.py", + "achievement_tracker.py", + ]: + (lib / name).write_text(f"# {name} stub") + return src + + +class TestInstallHookWithLibStaleSafe: + """Regression gate for #1490 sister-bug in _install_hook_with_lib.""" + + def test_replaces_stale_lib_modules(self, tmp_path, fake_hook_source): + """Pre-existing renamed modules from prior versions must be purged.""" + hooks_dir = tmp_path / "target_hooks" + # Pre-populate with a stale module that no longer exists in source + stale_lib = hooks_dir / "lib" + stale_lib.mkdir(parents=True) + (stale_lib / "renamed_in_v5_5.py").write_text("# stale rename leftover") + + target = hooks_dir / "codingbuddy-mode-detect.py" + session_start._install_hook_with_lib(fake_hook_source, hooks_dir, target) + + assert not (stale_lib / "renamed_in_v5_5.py").exists(), ( + "stale module from prior version must be purged" + ) + assert (stale_lib / "mode_engine.py").exists() + assert (stale_lib / "runtime_mode.py").exists() + assert (stale_lib / "tiny_actor_presets.py").exists() + + def test_excludes_pycache_pyc_pytest_cache_and_test_files( + self, tmp_path, fake_hook_source + ): + """Source pollutants must not pollute the runtime lib dir.""" + # Add pollutants to source lib + src_lib = fake_hook_source.parent / "lib" + pycache = src_lib / "__pycache__" + pycache.mkdir() + (pycache / "x.cpython-39.pyc").write_text("compiled") + (src_lib / "stale.pyc").write_text("compiled") + pcache = src_lib / ".pytest_cache" + pcache.mkdir() + (pcache / "v").write_text("cache") + (src_lib / "test_mode_engine.py").write_text("def test_x(): pass") + + hooks_dir = tmp_path / "target_hooks" + target = hooks_dir / "codingbuddy-mode-detect.py" + session_start._install_hook_with_lib(fake_hook_source, hooks_dir, target) + + target_lib = hooks_dir / "lib" + assert (target_lib / "mode_engine.py").is_file() # real module copied + assert not (target_lib / "__pycache__").exists() + assert not (target_lib / ".pytest_cache").exists() + assert not list(target_lib.glob("*.pyc")) + assert not list(target_lib.glob("test_*.py")) + + def test_renames_source_to_target_filename( + self, tmp_path, fake_hook_source + ): + """``user-prompt-submit.py`` source must land at HOOK_FILENAME target. + + The hook is renamed at install time so settings.json points at + the canonical ``codingbuddy-mode-detect.py`` regardless of the + plugin's source filename. + """ + hooks_dir = tmp_path / "target_hooks" + target = hooks_dir / "codingbuddy-mode-detect.py" + session_start._install_hook_with_lib(fake_hook_source, hooks_dir, target) + + assert target.exists(), "rename target must exist" + assert (target.stat().st_mode & 0o777) == 0o755 + assert not (hooks_dir / "user-prompt-submit.py").exists(), ( + "source filename must NOT linger after rename" + ) + + def test_idempotent_double_invocation(self, tmp_path, fake_hook_source): + """Two consecutive installs leave the same target state.""" + hooks_dir = tmp_path / "target_hooks" + target = hooks_dir / "codingbuddy-mode-detect.py" + + session_start._install_hook_with_lib(fake_hook_source, hooks_dir, target) + first = sorted(p.name for p in (hooks_dir / "lib").iterdir()) + first_target_exists = target.exists() + first_source_lingers = (hooks_dir / "user-prompt-submit.py").exists() + + session_start._install_hook_with_lib(fake_hook_source, hooks_dir, target) + second = sorted(p.name for p in (hooks_dir / "lib").iterdir()) + + assert first == second + assert first_target_exists is True + assert first_source_lingers is False + assert target.exists() + assert not (hooks_dir / "user-prompt-submit.py").exists() From cdc63e8e8ebf27a048be0819cfe96f5d74f3e91e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:00:43 +0900 Subject: [PATCH 04/10] feat(health): add check_hud_installation diagnostic (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hooks/lib/health_check.py | 105 +++++++++++++++- .../tests/test_health_check.py | 112 +++++++++++++++++- 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/packages/claude-code-plugin/hooks/lib/health_check.py b/packages/claude-code-plugin/hooks/lib/health_check.py index 18f55761..5ffc8ae8 100644 --- a/packages/claude-code-plugin/hooks/lib/health_check.py +++ b/packages/claude-code-plugin/hooks/lib/health_check.py @@ -246,11 +246,113 @@ def check_standalone_readiness(self) -> Dict[str, str]: ) return _result("standalone_readiness", "WARN", f"Standalone not ready: {', '.join(issues)}") + # ------------------------------------------------------------------ + # Check 11: HUD asset installation (#1490 prevention) + # ------------------------------------------------------------------ + def check_hud_installation(self) -> Dict[str, str]: + """Verify HUD asset installation integrity. + + Detects the v5.6.0/v5.6.1 failure mode where ``~/.claude/hud/lib`` + is missing or stale and the statusLine renders only the + fallback ``◕‿◕ CodingBuddy`` face. + + Performs three layers of verification: + 1. Script presence at ``~/.claude/hud/codingbuddy-hud.py`` + 2. Lib directory presence + the seven critical modules: + ``hud_buddy``, ``hud_state``, ``hud_helpers``, + ``tiny_actor_presets``, ``hud_version``, ``hud_rate_limits``, + ``hud_layout`` + 3. A subprocess render smoke test that catches the case where + everything looks present but imports still fail at runtime + (e.g. permission issues, partial copy) + """ + import subprocess + + hud_dir = os.path.join(self._claude_dir, "hud") + script = os.path.join(hud_dir, "codingbuddy-hud.py") + lib = os.path.join(hud_dir, "lib") + stamp = os.path.join(hud_dir, ".version") + + if not os.path.isfile(script): + return _result( + "hud_installation", + "FAIL", + "HUD script missing at ~/.claude/hud/codingbuddy-hud.py", + ) + + if not os.path.isdir(lib): + return _result( + "hud_installation", + "FAIL", + "HUD lib/ directory missing — statusLine renders fallback only", + ) + + required_modules = [ + "hud_buddy.py", + "hud_state.py", + "hud_helpers.py", + "tiny_actor_presets.py", + "hud_version.py", + "hud_rate_limits.py", + "hud_layout.py", + ] + missing = [ + m for m in required_modules if not os.path.isfile(os.path.join(lib, m)) + ] + if missing: + return _result( + "hud_installation", + "FAIL", + f"HUD lib missing modules: {', '.join(missing)}", + ) + + # Subprocess render smoke — catches runtime import failures. + try: + r = subprocess.run( + ["python3", script], + input='{"session_id":"healthcheck","model":{"display_name":"Test"}}', + capture_output=True, + text=True, + timeout=5, + ) + if r.stdout.strip() == "◕‿◕ CodingBuddy": + return _result( + "hud_installation", + "FAIL", + "HUD smoke test produced fallback face — lib import failing at runtime", + ) + except subprocess.TimeoutExpired: + return _result( + "hud_installation", + "WARN", + "HUD smoke test timed out (5s)", + ) + except Exception as e: + return _result( + "hud_installation", + "WARN", + f"HUD smoke test crashed: {e}", + ) + + version_msg = "" + if os.path.isfile(stamp): + try: + with open(stamp, "r", encoding="utf-8") as f: + version_msg = f" (v{f.read().strip()})" + except OSError: + pass + + return _result( + "hud_installation", + "PASS", + f"HUD assets installed and rendering full status line{version_msg}", + ) + # ------------------------------------------------------------------ # Aggregate # ------------------------------------------------------------------ def run_all(self) -> List[Dict[str, str]]: - """Run all 10 diagnostic checks and return results.""" + """Run all 11 diagnostic checks and return results.""" return [ self.check_hooks_json(), self.check_hook_files(), @@ -262,6 +364,7 @@ def run_all(self) -> List[Dict[str, str]]: self.check_mcp_connection(), self.check_runtime_mode(), self.check_standalone_readiness(), + self.check_hud_installation(), ] @staticmethod diff --git a/packages/claude-code-plugin/tests/test_health_check.py b/packages/claude-code-plugin/tests/test_health_check.py index 54c6124c..e97cb277 100644 --- a/packages/claude-code-plugin/tests/test_health_check.py +++ b/packages/claude-code-plugin/tests/test_health_check.py @@ -310,10 +310,10 @@ def test_missing_ai_rules_passes_with_fallback(self, env): class TestRunAll: """run_all() returns all 10 check results.""" - def test_returns_10_results(self, env): + def test_returns_11_results(self, env): checker = _make_checker(env) results = checker.run_all() - assert len(results) == 10 + assert len(results) == 11 # bumped from 10 in v5.6.2 (#1490) def test_each_check_has_required_keys(self, env): checker = _make_checker(env) @@ -342,3 +342,111 @@ def test_format_report_shows_failures(self, env): results = checker.run_all() report = checker.format_report(results) assert "FAIL" in report + + +# ============================================================================ +# v5.6.2 (#1490) — check_hud_installation diagnostic +# ============================================================================ + + +HUD_REQUIRED_LIB_MODULES = [ + "hud_buddy.py", + "hud_state.py", + "hud_helpers.py", + "tiny_actor_presets.py", + "hud_version.py", + "hud_rate_limits.py", + "hud_layout.py", +] + + +def _populate_minimal_hud(home: "os.PathLike", lib_modules=None) -> None: + """Populate ~/.claude/hud with a stub script and (optionally) lib stubs. + + The stub script always prints the fallback face so the smoke test + has predictable output. Pass lib_modules=None to omit the lib dir. + """ + from pathlib import Path + h = Path(home) / ".claude" / "hud" + h.mkdir(parents=True, exist_ok=True) + (h / "codingbuddy-hud.py").write_text( + "#!/usr/bin/env python3\nprint('\u25d5\u203f\u25d5 CodingBuddy')\n" + ) + os.chmod(str(h / "codingbuddy-hud.py"), 0o755) + if lib_modules is not None: + lib = h / "lib" + lib.mkdir(exist_ok=True) + for name in lib_modules: + (lib / name).write_text(f"# {name} stub") + + +class TestCheckHudInstallation: + """Check 11: HUD asset installation integrity (#1490).""" + + def test_fail_when_script_missing(self, env): + checker = _make_checker(env) + # env fixture creates ~/.claude but no hud/ dir + result = checker.check_hud_installation() + assert result["status"] == "FAIL" + assert "script missing" in result["message"].lower() + + def test_fail_when_lib_directory_missing(self, env): + _populate_minimal_hud(env, lib_modules=None) # script only + checker = _make_checker(env) + result = checker.check_hud_installation() + assert result["status"] == "FAIL" + assert "lib/" in result["message"] or "lib" in result["message"].lower() + + def test_fail_when_required_modules_missing(self, env): + # Create lib but only with a subset of modules + _populate_minimal_hud(env, lib_modules=["hud_buddy.py", "hud_state.py"]) + checker = _make_checker(env) + result = checker.check_hud_installation() + assert result["status"] == "FAIL" + assert "missing modules" in result["message"] + # The specific missing names must be reported + assert "tiny_actor_presets.py" in result["message"] + + def test_fail_when_smoke_test_returns_fallback(self, env): + # Populate everything but stub script always prints fallback + _populate_minimal_hud(env, lib_modules=HUD_REQUIRED_LIB_MODULES) + checker = _make_checker(env) + result = checker.check_hud_installation() + assert result["status"] == "FAIL" + assert "fallback" in result["message"].lower() + + def test_pass_with_real_plugin_install(self, env, monkeypatch): + """End-to-end: install real HUD via _install_statusline, then check. + + This is the green-path regression gate — if check_hud_installation + ever stops returning PASS for a freshly-installed real plugin, + we know either the installer or the diagnostic regressed. + """ + from pathlib import Path + import importlib.util as importutil + + # Bootstrap session-start.py import + repo_hooks = Path(__file__).resolve().parents[1] / "hooks" + real_hud_source = repo_hooks / "codingbuddy-hud.py" + if not real_hud_source.exists(): + pytest.skip(f"real HUD source not found at {real_hud_source}") + + spec = importutil.spec_from_file_location( + "session_start_for_health", str(repo_hooks / "session-start.py") + ) + session_start = importutil.module_from_spec(spec) + spec.loader.exec_module(session_start) + + # Force the installer to use the real source + monkeypatch.setattr( + session_start, "_find_hud_source", lambda: real_hud_source + ) + settings_file = env / ".claude" / "settings.json" + session_start._install_statusline(env, settings_file) + + checker = _make_checker(env) + result = checker.check_hud_installation() + assert result["status"] == "PASS", ( + f"expected PASS, got {result}" + ) + assert "rendering full status line" in result["message"] From 5c587bed6c188a1069db85ca74955b9f36c986cb Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:01:40 +0900 Subject: [PATCH 05/10] chore(scripts): add verify-install-simulation.sh pre-release gate (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release regression script that simulates a fresh user receiving cache 5.6.x and starting Claude Code for the first time. Runs the real _install_statusline against the in-tree hooks/ source in an isolated tmpdir, then executes the installed script as a subprocess and asserts the output is the full status line — not the bare '◕‿◕ CodingBuddy' fallback face. Why this complements pytest: - The pytest E2E gate runs inside the test runner's process and fixtures. This shell script exercises the exact code path a user hits, including filesystem layout, chmod, and a fresh subprocess. - It can be run by maintainers locally before pushing release branches without needing to set up a Python venv with pytest. - It is invoked from canary CI in the next commit so PRs cannot merge with a broken installer. Exit codes: 0 — full status line rendered, all assertions passed 1 — fallback face detected or required tokens missing 2 — install crashed before render Local verification at commit time: $ bash packages/claude-code-plugin/scripts/verify-install-simulation.sh [verify-install-simulation] PASS: full status line rendered exit=0 Output: '◕‿◕ CB v5.6.1 | Ready 🟢 | $0.42🔥$0.21/m | 2m | 0% | Opus 4.6' Refs #1490 --- .../scripts/verify-install-simulation.sh | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100755 packages/claude-code-plugin/scripts/verify-install-simulation.sh diff --git a/packages/claude-code-plugin/scripts/verify-install-simulation.sh b/packages/claude-code-plugin/scripts/verify-install-simulation.sh new file mode 100755 index 00000000..dcf1af43 --- /dev/null +++ b/packages/claude-code-plugin/scripts/verify-install-simulation.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# verify-install-simulation.sh — pre-release regression gate for #1490 +# +# Simulates a fresh user receiving cache 5.6.x and starting Claude Code +# for the first time. Runs _install_statusline against the real plugin +# hooks/ directory in an isolated tmpdir, then executes the installed +# script as a real subprocess and asserts the output is the full status +# line — not the bare '◕‿◕ CodingBuddy' fallback face. +# +# This script complements pytest unit/E2E tests by exercising the +# exact code path a user hits (including the file system, chmod, and +# subprocess invocation). Run it manually before pushing release +# branches and from CI on every PR. +# +# Exit codes: +# 0 — full status line rendered, all assertions passed +# 1 — fallback face detected or required tokens missing +# 2 — install crashed before render + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +HOOKS_DIR="$PLUGIN_ROOT/hooks" + +if [ ! -f "$HOOKS_DIR/codingbuddy-hud.py" ]; then + echo "[verify-install-simulation] FAIL: HUD source not found at $HOOKS_DIR/codingbuddy-hud.py" >&2 + exit 2 +fi + +TMPHOME="$(mktemp -d)" +trap 'rm -rf "$TMPHOME"' EXIT + +echo "[verify-install-simulation] simulating user install in $TMPHOME" + +PLUGIN_ROOT="$PLUGIN_ROOT" HOOKS_DIR="$HOOKS_DIR" TMPHOME="$TMPHOME" \ +python3 - <<'PYEOF' +import json +import os +import subprocess +import sys +from pathlib import Path + +plugin_root = os.environ["PLUGIN_ROOT"] +hooks_dir = os.environ["HOOKS_DIR"] +tmphome = os.environ["TMPHOME"] + +# Make session-start.py importable (hyphenated filename) +sys.path.insert(0, hooks_dir) +import importlib.util as importutil +spec = importutil.spec_from_file_location( + "session_start", os.path.join(hooks_dir, "session-start.py") +) +session_start = importutil.module_from_spec(spec) +spec.loader.exec_module(session_start) + +# Force the installer to use the in-tree source rather than any cached +# copy on the developer's machine. This guarantees the script under +# test is exactly what's about to ship. +hud_source = Path(hooks_dir) / "codingbuddy-hud.py" +session_start._find_hud_source = lambda: hud_source + +home = Path(tmphome) +settings_file = home / ".claude" / "settings.json" +settings_file.parent.mkdir(parents=True, exist_ok=True) +settings_file.write_text("{}") + +# Run the installer the same way session-start hook would +try: + session_start._install_statusline(home, settings_file) +except Exception as exc: + print(f"[verify-install-simulation] FAIL: installer crashed: {exc}", file=sys.stderr) + sys.exit(2) + +installed_script = home / ".claude" / "hud" / "codingbuddy-hud.py" +installed_lib = home / ".claude" / "hud" / "lib" + +if not installed_script.exists(): + print("[verify-install-simulation] FAIL: script not installed", file=sys.stderr) + sys.exit(2) +if not installed_lib.is_dir(): + print("[verify-install-simulation] FAIL: lib/ not synced", file=sys.stderr) + sys.exit(1) + +# Verify the 12 critical modules are present +required = [ + "hud_buddy.py", "hud_cache_savings.py", "hud_context_bar.py", + "hud_helpers.py", "hud_layout.py", "hud_rainbow.py", + "hud_rate_limits.py", "hud_session.py", "hud_state.py", + "hud_velocity.py", "hud_version.py", "tiny_actor_presets.py", +] +missing = [m for m in required if not (installed_lib / m).is_file()] +if missing: + print( + f"[verify-install-simulation] FAIL: missing lib modules: {missing}", + file=sys.stderr, + ) + sys.exit(1) + +# Render via subprocess — exactly how Claude Code invokes the statusLine +payload = json.dumps({ + "session_id": "verify-install-simulation", + "model": {"display_name": "Opus 4.6"}, + "cost": {"total_cost_usd": 0.42, "total_duration_ms": 120000}, +}) +result = subprocess.run( + ["python3", str(installed_script)], + input=payload, + capture_output=True, + text=True, + timeout=10, +) +out = result.stdout +print(f"[verify-install-simulation] stdout={out!r}") +print(f"[verify-install-simulation] stderr={result.stderr!r}") + +if result.returncode != 0: + print( + f"[verify-install-simulation] FAIL: render exited {result.returncode}", + file=sys.stderr, + ) + sys.exit(1) + +if out.strip() == "\u25d5\u203f\u25d5 CodingBuddy": + print( + "[verify-install-simulation] FAIL: fallback face only — " + "installer did not produce a working HUD (#1490 regression)", + file=sys.stderr, + ) + sys.exit(1) + +required_tokens = ["CB v", "Opus 4.6", "$0.42"] +for token in required_tokens: + if token not in out: + print( + f"[verify-install-simulation] FAIL: missing token {token!r} in stdout", + file=sys.stderr, + ) + sys.exit(1) + +print("[verify-install-simulation] PASS: full status line rendered") +PYEOF From 1fc7519a91e8261b77a092e838e9d47eb15ff71e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:03:48 +0900 Subject: [PATCH 06/10] ci: add HUD installer regression gate to canary + e2e workflows (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the v5.6.2 regression gate to two CI workflows so a broken installer cannot reach canary or release: 1. canary.yml — new ``plugin-hooks-tests`` job (Python pytest only, no node/yarn build needed) blocks ``publish-canary`` via ``needs``. Triggered on master/stag-** push, so master merges are gated before any canary npm publish. 2. e2e-plugin.yml — adds the same pytest invocation + simulation script step to the existing ``e2e-plugin-hooks`` job, and extends ``paths`` to include ``packages/claude-code-plugin/tests/**`` and ``scripts/verify-install-simulation.sh`` so PR-level changes trigger the gate. e2e-plugin runs on non-master branches, so PRs see the gate before merge. Together the two jobs cover both PR review (e2e-plugin) and post-merge canary publish (canary), so the v5.6.0/v5.6.1 failure mode (installer ships without lib sync, all users see the fallback face) cannot recur silently. Test invocations cover all five v5.6.2 hotfix test files: - tests/test_atomic_sync_with_lib.py (9 tests) - tests/test_session_start_hud.py (20 tests) - tests/test_install_hook_with_lib.py (4 tests) - tests/test_health_check.py (35 tests) - scripts/verify-install-simulation.sh (subprocess render gate) actions/setup-python pin reused from existing e2e-plugin.yml entry (SHA a26af69be951a213d495a4c3e4e4022e16d87065 = v5.6.0). Refs #1490 --- .github/workflows/canary.yml | 34 ++++++++++++++++++++++++++++++++ .github/workflows/e2e-plugin.yml | 15 ++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 5fdbc5f1..17973d8c 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -21,7 +21,41 @@ permissions: contents: read jobs: + plugin-hooks-tests: + # Regression gate for #1490 — HUD installer must sync hooks/lib + # alongside the script. This job runs the Python pytest suites for + # the plugin hooks (no node/yarn build needed) so a broken installer + # cannot ship to canary or release. + if: github.repository == 'JeremyDev87/codingbuddy' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + + - name: Install pytest + run: python3 -m pip install --quiet pytest + + - name: Run plugin hook test suites (#1490 regression gate) + working-directory: packages/claude-code-plugin + run: | + python3 -m pytest \ + tests/test_atomic_sync_with_lib.py \ + tests/test_session_start_hud.py \ + tests/test_install_hook_with_lib.py \ + tests/test_health_check.py \ + -v --tb=short + + - name: HUD install simulation (#1490 end-to-end gate) + run: bash packages/claude-code-plugin/scripts/verify-install-simulation.sh + publish-canary: + needs: plugin-hooks-tests if: github.repository == 'JeremyDev87/codingbuddy' runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.github/workflows/e2e-plugin.yml b/.github/workflows/e2e-plugin.yml index 5a1b4740..70626b0a 100644 --- a/.github/workflows/e2e-plugin.yml +++ b/.github/workflows/e2e-plugin.yml @@ -7,6 +7,8 @@ on: - stag-** paths: - 'packages/claude-code-plugin/hooks/**' + - 'packages/claude-code-plugin/tests/**' + - 'packages/claude-code-plugin/scripts/verify-install-simulation.sh' - 'tests/e2e/plugin-hooks/**' - .github/workflows/e2e-plugin.yml @@ -50,6 +52,19 @@ jobs: - name: Run E2E plugin hook tests run: python3 -m pytest tests/e2e/plugin-hooks/ -v --timeout=30 --tb=short + - name: Plugin hook unit/E2E regression suites (#1490) + working-directory: packages/claude-code-plugin + run: | + python3 -m pytest \ + tests/test_atomic_sync_with_lib.py \ + tests/test_session_start_hud.py \ + tests/test_install_hook_with_lib.py \ + tests/test_health_check.py \ + -v --tb=short + + - name: HUD install simulation (#1490 end-to-end gate) + run: bash packages/claude-code-plugin/scripts/verify-install-simulation.sh + e2e-plugin-docker: if: github.repository == 'JeremyDev87/codingbuddy' runs-on: ubuntu-latest From 2f85d9a49fbf0e58b698e90e240d1275cef1db42 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:06:06 +0900 Subject: [PATCH 07/10] chore(release): prepare v5.6.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the workspace version from 5.6.1 to 5.6.2 and publish the CHANGELOG entry for the HUD installer hotfix. v5.6.2 closes the v5.6.0 / v5.6.1 HUD statusLine installer regression (#1490). Every user who upgraded to 5.6.0 or 5.6.1 saw only the fallback face '◕‿◕ CodingBuddy' instead of the full Wave 1/2/3 status line, because the installer never copied the hooks/lib/ modules the script depends on. v5.6.2 routes both the HUD installer and the UserPromptSubmit hook installer through a new _atomic_sync_with_lib helper that copies the script and atomically replaces the entire lib/ directory (rmtree + copytree), guaranteeing renamed/removed modules from prior plugin versions cannot linger. Bump surface: - apps/mcp-server/package.json, src/shared/version.ts - packages/rules/package.json - packages/claude-code-plugin/package.json (+ peerDependencies), .claude-plugin/plugin.json, README.md - .claude-plugin/marketplace.json - yarn.lock - CHANGELOG.md (new [5.6.2] section) Refs #1490 --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 28 +++++++++++++++++++ apps/mcp-server/package.json | 2 +- apps/mcp-server/src/shared/version.ts | 2 +- .../.claude-plugin/plugin.json | 2 +- packages/claude-code-plugin/README.md | 2 +- packages/claude-code-plugin/package.json | 4 +-- packages/rules/package.json | 2 +- yarn.lock | 2 +- 9 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 33a3e6e1..30a8fde7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "codingbuddy", "source": "./packages/claude-code-plugin", "description": "PLAN/ACT/EVAL workflow, specialist agents, and reusable skills for systematic TDD development", - "version": "5.6.1", + "version": "5.6.2", "category": "development" } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index c25499cf..a1fcfac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.6.2] - 2026-04-12 + +Critical hotfix for the v5.6.0 / v5.6.1 HUD statusLine installer +regression. Every user who upgraded to v5.6.0 or v5.6.1 saw only the +fallback face `◕‿◕ CodingBuddy` instead of the full Wave 1/2/3 status +line, regardless of how many statusbar features had shipped, because +the installer never copied the `hooks/lib/` modules the script +depends on. + +### Fixed +- **HUD installer never synced `hooks/lib/`** ([#1490](https://github.com/JeremyDev87/codingbuddy/issues/1490)) — `_install_statusline` only ran `shutil.copy` on `codingbuddy-hud.py` and never touched the sibling `lib/` directory. The v5.6.0 refactor (`b0fb332`) extracted 11 `hud_*.py` modules + `tiny_actor_presets.py` to `hooks/lib/`, so every upgraded user ended up with `~/.claude/hud/codingbuddy-hud.py` present and `~/.claude/hud/lib/` missing. The script's `try: from hud_buddy import BUDDY_FACE` failed on the missing module and the outer `try/except` printed only the bare fallback `◕‿◕ CodingBuddy`. Every Wave 1-A version, Wave 1-B heal, Wave 1-C rate limit icons, Wave 1-D adaptive layout, Wave 2-A breathing face, Wave 2-B velocity, Wave 2-C cache savings, Wave 2-D rainbow, Wave 2-E smart context bar, and Wave 3 / 3b integration was therefore invisible. Fixed by routing the install through a new `_atomic_sync_with_lib` helper that copies the script and atomically replaces the entire `lib/` directory (rmtree + copytree). +- **Sister bug in `_install_hook_with_lib`** — the UserPromptSubmit hook installer used `shutil.copytree(dirs_exist_ok=True)`, which writes new files but never removes files that existed before but are gone now. A renamed module from a prior version would have lingered in `~/.claude/hooks/lib/` and could be imported first by Python's import system. Now also routes through `_atomic_sync_with_lib` so renamed/removed modules are purged on every sync. + +### Added +- **`_atomic_sync_with_lib(source_script, target_dir, extra_ignore)`** — canonical install primitive used by both `_install_statusline` and `_install_hook_with_lib`. Performs an atomic `rmtree + copytree` on the lib directory and excludes `__pycache__`, `*.pyc`, `*.pyo`, `.pytest_cache`, `test_*.py`, `*.egg-info` so the runtime `sys.path` stays clean. Optional `CODINGBUDDY_HUD_DEBUG=1` env var surfaces install errors on stderr (default still silent so session start is never blocked). +- **`~/.claude/hud/.version` stamp** — `_install_statusline` now writes the plugin version it just installed so `health_check.check_hud_installation` can detect drift and the user can confirm which release they are running. +- **`HealthChecker.check_hud_installation` (check #11)** — new diagnostic verifies `~/.claude/hud/codingbuddy-hud.py` exists, `~/.claude/hud/lib/` contains the seven critical modules, and a subprocess render smoke test does not return the fallback face. Detects all known failure modes of #1490 from the user's existing health-check workflow. +- **`scripts/verify-install-simulation.sh`** — pre-release shell gate that simulates a fresh user receiving cache 5.6.x and starting Claude Code. Runs `_install_statusline` against the in-tree source in an isolated tmpdir, then executes the installed script as a real subprocess and asserts the output is the full status line. Run locally before pushing release branches; invoked from CI in `e2e-plugin.yml` and `canary.yml`. + +### Test Coverage +- **9 new tests** in `tests/test_atomic_sync_with_lib.py` covering script copy, executable bit, lib copy, no-lib silent skip, ignore patterns, stale module replacement, idempotent re-invocation, and `extra_ignore` arg. +- **14 new tests** in `tests/test_session_start_hud.py`: + - 8 unit cases (`TestSyncHudAssets`) — copies all 12 required modules, excludes pollutants, replaces stale modules, idempotent, writes version stamp, gracefully skips when lib absent, preserves settings.json update. + - 4 parametrized E2E cases (`TestHudInstallE2ERegressionGate`) — runs the installed script as a real subprocess across `clean`, `partial`, `stale`, and `fresh` starting states and asserts the output is **not** `◕‿◕ CodingBuddy`. This is the single regression gate that would have caught the v5.6.0 / v5.6.1 ship. +- **4 new tests** in `tests/test_install_hook_with_lib.py` — sister-bug regression gate for `_install_hook_with_lib` (stale module replacement, ignore patterns, source-name rename, idempotency). +- **5 new tests** in `tests/test_health_check.py::TestCheckHudInstallation` — script missing, lib missing, modules missing, smoke test fallback detection, end-to-end PASS with real `_install_statusline` invocation. +- **CI gates** — both `e2e-plugin.yml` (PR-level) and `canary.yml` (post-merge) now run all four pytest suites and `verify-install-simulation.sh` so a broken installer cannot reach release. + ## [5.6.1] - 2026-04-11 Hotfix closing the remaining gaps in the v5.6.0 HUD Statusbar Wave cycle: diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index d068ffe7..29c9f14f 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "codingbuddy", - "version": "5.6.1", + "version": "5.6.2", "description": "Multi-AI Rules MCP Server - One source of truth for AI coding rules across all AI assistants", "author": "JeremyDev87", "license": "MIT", diff --git a/apps/mcp-server/src/shared/version.ts b/apps/mcp-server/src/shared/version.ts index 42eb0212..faca409c 100644 --- a/apps/mcp-server/src/shared/version.ts +++ b/apps/mcp-server/src/shared/version.ts @@ -2,4 +2,4 @@ * Single source of truth for the runtime package version. * Updated automatically by scripts/bump-version.sh on each release. */ -export const VERSION = '5.6.1'; +export const VERSION = '5.6.2'; diff --git a/packages/claude-code-plugin/.claude-plugin/plugin.json b/packages/claude-code-plugin/.claude-plugin/plugin.json index 39594d35..dbc6785e 100644 --- a/packages/claude-code-plugin/.claude-plugin/plugin.json +++ b/packages/claude-code-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codingbuddy", - "version": "5.6.1", + "version": "5.6.2", "description": "PLAN/ACT/EVAL workflow with auto-detection, specialist agents, and reusable skills for systematic TDD development", "author": { "name": "JeremyDev87", diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index a511ee6f..1016ac2d 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -2,7 +2,7 @@ # CodingBuddy Claude Code Plugin -> Version 5.6.1 +> Version 5.6.2 Multi-AI Rules for consistent coding practices - PLAN/ACT/EVAL workflow, specialist agents, and reusable skills for systematic development. diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index 6bfef13a..4be09f33 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -1,6 +1,6 @@ { "name": "codingbuddy-claude-plugin", - "version": "5.6.1", + "version": "5.6.2", "description": "Claude Code Plugin for CodingBuddy - PLAN/ACT/EVAL workflow, specialist agents, and reusable skills", "author": "JeremyDev87", "license": "MIT", @@ -53,7 +53,7 @@ "test:coverage": "vitest run --coverage" }, "peerDependencies": { - "codingbuddy": "^5.6.1" + "codingbuddy": "^5.6.2" }, "peerDependenciesMeta": { "codingbuddy": { diff --git a/packages/rules/package.json b/packages/rules/package.json index 8546b972..70dd17a0 100644 --- a/packages/rules/package.json +++ b/packages/rules/package.json @@ -1,6 +1,6 @@ { "name": "codingbuddy-rules", - "version": "5.6.1", + "version": "5.6.2", "description": "AI coding rules for consistent practices across AI assistants", "main": "index.js", "types": "index.d.ts", diff --git a/yarn.lock b/yarn.lock index 6601c5c3..f4be90e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5735,7 +5735,7 @@ __metadata: typescript: "npm:5.9.3" vitest: "npm:4.0.17" peerDependencies: - codingbuddy: ^5.6.1 + codingbuddy: ^5.6.2 peerDependenciesMeta: codingbuddy: optional: true From a8afd9a7cf65b8b848a6af61403d0c770e028f8c Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:09:43 +0900 Subject: [PATCH 08/10] chore(scripts): isolate HOME and assert exact version in verify-install-simulation (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes uncovered while running the script after the v5.6.2 bump: 1. **HOME isolation** — the subprocess that ran the installed HUD inherited the developer's real HOME, so `read_installed_version` tier-1 lookup picked up `~/.claude/plugins/installed_plugins.json` from the developer machine (5.6.1) instead of the version we are about to ship. Output read `CB v5.6.1` even though plugin.json was already 5.6.2. The script now passes an explicit `env=` to subprocess.run with HOME pointing at the tmpdir. 2. **Fake installed_plugins.json** — Claude Code writes `~/.claude/plugins/installed_plugins.json` after every plugin install/update, and `hud_version.get_fresh_version` reads it as tier 1. The simulation now writes a stub manifest in the isolated tmpdir so the installed HUD's version-resolution path matches what a real user will hit on first session-start after `/plugin update`. 3. **Exact version assertion** — the script now reads the version from `plugin.json`, asserts `CB v` appears in the rendered status line, and verifies `~/.claude/hud/.version` contains the same value. This auto-tracks bump-version.sh and removes the previous risk of shipping a release where the version segment renders empty without anyone noticing. Local verification: $ bash packages/claude-code-plugin/scripts/verify-install-simulation.sh [verify-install-simulation] stdout='◕‿◕ CB v5.6.2 | Ready 🟢 | $0.42🔥$0.21/m | 2m | 0% | Opus 4.6' [verify-install-simulation] PASS: full status line rendered (v5.6.2) exit=0 Refs #1490 --- .../namespace-manifest.json | 2 +- .../scripts/verify-install-simulation.sh | 62 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/claude-code-plugin/namespace-manifest.json b/packages/claude-code-plugin/namespace-manifest.json index 15074567..03f53fd6 100644 --- a/packages/claude-code-plugin/namespace-manifest.json +++ b/packages/claude-code-plugin/namespace-manifest.json @@ -34,5 +34,5 @@ "namespaced": "codingbuddy:plan" } ], - "generatedAt": "2026-04-11T16:03:33.849Z" + "generatedAt": "2026-04-11T17:16:02.224Z" } diff --git a/packages/claude-code-plugin/scripts/verify-install-simulation.sh b/packages/claude-code-plugin/scripts/verify-install-simulation.sh index dcf1af43..a9bd923d 100755 --- a/packages/claude-code-plugin/scripts/verify-install-simulation.sh +++ b/packages/claude-code-plugin/scripts/verify-install-simulation.sh @@ -65,6 +65,32 @@ settings_file = home / ".claude" / "settings.json" settings_file.parent.mkdir(parents=True, exist_ok=True) settings_file.write_text("{}") +plugin_root_path = Path(plugin_root) + +# Mimic Claude Code's plugin manifest so the installed HUD's tier-1 +# version lookup (read_installed_version → ~/.claude/plugins/installed_plugins.json) +# resolves to the version we are about to ship. Without this the +# isolated tmpdir has no installed_plugins.json and the version +# segment renders empty. +plugin_json = plugin_root_path / ".claude-plugin" / "plugin.json" +import json as _json +expected_version = _json.loads(plugin_json.read_text())["version"] +plugins_dir = home / ".claude" / "plugins" +plugins_dir.mkdir(parents=True, exist_ok=True) +(plugins_dir / "installed_plugins.json").write_text( + _json.dumps({ + "plugins": { + "codingbuddy@jeremydev87": [ + { + "scope": "user", + "installPath": str(plugin_root_path), + "version": expected_version, + } + ] + } + }) +) + # Run the installer the same way session-start hook would try: session_start._install_statusline(home, settings_file) @@ -103,12 +129,24 @@ payload = json.dumps({ "model": {"display_name": "Opus 4.6"}, "cost": {"total_cost_usd": 0.42, "total_duration_ms": 120000}, }) +# Isolate HOME so the installed script reads the tmpdir installation +# instead of leaking the developer's real ~/.claude/plugins/installed_plugins.json. +# Without this, hud_version.get_fresh_version's tier-1 lookup picks up +# the real machine's plugin version and the assertion below cannot +# verify that the v5.6.2 prep actually flows through the install path. +isolated_env = { + "HOME": str(home), + "PATH": os.environ.get("PATH", ""), + "LANG": os.environ.get("LANG", ""), + "LC_ALL": os.environ.get("LC_ALL", ""), +} result = subprocess.run( ["python3", str(installed_script)], input=payload, capture_output=True, text=True, timeout=10, + env=isolated_env, ) out = result.stdout print(f"[verify-install-simulation] stdout={out!r}") @@ -129,7 +167,13 @@ if out.strip() == "\u25d5\u203f\u25d5 CodingBuddy": ) sys.exit(1) -required_tokens = ["CB v", "Opus 4.6", "$0.42"] +# expected_version was resolved earlier from plugin.json so the +# assertion auto-tracks bump-version.sh. +required_tokens = [ + f"CB v{expected_version}", # version exactly matches plugin.json + "Opus 4.6", + "$0.42", +] for token in required_tokens: if token not in out: print( @@ -138,5 +182,19 @@ for token in required_tokens: ) sys.exit(1) -print("[verify-install-simulation] PASS: full status line rendered") +# Verify version stamp file +stamp = home / ".claude" / "hud" / ".version" +if not stamp.exists(): + print("[verify-install-simulation] FAIL: .version stamp not written", file=sys.stderr) + sys.exit(1) +stamp_value = stamp.read_text(encoding="utf-8").strip() +if stamp_value != expected_version: + print( + f"[verify-install-simulation] FAIL: stamp {stamp_value!r} != " + f"plugin.json version {expected_version!r}", + file=sys.stderr, + ) + sys.exit(1) + +print(f"[verify-install-simulation] PASS: full status line rendered (v{expected_version})") PYEOF From 903aded9896ff454b9970411197931d7c7e1044f Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:23:40 +0900 Subject: [PATCH 09/10] fix(tests,health): isolate HOME in E2E gates + detect empty version segment (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Critical issues surfaced in CI (e2e-plugin-hooks/3.11) that the local pre-push check missed because the developer/runner HOME leaked into pytest subprocess invocations: 1. **TestHudInstallE2ERegressionGate all 4 scenarios FAILED on CI** with `assert "CB v" in out` because ``hud_version.get_fresh_version``'s tier-1 lookup reads ``~/.claude/plugins/installed_plugins.json`` and CI has none. The render therefore produced ``CB | Ready 🟢 | ...`` — a silent half-broken state where every module imports successfully but the version segment is empty. Locally the same test passed only because the developer's real ``~/.claude/plugins/installed_plugins.json`` (v5.6.1) leaked into the subprocess. Fix: the test now writes a stub ``installed_plugins.json`` into the tmpdir fake home and invokes the subprocess with ``env={"HOME": str(home), ...}`` so all three version-resolution tiers (tier 1 installed_plugins.json, tier 2 plugin.json, tier 3 hud_state) see the isolated environment. Assertion is strengthened to ``CB v`` with ``expected_version`` read from ``plugin.json`` so future bump-version.sh runs auto-gate. A ``.version`` stamp assertion is also added per scenario. The simulation shell script (commit 8) was already hardened this way; this commit brings the pytest E2E suite to parity so CI and local runs agree. 2. **check_hud_installation smoke test did not detect empty version** — it only compared against the literal fallback face, so the ``CB | Ready ...`` half-broken state returned PASS even though the user would see a status line with a missing version segment. Fix: ``check_hud_installation`` now detects ``"CB "`` without ``"CB v"`` in the rendered output and returns FAIL with a clear message ("HUD rendered empty version segment — hud_version fallback chain broken"). The subprocess call also pins ``HOME=self._home_dir`` so the diagnostic honours the HealthChecker's configured home directory rather than leaking the CI runner's real home. A new regression test ``test_fail_when_version_segment_is_empty`` stubs a script that prints the half-broken line and asserts the check FAILs. 3. **test_pass_with_real_plugin_install** (health_check green-path gate) now writes the same fake ``installed_plugins.json`` so the subprocess check_hud_installation invokes can resolve the plugin version inside the isolated environment. Local verification: $ python3 -m pytest tests/test_session_start_hud.py tests/test_health_check.py -v ... 56 passed in 0.90s $ bash packages/claude-code-plugin/scripts/verify-install-simulation.sh [verify-install-simulation] PASS: full status line rendered (v5.6.2) Refs #1490 --- .../hooks/lib/health_check.py | 30 +++++++++- .../tests/test_health_check.py | 55 +++++++++++++++++++ .../tests/test_session_start_hud.py | 53 +++++++++++++++++- 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/claude-code-plugin/hooks/lib/health_check.py b/packages/claude-code-plugin/hooks/lib/health_check.py index 5ffc8ae8..eaef0233 100644 --- a/packages/claude-code-plugin/hooks/lib/health_check.py +++ b/packages/claude-code-plugin/hooks/lib/health_check.py @@ -306,7 +306,20 @@ def check_hud_installation(self) -> Dict[str, str]: f"HUD lib missing modules: {', '.join(missing)}", ) - # Subprocess render smoke — catches runtime import failures. + # Subprocess render smoke — catches runtime import failures + # AND partially-rendered status lines (e.g. empty version + # segment when hud_version's 3-tier fallback all fail). + # HOME is pinned to self._home_dir so the subprocess's + # tier-1 version lookup (~/.claude/plugins/installed_plugins.json) + # resolves against the same environment the diagnostic was + # configured with, rather than leaking the CI runner's real + # home. + isolated_env = { + "HOME": self._home_dir, + "PATH": os.environ.get("PATH", ""), + "LANG": os.environ.get("LANG", ""), + "LC_ALL": os.environ.get("LC_ALL", ""), + } try: r = subprocess.run( ["python3", script], @@ -314,13 +327,26 @@ def check_hud_installation(self) -> Dict[str, str]: capture_output=True, text=True, timeout=5, + env=isolated_env, ) - if r.stdout.strip() == "◕‿◕ CodingBuddy": + rendered = r.stdout.strip() + if rendered == "◕‿◕ CodingBuddy": return _result( "hud_installation", "FAIL", "HUD smoke test produced fallback face — lib import failing at runtime", ) + # Version segment must not be empty. ``CB `` without the + # trailing ``v`` indicates all three version-resolution + # tiers (installed_plugins.json, plugin.json, hud_state) + # returned the empty string — a silent half-broken state + # that would otherwise ship unnoticed. + if "CB " in rendered and "CB v" not in rendered: + return _result( + "hud_installation", + "FAIL", + "HUD rendered empty version segment — hud_version fallback chain broken", + ) except subprocess.TimeoutExpired: return _result( "hud_installation", diff --git a/packages/claude-code-plugin/tests/test_health_check.py b/packages/claude-code-plugin/tests/test_health_check.py index e97cb277..cd818160 100644 --- a/packages/claude-code-plugin/tests/test_health_check.py +++ b/packages/claude-code-plugin/tests/test_health_check.py @@ -415,6 +415,33 @@ def test_fail_when_smoke_test_returns_fallback(self, env): assert result["status"] == "FAIL" assert "fallback" in result["message"].lower() + def test_fail_when_version_segment_is_empty(self, env): + """v5.6.2 regression gate: empty version segment must FAIL. + + Stubs a script that renders a partial status line without a + ``CB v`` prefix — mimics the v5.6.0/v5.6.1 runtime + where hud_version's 3-tier fallback all returned empty + strings and the rendered line read ``CB | Ready 🟢 | ...``. + The smoke test must detect this silent half-broken state. + """ + from pathlib import Path + h = Path(env) / ".claude" / "hud" + h.mkdir(parents=True, exist_ok=True) + (h / "codingbuddy-hud.py").write_text( + "#!/usr/bin/env python3\n" + "print('\u25d5\u203f\u25d5 CB | Ready \U0001f7e2 | Opus 4.6')\n" + ) + os.chmod(str(h / "codingbuddy-hud.py"), 0o755) + lib = h / "lib" + lib.mkdir(exist_ok=True) + for name in HUD_REQUIRED_LIB_MODULES: + (lib / name).write_text(f"# {name} stub") + + checker = _make_checker(env) + result = checker.check_hud_installation() + assert result["status"] == "FAIL" + assert "empty version segment" in result["message"].lower() + def test_pass_with_real_plugin_install(self, env, monkeypatch): """End-to-end: install real HUD via _install_statusline, then check. @@ -422,6 +449,7 @@ def test_pass_with_real_plugin_install(self, env, monkeypatch): ever stops returning PASS for a freshly-installed real plugin, we know either the installer or the diagnostic regressed. """ + import json as _json from pathlib import Path import importlib.util as importutil @@ -437,6 +465,32 @@ def test_pass_with_real_plugin_install(self, env, monkeypatch): session_start = importutil.module_from_spec(spec) spec.loader.exec_module(session_start) + # Mimic Claude Code's plugin manifest so check_hud_installation's + # subprocess (HOME=self._home_dir=env) can resolve the plugin + # version via tier-1 lookup. Without this, CI environments + # produce an empty version segment and the new version-segment + # validation correctly FAILs the check. + plugin_root = repo_hooks.parent + plugin_json = plugin_root / ".claude-plugin" / "plugin.json" + expected_version = _json.loads(plugin_json.read_text())["version"] + plugins_dir = env / ".claude" / "plugins" + plugins_dir.mkdir(parents=True, exist_ok=True) + (plugins_dir / "installed_plugins.json").write_text( + _json.dumps( + { + "plugins": { + "codingbuddy@jeremydev87": [ + { + "scope": "user", + "installPath": str(plugin_root), + "version": expected_version, + } + ] + } + } + ) + ) + # Force the installer to use the real source monkeypatch.setattr( session_start, "_find_hud_source", lambda: real_hud_source @@ -450,3 +504,4 @@ def test_pass_with_real_plugin_install(self, env, monkeypatch): f"expected PASS, got {result}" ) assert "rendering full status line" in result["message"] + assert expected_version in result["message"] diff --git a/packages/claude-code-plugin/tests/test_session_start_hud.py b/packages/claude-code-plugin/tests/test_session_start_hud.py index 4a923e97..4cc06a4c 100644 --- a/packages/claude-code-plugin/tests/test_session_start_hud.py +++ b/packages/claude-code-plugin/tests/test_session_start_hud.py @@ -362,6 +362,35 @@ def test_install_then_render_full_status_line( settings_file.parent.mkdir(parents=True) settings_file.write_text("{}") + # Mimic Claude Code's plugin manifest so the installed HUD's + # tier-1 version lookup (hud_version.get_fresh_version → + # ~/.claude/plugins/installed_plugins.json) resolves to the + # in-tree plugin version. Without this, CI environments (which + # have no prior install) leave the version segment empty and + # the `"CB v" in out` assertion below fails. This mirrors the + # behavior Claude Code performs after /plugin update on real + # user machines. + plugin_root = real_plugin_hud_source.parents[1] + plugin_json_path = plugin_root / ".claude-plugin" / "plugin.json" + expected_version = json.loads(plugin_json_path.read_text())["version"] + plugins_dir = home / ".claude" / "plugins" + plugins_dir.mkdir(parents=True, exist_ok=True) + (plugins_dir / "installed_plugins.json").write_text( + json.dumps( + { + "plugins": { + "codingbuddy@jeremydev87": [ + { + "scope": "user", + "installPath": str(plugin_root), + "version": expected_version, + } + ] + } + } + ) + ) + hud_dir = home / ".claude" / "hud" if scenario == "partial": @@ -405,7 +434,10 @@ def test_install_then_render_full_status_line( if scenario == "stale": assert not (installed_lib / "hud_obsolete_v5_5.py").exists() - # 🔴 The render gate + # 🔴 The render gate — run the installed script as a real + # subprocess with an isolated HOME so tier-1 version lookup + # reads the fake installed_plugins.json we wrote above instead + # of leaking the developer/CI runner's real home directory. stdin_payload = json.dumps( { "session_id": "regression-gate", @@ -416,12 +448,19 @@ def test_install_then_render_full_status_line( }, } ) + isolated_env = { + "HOME": str(home), + "PATH": os.environ.get("PATH", ""), + "LANG": os.environ.get("LANG", ""), + "LC_ALL": os.environ.get("LC_ALL", ""), + } result = subprocess.run( ["python3", str(installed_script)], input=stdin_payload, capture_output=True, text=True, timeout=10, + env=isolated_env, ) assert result.returncode == 0, ( f"scenario={scenario} crashed: stderr={result.stderr!r}" @@ -436,6 +475,16 @@ def test_install_then_render_full_status_line( f"{sorted(p.name for p in installed_lib.iterdir())}" ) - assert "CB v" in out, f"version segment missing: {out!r}" + # Exact version assertion — auto-tracks bump-version.sh so + # every release gates on a fully-populated version segment. + assert f"CB v{expected_version}" in out, ( + f"version segment missing/wrong: {out!r} " + f"(expected 'CB v{expected_version}')" + ) assert "Opus 4.6" in out, f"model segment missing: {out!r}" assert "$0.42" in out, f"cost segment missing: {out!r}" + + # Stamp file assertion + stamp = hud_dir / ".version" + assert stamp.exists(), f"scenario={scenario}: .version stamp missing" + assert stamp.read_text(encoding="utf-8").strip() == expected_version From 7db70bd03a0160001e82f61b838de12220f5795e Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 02:31:35 +0900 Subject: [PATCH 10/10] refactor(hooks): atomic staging-based lib swap + drop redundant chmod (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two EVAL findings from the PR #1490 review: **HIGH #1 — race window during lib sync** The previous implementation was rmtree-then-copytree: if target_lib.exists(): shutil.rmtree(target_lib) shutil.copytree(source_lib, target_lib, ...) which left ``target_lib`` missing for the full duration of the copytree (hundreds of milliseconds for a multi-megabyte lib). Any concurrent HUD subprocess that statted the installed script during that window would see ``ImportError`` and render the fallback face — ironic given this PR exists to fix exactly that symptom. Replaced with a staging/rename pattern: 1. ``copytree(source_lib, .lib.staging-, ignore=...)`` 2. ``rename(target_lib, .lib.old-)`` ← atomic syscall 3. ``rename(.lib.staging-, target_lib)`` ← atomic syscall 4. ``rmtree(.lib.old-)`` in ``finally`` The window during which ``target_lib`` does not exist now shrinks from ``O(copytree)`` to ``O(rename)`` — effectively atomic on POSIX. Concurrent installers from multiple simultaneous Claude Code sessions are safe because each uses a uuid-scoped staging dir. Rollback: if ``copytree`` raises mid-sync the except block removes the staging dir and restores ``target_lib`` from the archive, so an existing working install is never lost to a partial sync. **HIGH #2 — redundant chmod after rename in _install_hook_with_lib** ``_atomic_sync_with_lib`` already chmod's the synced script to 0o755 before ``_install_hook_with_lib`` renames it to HOOK_FILENAME, and POSIX rename preserves permission bits. The second chmod was defensive but triggered an unnecessary silent-failure path on filesystems that reject mode changes (NFS, read-only mounts). Removed with a code comment explaining why. **New regression tests (+2)**: - ``test_no_staging_leftovers_after_success`` — no stray ``.lib.staging-*`` / ``.lib.old-*`` dirs after a successful sync - ``test_rollback_preserves_old_lib_when_copytree_fails`` — monkeypatches ``shutil.copytree`` to raise OSError, then asserts the pre-existing target lib survives and no staging/archive dirs leak Local verification: 105 pytest suites pass (100 + 5 from prior commit + 2 new race-safety gates). Refs #1490 --- .../claude-code-plugin/hooks/session-start.py | 101 ++++++++++++++---- .../tests/test_atomic_sync_with_lib.py | 63 +++++++++++ 2 files changed, 142 insertions(+), 22 deletions(-) diff --git a/packages/claude-code-plugin/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index 316460d5..90715387 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -426,29 +426,51 @@ def _atomic_sync_with_lib( (#1490) where renamed/removed modules from prior plugin versions remained in the target directory and caused import failures. - Behavior: + Behavior (3-phase, near-atomic lib swap): 1. ``mkdir -p target_dir`` 2. Copy ``source_script`` to ``target_dir/`` - and ``chmod 0o755`` - 3. If ``source_script.parent / "lib"`` exists, **rmtree** any - existing ``target_dir/lib`` and then ``copytree`` the source - lib so renamed modules cannot linger. - - Why rmtree-then-copytree (and not ``dirs_exist_ok=True``): - ``dirs_exist_ok=True`` only writes; it does not remove files - that existed before but are gone now. A renamed module + and ``chmod 0o755``. + 3. If ``source_script.parent / "lib"`` exists: + a. Copy it into a staging directory ``.lib.staging-`` + b. Rename the existing ``lib`` aside to ``.lib.old-`` + c. Rename the staging directory to ``lib`` (POSIX ``rename`` + is atomic, so the window during which importers see no + lib is bounded by two ``rename`` syscalls — microseconds) + d. ``rmtree`` the archived old lib + + Why staging instead of rmtree-then-copytree: + The naive pattern ``rmtree(target_lib); copytree(source_lib, target_lib)`` + leaves the target empty for the duration of ``copytree`` (which + can be hundreds of milliseconds for a multi-megabyte lib). Any + concurrent HUD subprocess that statted the script during that + window would see import failures and render the fallback face. + The staging/rename pattern shrinks the window from ``O(copytree)`` + to ``O(rename)`` — effectively atomic. It also tolerates + concurrent installers from multiple simultaneous Claude Code + sessions because each uses a uuid-scoped staging directory. + + Why not ``dirs_exist_ok=True`` on its own: + That mode only writes; it does not remove files that existed + before but are gone now. A renamed module (e.g. ``hud_old.py`` → ``hud_new.py``) would remain in the target lib and could be imported first, causing subtle - regressions. session-start runs once per Claude Code session, - so the cost of the additional rmtree is negligible. + regressions. The staging pattern gives the same stale-safety + guarantees with atomicity on top. Args: source_script: Path to the script file to install. Its parent directory is searched for a sibling ``lib/`` to mirror. target_dir: Destination directory. Created if missing. extra_ignore: Additional ignore-pattern tuple appended to the - shared :data:`_HUD_SYNC_IGNORE_PATTERNS` list. + shared :data:`_HUD_SYNC_IGNORE_PATTERNS` list. Reserved for + future wave-specific excludes; both production callers + currently pass ``None``. Runtime lib modules are assumed + to follow the ``hud_*`` / ``tiny_actor_*`` naming + convention — do NOT add runtime helpers named + ``test_*.py`` or the ignore filter will drop them. """ + import uuid + target_dir.mkdir(parents=True, exist_ok=True) # 1. Script @@ -456,20 +478,47 @@ def _atomic_sync_with_lib( shutil.copy(source_script, target_script) target_script.chmod(0o755) - # 2. Lib (atomic replace) + # 2. Lib — staging/rename pattern for near-atomic swap source_lib = source_script.parent / "lib" - if source_lib.is_dir(): - target_lib = target_dir / "lib" - if target_lib.exists(): - shutil.rmtree(target_lib) - ignore_patterns = _HUD_SYNC_IGNORE_PATTERNS - if extra_ignore: - ignore_patterns = ignore_patterns + tuple(extra_ignore) + if not source_lib.is_dir(): + return + + target_lib = target_dir / "lib" + ignore_patterns = _HUD_SYNC_IGNORE_PATTERNS + if extra_ignore: + ignore_patterns = ignore_patterns + tuple(extra_ignore) + + suffix = uuid.uuid4().hex[:8] + staging_lib = target_dir / f".lib.staging-{suffix}" + archive_lib = target_dir / f".lib.old-{suffix}" + + try: shutil.copytree( source_lib, - target_lib, + staging_lib, ignore=shutil.ignore_patterns(*ignore_patterns), ) + # Atomic archive + promote. Each rename is a single syscall, + # so the window during which ``target_lib`` does not exist is + # bounded by one ``rename`` (~microseconds). + if target_lib.exists(): + os.rename(str(target_lib), str(archive_lib)) + os.rename(str(staging_lib), str(target_lib)) + except Exception: + # Leave target_lib in the best recoverable state: prefer the + # old lib (from archive) over nothing. Staging is always + # disposable. + if staging_lib.exists(): + shutil.rmtree(staging_lib, ignore_errors=True) + if archive_lib.exists() and not target_lib.exists(): + try: + os.rename(str(archive_lib), str(target_lib)) + except OSError: + pass + raise + finally: + if archive_lib.exists(): + shutil.rmtree(archive_lib, ignore_errors=True) def _install_hook_with_lib( @@ -484,6 +533,13 @@ def _install_hook_with_lib( that canonical name (``codingbuddy-mode-detect.py``) rather than the source name (``user-prompt-submit.py``). + Note on permission bits: ``_atomic_sync_with_lib`` has already + chmod'd the synced script to ``0o755`` before we rename it, and + POSIX ``rename`` preserves permission bits across the move. We + therefore do not re-chmod after the rename; a redundant ``chmod`` + there would trigger an unnecessary silent failure path on + filesystems that reject mode changes (NFS, read-only mounts). + Args: source_file: Path to the source hook script. hooks_dir: Target directory (e.g. ``~/.claude/hooks/``). @@ -495,7 +551,8 @@ def _install_hook_with_lib( if target_file.exists(): target_file.unlink() synced.rename(target_file) - target_file.chmod(0o755) + # No chmod here: _atomic_sync_with_lib already set 0o755 on + # ``synced`` and ``rename`` preserves mode. CODINGBUDDY_MCP_ENTRY = { diff --git a/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py b/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py index 6744c69e..0039df76 100644 --- a/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py +++ b/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py @@ -199,3 +199,66 @@ def test_extra_ignore_argument_is_honored( target_lib = target_dir / "lib" assert (target_lib / "mod_a.py").is_file() assert not (target_lib / "shared_helper.py").exists() + + def test_no_staging_leftovers_after_success( + self, fake_source_with_lib, target_dir + ): + """Staging/archive directories must be cleaned up on success. + + Regression gate for the v5.6.2 atomic-swap refactor: after a + successful sync, the parent directory must NOT contain any + stray ``.lib.staging-*`` or ``.lib.old-*`` entries, or the + next session-start would race on them. + """ + session_start._atomic_sync_with_lib(fake_source_with_lib, target_dir) + leftover_staging = list(target_dir.glob(".lib.staging-*")) + leftover_old = list(target_dir.glob(".lib.old-*")) + assert leftover_staging == [], ( + f"staging directories leaked: {leftover_staging}" + ) + assert leftover_old == [], ( + f"archive directories leaked: {leftover_old}" + ) + assert (target_dir / "lib" / "mod_a.py").is_file() + + def test_rollback_preserves_old_lib_when_copytree_fails( + self, tmp_path, monkeypatch + ): + """If copytree fails mid-sync, the existing lib must survive. + + Simulates a source lib whose copytree raises partway through + and asserts that the pre-existing target_lib is NOT lost. + Protects users from losing a working HUD install if a future + plugin ships a broken source tree or the disk fills up. + """ + src_hooks = tmp_path / "src_hooks" + src_hooks.mkdir() + (src_hooks / "script.py").write_text("# src") + src_lib = src_hooks / "lib" + src_lib.mkdir() + (src_lib / "mod_a.py").write_text("VAL_A = 1") + + target = tmp_path / "target" + target.mkdir() + target_lib = target / "lib" + target_lib.mkdir() + (target_lib / "prior_mod.py").write_text("# survivor") + + real_copytree = shutil.copytree + + def flaky_copytree(*args, **kwargs): + raise OSError("simulated disk full") + + monkeypatch.setattr(session_start.shutil, "copytree", flaky_copytree) + + with pytest.raises(OSError, match="simulated disk full"): + session_start._atomic_sync_with_lib( + src_hooks / "script.py", target + ) + + # Old lib must survive + assert target_lib.exists() + assert (target_lib / "prior_mod.py").read_text() == "# survivor" + # No leaked staging or archive dirs + assert list(target.glob(".lib.staging-*")) == [] + assert list(target.glob(".lib.old-*")) == []