Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/landing-page/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Next.js 16 Project Setup', () => {
});

test('Next.js 16.x is installed and locked', () => {
expect(pkg.dependencies.next).toBe('16.1.6');
expect(pkg.dependencies.next).toBe('16.2.3');
});

test('React 19.x is installed and locked', () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/landing-page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next": "16.2.3",
"next-intl": "^4.8.2",
"next-themes": "^0.4.6",
"prism-react-renderer": "^2.4.1",
Expand All @@ -56,7 +56,7 @@
"axe-core": "^4.11.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.2.3",
"happy-dom": "^20.8.8",
"jest-axe": "^10.0.0",
"madge": "^8.0.0",
Expand Down
67 changes: 49 additions & 18 deletions packages/claude-code-plugin/hooks/codingbuddy-hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import os
import sys
from datetime import datetime, timezone
from typing import Optional

# --- lib import bootstrap ---
# statusLine entry script: sys.path insertion here is intentional so
Expand All @@ -23,28 +24,40 @@
sys.path.insert(0, _LIB_DIR)

# === test_hud.py compatibility re-exports — DO NOT REMOVE without coordinated test update ===
# Defensive fallback: statusLine is a hot path invoked by Claude Code on
# every render. If any lib module is temporarily broken (e.g. mid-wave
# refactor), fall back to minimal inline implementations so the status
# bar still renders instead of crashing the Claude Code subprocess.
# Narrow the fallback to ImportError only: real logic bugs in lib modules
# (SyntaxError, NameError, AttributeError) must surface immediately instead
# of being silently swallowed by a catch-all. If a lib module fails to import
# entirely, the outer main() try/except at the bottom of this file still
# emits the minimal safe output via the BUDDY_FACE constant.
try:
from hud_buddy import BUDDY_FACE # canonical SSoT via tiny_actor_presets
except Exception: # pragma: no cover - defensive
BUDDY_FACE = "\u25d5\u203f\u25d5" # ◕‿◕
except ImportError: # pragma: no cover - defensive
BUDDY_FACE = "◕‿◕" # minimal constant for safe-output path

try:
from hud_rate_limits import format_rate_limits
except Exception: # pragma: no cover - defensive
def format_rate_limits(stdin_data: dict) -> str: # type: ignore[misc]
return ""
from hud_rate_limits import format_rate_limits # noqa: F401 re-exported for test_hud.py
except ImportError: # pragma: no cover - defensive
pass # main() catch-all handles absence

try:
from hud_version import get_fresh_version as _get_fresh_version # backcompat alias
except Exception: # pragma: no cover - defensive
def _get_fresh_version( # type: ignore[misc]
hud_state: dict, *, plugins_file: str = ""
) -> str:
return hud_state.get("version", "")
except ImportError: # pragma: no cover - defensive
pass # main() catch-all handles absence

# Wave 2-B velocity + Wave 2-C cache savings hot-path suffixes for the cost segment.
# Hoisted to module top per perf-1485 H1 so format_status_line avoids a
# sys.modules lookup on every render (~0.47μs saved per call).
try:
from hud_velocity import format_velocity_segment as _format_velocity_segment
except ImportError: # pragma: no cover - defensive
def _format_velocity_segment(stdin_data, hud_state=None): # type: ignore[misc]
return ""

try:
from hud_cache_savings import format_cache_savings as _format_cache_savings
except ImportError: # pragma: no cover - defensive
def _format_cache_savings(stdin_data): # type: ignore[misc]
return ""

# Agent eye glyphs from .ai-rules agent definitions.
AGENT_GLYPHS = {
Expand Down Expand Up @@ -411,17 +424,30 @@ def format_status_line(
active_agent: str = "",
*,
plugins_file: str = "",
plugin_json_file: Optional[str] = None,
) -> str:
"""Format the statusLine output.

Fallback order per field:
version → installed_plugins.json > hud-state.version
version → installed_plugins.json > plugin.json > hud-state.version
cost → stdin cost.total_cost_usd > estimate_cost()
duration → stdin cost.total_duration_ms > hud-state sessionStartTimestamp
agent → stdin agent.name > hud_state.activeAgent > active_agent param
model → stdin model.display_name > model.id

Args:
plugin_json_file: Wave 1-A control for the local ``plugin.json``
fallback. ``None`` (default) disables tier-2 — matches the
hud_version contract for backwards compatibility. ``""``
enables the dev-install default path. A non-empty string
overrides the path for tests. ``main()`` passes ``""`` in
production so statusLine always reflects a fresh version.
"""
version = _get_fresh_version(hud_state, plugins_file=plugins_file)
version = _get_fresh_version(
hud_state,
plugins_file=plugins_file,
plugin_json_file=plugin_json_file,
)
mode = hud_state.get("currentMode")
mode_label = mode if mode else "Ready"

Expand Down Expand Up @@ -482,7 +508,12 @@ def main():

env_agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")

output = format_status_line(stdin_data, hud_state, env_agent)
# Pass plugin_json_file="" to enable Wave 1-A dev-install
# plugin.json fallback. Tests opt out by omitting this kwarg
# and relying on the Optional[str]=None default.
output = format_status_line(
stdin_data, hud_state, env_agent, plugin_json_file=""
)
print(output)
except Exception:
print(f"{BUDDY_FACE} CodingBuddy")
Expand Down
91 changes: 74 additions & 17 deletions packages/claude-code-plugin/hooks/lib/hud_version.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,96 @@
"""Version resolution for CodingBuddy statusLine (#1326).
"""Version resolution for CodingBuddy statusLine (#1326, #1464 Wave 1-A).

Wave 0 extracts the plugin-version fallback logic from
``codingbuddy-hud.py`` so Wave 1-A can extend the resolution chain
without touching the monolith.
Wave 1-A strengthens the version resolution chain with a local
``plugin.json`` fallback so the HUD never shows a stale snapshot after
a plugin update even when ``installed_plugins.json`` is missing or
cannot be parsed.

The public entry point is :func:`get_fresh_version`. ``codingbuddy-hud``
calls it internally from ``format_status_line``; callers pass the
current ``hud_state`` dict and an optional ``plugins_file`` override
used by the test-suite to point at a fixture path.
current ``hud_state`` dict and optional path overrides used by tests.

Behavior-preserving contract (mirrors the original monolith helper):
Resolution chain (first non-empty result wins):

1. Attempt to read the freshest version from
``installed_plugins.json`` via
:func:`hud_helpers.read_installed_version`.
2. On success, return that value.
3. On any failure (missing file, parse error, unexpected exception),
fall back to ``hud_state.get("version", "")``.
1. ``installed_plugins.json`` — authoritative after ``/plugin update``
(global Claude Code plugin registry).
2. ``../.claude-plugin/plugin.json`` — deterministic via ``__file__``
relative path, authoritative for dev installs where the plugin is
running from a git checkout.
3. ``hud_state.get("version", "")`` — snapshot written at session
start (may be stale, last resort).
"""
from __future__ import annotations

from typing import Any, Dict
import json
import os
from typing import Any, Dict, Optional


def _default_plugin_json_path() -> str:
"""Resolve ``plugin.json`` relative to this module's location.

``hud_version.py`` lives at
``packages/claude-code-plugin/hooks/lib/hud_version.py``.
``plugin.json`` lives at
``packages/claude-code-plugin/.claude-plugin/plugin.json``.
So we walk up two levels (``lib/`` -> ``hooks/`` -> package root)
and then descend into ``.claude-plugin/``.
"""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.normpath(
os.path.join(here, "..", "..", ".claude-plugin", "plugin.json")
)


def _read_local_plugin_json(path: str) -> str:
"""Read ``plugin.json`` and return its ``version`` field.

Returns an empty string on any failure (missing file, parse error,
missing key). Never raises — caller must be able to skip silently.
"""
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
v = data.get("version")
return v if isinstance(v, str) else ""
except Exception:
return ""


def get_fresh_version(
hud_state: Dict[str, Any],
*,
plugins_file: str = "",
plugin_json_file: Optional[str] = None,
) -> str:
"""Return the freshest known plugin version string.

Args:
hud_state: Current HUD state dict (supplies the fallback
hud_state: Current HUD state dict (supplies the final fallback
``version`` field).
plugins_file: Optional override for the
``installed_plugins.json`` path, used by tests.
plugin_json_file: Local ``plugin.json`` fallback control:

* ``None`` (default) — tier-2 fallback is **disabled**.
Only ``installed_plugins.json`` and ``hud_state`` are
consulted. This keeps the signature backwards-compatible
with callers that do not opt in.
* ``""`` — use the default dev-install path resolved from
``__file__`` (i.e. ``../.claude-plugin/plugin.json``).
``format_status_line`` passes this in production so
statusLine always reflects a fresh local version.
* non-empty string — treat as an explicit file path
override, used by the test suite for fixture files.

Notes:
``hud_helpers`` is imported lazily inside the function body to
preserve the hot-path resilience of the original monolith. If
``hud_helpers`` is temporarily broken (e.g. mid-wave refactor),
the statusLine still renders via the ``hud_state`` fallback
instead of crashing at module load.
the statusLine still renders via the later fallbacks instead
of crashing at module load.
"""
# 1. Global installed_plugins.json (authoritative after /plugin update)
try:
from hud_helpers import read_installed_version # lazy for resilience
kwargs = {"plugins_file": plugins_file} if plugins_file else {}
Expand All @@ -51,4 +99,13 @@ def get_fresh_version(
return fresh
except Exception:
pass

# 2. Local plugin.json (opt-in: None disables this tier entirely)
if plugin_json_file is not None:
path_to_try = plugin_json_file or _default_plugin_json_path()
local = _read_local_plugin_json(path_to_try)
if local:
return local

# 3. hud-state snapshot (may be stale, last resort)
return hud_state.get("version", "")
98 changes: 98 additions & 0 deletions packages/claude-code-plugin/tests/test_hud_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,104 @@ def test_reads_installed_plugins_file_when_present(tmp_path):
assert result == "7.7.7"


def test_plugin_json_fallback_when_installed_plugins_missing(tmp_path):
"""Wave 1-A: plugin.json is the second-tier fallback.

When installed_plugins.json is missing but a local plugin.json
exists, the returned version must be the plugin.json value.
"""
missing_plugins = tmp_path / "no_plugins.json"
plugin_json = tmp_path / "plugin.json"
plugin_json.write_text('{"version": "8.8.8"}', encoding="utf-8")
result = hud_version.get_fresh_version(
{"version": "stale"},
plugins_file=str(missing_plugins),
plugin_json_file=str(plugin_json),
)
assert result == "8.8.8"


def test_installed_plugins_wins_over_plugin_json(tmp_path):
"""Wave 1-A: installed_plugins.json (tier 1) beats plugin.json (tier 2)."""
plugins = tmp_path / "installed_plugins.json"
plugins.write_text(
'{"plugins": {"codingbuddy@dev": [{"version": "tier-1"}]}}',
encoding="utf-8",
)
plugin_json = tmp_path / "plugin.json"
plugin_json.write_text('{"version": "tier-2"}', encoding="utf-8")
result = hud_version.get_fresh_version(
{"version": "tier-3"},
plugins_file=str(plugins),
plugin_json_file=str(plugin_json),
)
assert result == "tier-1"


def test_plugin_json_beats_hud_state(tmp_path):
"""Wave 1-A: plugin.json (tier 2) beats hud_state.version (tier 3)."""
missing_plugins = tmp_path / "no_plugins.json"
plugin_json = tmp_path / "plugin.json"
plugin_json.write_text('{"version": "tier-2"}', encoding="utf-8")
result = hud_version.get_fresh_version(
{"version": "tier-3-stale"},
plugins_file=str(missing_plugins),
plugin_json_file=str(plugin_json),
)
assert result == "tier-2"


def test_all_fallbacks_fail_returns_hud_state_version(tmp_path):
"""Wave 1-A: if both files are absent, fall through to hud_state."""
missing_plugins = tmp_path / "no_plugins.json"
missing_plugin_json = tmp_path / "no_plugin.json"
result = hud_version.get_fresh_version(
{"version": "9.9.9"},
plugins_file=str(missing_plugins),
plugin_json_file=str(missing_plugin_json),
)
assert result == "9.9.9"


def test_plugin_json_malformed_skipped(tmp_path):
"""Wave 1-A: malformed plugin.json must not crash — skip to hud_state."""
missing_plugins = tmp_path / "no_plugins.json"
bad_plugin_json = tmp_path / "plugin.json"
bad_plugin_json.write_text("this is not json", encoding="utf-8")
result = hud_version.get_fresh_version(
{"version": "fallback"},
plugins_file=str(missing_plugins),
plugin_json_file=str(bad_plugin_json),
)
assert result == "fallback"


def test_plugin_json_missing_version_key_skipped(tmp_path):
"""Wave 1-A: plugin.json without version key skips to hud_state."""
missing_plugins = tmp_path / "no_plugins.json"
plugin_json = tmp_path / "plugin.json"
plugin_json.write_text('{"name": "codingbuddy"}', encoding="utf-8")
result = hud_version.get_fresh_version(
{"version": "fallback"},
plugins_file=str(missing_plugins),
plugin_json_file=str(plugin_json),
)
assert result == "fallback"


def test_default_plugin_json_path_resolves_to_real_file():
"""Wave 1-A: __file__-relative default path must point at the real
.claude-plugin/plugin.json in the repo so dev installs work."""
import pathlib
path = hud_version._default_plugin_json_path()
assert os.path.isfile(path), (
f"Expected plugin.json at {path}; hud_version default path is wrong."
)
# Smoke check: the file is parseable and has a version field
version = hud_version._read_local_plugin_json(path)
assert version, "plugin.json exists but version field is empty"


def test_import_does_not_read_real_plugins_file(monkeypatch, tmp_path):
"""Lock: module load must not touch ~/.claude/plugins/installed_plugins.json."""
fake_home = tmp_path / "fake_home"
Expand Down
Loading
Loading