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/.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 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/hooks/lib/health_check.py b/packages/claude-code-plugin/hooks/lib/health_check.py index 18f55761..eaef0233 100644 --- a/packages/claude-code-plugin/hooks/lib/health_check.py +++ b/packages/claude-code-plugin/hooks/lib/health_check.py @@ -246,11 +246,139 @@ 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 + # 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], + input='{"session_id":"healthcheck","model":{"display_name":"Test"}}', + capture_output=True, + text=True, + timeout=5, + env=isolated_env, + ) + 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", + "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 +390,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/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index 46f81992..90715387 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -397,33 +397,162 @@ def register_hook_in_settings(settings_file: Path) -> bool: return True +# 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: + """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 (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: + 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. 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. 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 + target_script = target_dir / source_script.name + shutil.copy(source_script, target_script) + target_script.chmod(0o755) + + # 2. Lib — staging/rename pattern for near-atomic swap + source_lib = source_script.parent / "lib" + 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, + 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( source_file: Path, hooks_dir: Path, target_file: Path ) -> 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/. + 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``). + + 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/). + hooks_dir: Target directory (e.g. ``~/.claude/hooks/``). target_file: Full target path for the hook script. """ - hooks_dir.mkdir(parents=True, exist_ok=True) - shutil.copy(source_file, target_file) - target_file.chmod(0o755) - - # Copy lib/ directory alongside the hook (#1102) - source_lib = source_file.parent / "lib" - if source_lib.is_dir(): - target_lib = hooks_dir / "lib" - shutil.copytree( - source_lib, - target_lib, - dirs_exist_ok=True, - ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), - ) + _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) + # No chmod here: _atomic_sync_with_lib already set 0o755 on + # ``synced`` and ``rename`` preserves mode. CODINGBUDDY_MCP_ENTRY = { @@ -548,19 +677,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/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/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/claude-code-plugin/scripts/verify-install-simulation.sh b/packages/claude-code-plugin/scripts/verify-install-simulation.sh new file mode 100755 index 00000000..a9bd923d --- /dev/null +++ b/packages/claude-code-plugin/scripts/verify-install-simulation.sh @@ -0,0 +1,200 @@ +#!/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("{}") + +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) +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}, +}) +# 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}") +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) + +# 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( + f"[verify-install-simulation] FAIL: missing token {token!r} in stdout", + file=sys.stderr, + ) + sys.exit(1) + +# 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 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..0039df76 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_atomic_sync_with_lib.py @@ -0,0 +1,264 @@ +"""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() + + 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-*")) == [] diff --git a/packages/claude-code-plugin/tests/test_health_check.py b/packages/claude-code-plugin/tests/test_health_check.py index 54c6124c..cd818160 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,166 @@ 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_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. + + 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. + """ + import json as _json + 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) + + # 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 + ) + 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"] + assert expected_version in result["message"] 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() 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..4cc06a4c 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,280 @@ 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("{}") + + # 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": + # 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 — 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", + "model": {"display_name": "Opus 4.6"}, + "cost": { + "total_cost_usd": 0.42, + "total_duration_ms": 120000, + }, + } + ) + 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}" + ) + 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())}" + ) + + # 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 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