Skip to content

Commit cdf0772

Browse files
committed
fix(hud): version resolution fallback to plugin.json (Wave 1-A)
Resolves the stale version display bug ("shows v5.2.0 when actual is v5.5.0") by adding a deterministic tier-2 fallback that reads .claude-plugin/plugin.json via __file__-relative path. - hud_version.get_fresh_version gains plugin_json_file parameter: * None (default): tier-2 disabled, preserves backcompat * "": enables dev-install default path (production) * non-empty: test override - _default_plugin_json_path() + _read_local_plugin_json() helpers - format_status_line accepts plugin_json_file passthrough - main() passes plugin_json_file="" to enable Wave 1-A in production - 7 new tests for fallback ordering, malformed files, default path - 162/162 tests pass (133 Golden Rule + 29 version/Wave 0) Closes #1466 Part of #1464 (Wave 0 statusbar refactor)
1 parent de622cc commit cdf0772

3 files changed

Lines changed: 202 additions & 21 deletions

File tree

packages/claude-code-plugin/hooks/codingbuddy-hud.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import sys
1616
from datetime import datetime, timezone
17+
from typing import Optional
1718

1819
# --- lib import bootstrap ---
1920
# statusLine entry script: sys.path insertion here is intentional so
@@ -42,8 +43,15 @@ def format_rate_limits(stdin_data: dict) -> str: # type: ignore[misc]
4243
from hud_version import get_fresh_version as _get_fresh_version # backcompat alias
4344
except Exception: # pragma: no cover - defensive
4445
def _get_fresh_version( # type: ignore[misc]
45-
hud_state: dict, *, plugins_file: str = ""
46+
hud_state: dict,
47+
*,
48+
plugins_file: str = "",
49+
plugin_json_file: Optional[str] = None,
4650
) -> str:
51+
# Signature must stay in sync with hud_version.get_fresh_version
52+
# (see Wave 1-A PR #1467 review — HIGH finding H1). When this
53+
# stub is active, hud_version is unavailable so tier-2 cannot
54+
# be honoured regardless of the kwarg value; accept and ignore.
4755
return hud_state.get("version", "")
4856

4957
# Agent eye glyphs from .ai-rules agent definitions.
@@ -411,17 +419,30 @@ def format_status_line(
411419
active_agent: str = "",
412420
*,
413421
plugins_file: str = "",
422+
plugin_json_file: Optional[str] = None,
414423
) -> str:
415424
"""Format the statusLine output.
416425
417426
Fallback order per field:
418-
version → installed_plugins.json > hud-state.version
427+
version → installed_plugins.json > plugin.json > hud-state.version
419428
cost → stdin cost.total_cost_usd > estimate_cost()
420429
duration → stdin cost.total_duration_ms > hud-state sessionStartTimestamp
421430
agent → stdin agent.name > hud_state.activeAgent > active_agent param
422431
model → stdin model.display_name > model.id
432+
433+
Args:
434+
plugin_json_file: Wave 1-A control for the local ``plugin.json``
435+
fallback. ``None`` (default) disables tier-2 — matches the
436+
hud_version contract for backwards compatibility. ``""``
437+
enables the dev-install default path. A non-empty string
438+
overrides the path for tests. ``main()`` passes ``""`` in
439+
production so statusLine always reflects a fresh version.
423440
"""
424-
version = _get_fresh_version(hud_state, plugins_file=plugins_file)
441+
version = _get_fresh_version(
442+
hud_state,
443+
plugins_file=plugins_file,
444+
plugin_json_file=plugin_json_file,
445+
)
425446
mode = hud_state.get("currentMode")
426447
mode_label = mode if mode else "Ready"
427448

@@ -482,7 +503,12 @@ def main():
482503

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

485-
output = format_status_line(stdin_data, hud_state, env_agent)
506+
# Pass plugin_json_file="" to enable Wave 1-A dev-install
507+
# plugin.json fallback. Tests opt out by omitting this kwarg
508+
# and relying on the Optional[str]=None default.
509+
output = format_status_line(
510+
stdin_data, hud_state, env_agent, plugin_json_file=""
511+
)
486512
print(output)
487513
except Exception:
488514
print(f"{BUDDY_FACE} CodingBuddy")
Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,96 @@
1-
"""Version resolution for CodingBuddy statusLine (#1326).
1+
"""Version resolution for CodingBuddy statusLine (#1326, #1464 Wave 1-A).
22
3-
Wave 0 extracts the plugin-version fallback logic from
4-
``codingbuddy-hud.py`` so Wave 1-A can extend the resolution chain
5-
without touching the monolith.
3+
Wave 1-A strengthens the version resolution chain with a local
4+
``plugin.json`` fallback so the HUD never shows a stale snapshot after
5+
a plugin update even when ``installed_plugins.json`` is missing or
6+
cannot be parsed.
67
78
The public entry point is :func:`get_fresh_version`. ``codingbuddy-hud``
89
calls it internally from ``format_status_line``; callers pass the
9-
current ``hud_state`` dict and an optional ``plugins_file`` override
10-
used by the test-suite to point at a fixture path.
10+
current ``hud_state`` dict and optional path overrides used by tests.
1111
12-
Behavior-preserving contract (mirrors the original monolith helper):
12+
Resolution chain (first non-empty result wins):
1313
14-
1. Attempt to read the freshest version from
15-
``installed_plugins.json`` via
16-
:func:`hud_helpers.read_installed_version`.
17-
2. On success, return that value.
18-
3. On any failure (missing file, parse error, unexpected exception),
19-
fall back to ``hud_state.get("version", "")``.
14+
1. ``installed_plugins.json`` — authoritative after ``/plugin update``
15+
(global Claude Code plugin registry).
16+
2. ``../.claude-plugin/plugin.json`` — deterministic via ``__file__``
17+
relative path, authoritative for dev installs where the plugin is
18+
running from a git checkout.
19+
3. ``hud_state.get("version", "")`` — snapshot written at session
20+
start (may be stale, last resort).
2021
"""
2122
from __future__ import annotations
2223

23-
from typing import Any, Dict
24+
import json
25+
import os
26+
from typing import Any, Dict, Optional
27+
28+
29+
def _default_plugin_json_path() -> str:
30+
"""Resolve ``plugin.json`` relative to this module's location.
31+
32+
``hud_version.py`` lives at
33+
``packages/claude-code-plugin/hooks/lib/hud_version.py``.
34+
``plugin.json`` lives at
35+
``packages/claude-code-plugin/.claude-plugin/plugin.json``.
36+
So we walk up two levels (``lib/`` -> ``hooks/`` -> package root)
37+
and then descend into ``.claude-plugin/``.
38+
"""
39+
here = os.path.dirname(os.path.abspath(__file__))
40+
return os.path.normpath(
41+
os.path.join(here, "..", "..", ".claude-plugin", "plugin.json")
42+
)
43+
44+
45+
def _read_local_plugin_json(path: str) -> str:
46+
"""Read ``plugin.json`` and return its ``version`` field.
47+
48+
Returns an empty string on any failure (missing file, parse error,
49+
missing key). Never raises — caller must be able to skip silently.
50+
"""
51+
try:
52+
with open(path, "r", encoding="utf-8") as f:
53+
data = json.load(f)
54+
v = data.get("version")
55+
return v if isinstance(v, str) else ""
56+
except Exception:
57+
return ""
2458

2559

2660
def get_fresh_version(
2761
hud_state: Dict[str, Any],
2862
*,
2963
plugins_file: str = "",
64+
plugin_json_file: Optional[str] = None,
3065
) -> str:
3166
"""Return the freshest known plugin version string.
3267
3368
Args:
34-
hud_state: Current HUD state dict (supplies the fallback
69+
hud_state: Current HUD state dict (supplies the final fallback
3570
``version`` field).
3671
plugins_file: Optional override for the
3772
``installed_plugins.json`` path, used by tests.
73+
plugin_json_file: Local ``plugin.json`` fallback control:
74+
75+
* ``None`` (default) — tier-2 fallback is **disabled**.
76+
Only ``installed_plugins.json`` and ``hud_state`` are
77+
consulted. This keeps the signature backwards-compatible
78+
with callers that do not opt in.
79+
* ``""`` — use the default dev-install path resolved from
80+
``__file__`` (i.e. ``../.claude-plugin/plugin.json``).
81+
``format_status_line`` passes this in production so
82+
statusLine always reflects a fresh local version.
83+
* non-empty string — treat as an explicit file path
84+
override, used by the test suite for fixture files.
3885
3986
Notes:
4087
``hud_helpers`` is imported lazily inside the function body to
4188
preserve the hot-path resilience of the original monolith. If
4289
``hud_helpers`` is temporarily broken (e.g. mid-wave refactor),
43-
the statusLine still renders via the ``hud_state`` fallback
44-
instead of crashing at module load.
90+
the statusLine still renders via the later fallbacks instead
91+
of crashing at module load.
4592
"""
93+
# 1. Global installed_plugins.json (authoritative after /plugin update)
4694
try:
4795
from hud_helpers import read_installed_version # lazy for resilience
4896
kwargs = {"plugins_file": plugins_file} if plugins_file else {}
@@ -51,4 +99,13 @@ def get_fresh_version(
5199
return fresh
52100
except Exception:
53101
pass
102+
103+
# 2. Local plugin.json (opt-in: None disables this tier entirely)
104+
if plugin_json_file is not None:
105+
path_to_try = plugin_json_file or _default_plugin_json_path()
106+
local = _read_local_plugin_json(path_to_try)
107+
if local:
108+
return local
109+
110+
# 3. hud-state snapshot (may be stale, last resort)
54111
return hud_state.get("version", "")

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,104 @@ def test_reads_installed_plugins_file_when_present(tmp_path):
4444
assert result == "7.7.7"
4545

4646

47+
def test_plugin_json_fallback_when_installed_plugins_missing(tmp_path):
48+
"""Wave 1-A: plugin.json is the second-tier fallback.
49+
50+
When installed_plugins.json is missing but a local plugin.json
51+
exists, the returned version must be the plugin.json value.
52+
"""
53+
missing_plugins = tmp_path / "no_plugins.json"
54+
plugin_json = tmp_path / "plugin.json"
55+
plugin_json.write_text('{"version": "8.8.8"}', encoding="utf-8")
56+
result = hud_version.get_fresh_version(
57+
{"version": "stale"},
58+
plugins_file=str(missing_plugins),
59+
plugin_json_file=str(plugin_json),
60+
)
61+
assert result == "8.8.8"
62+
63+
64+
def test_installed_plugins_wins_over_plugin_json(tmp_path):
65+
"""Wave 1-A: installed_plugins.json (tier 1) beats plugin.json (tier 2)."""
66+
plugins = tmp_path / "installed_plugins.json"
67+
plugins.write_text(
68+
'{"plugins": {"codingbuddy@dev": [{"version": "tier-1"}]}}',
69+
encoding="utf-8",
70+
)
71+
plugin_json = tmp_path / "plugin.json"
72+
plugin_json.write_text('{"version": "tier-2"}', encoding="utf-8")
73+
result = hud_version.get_fresh_version(
74+
{"version": "tier-3"},
75+
plugins_file=str(plugins),
76+
plugin_json_file=str(plugin_json),
77+
)
78+
assert result == "tier-1"
79+
80+
81+
def test_plugin_json_beats_hud_state(tmp_path):
82+
"""Wave 1-A: plugin.json (tier 2) beats hud_state.version (tier 3)."""
83+
missing_plugins = tmp_path / "no_plugins.json"
84+
plugin_json = tmp_path / "plugin.json"
85+
plugin_json.write_text('{"version": "tier-2"}', encoding="utf-8")
86+
result = hud_version.get_fresh_version(
87+
{"version": "tier-3-stale"},
88+
plugins_file=str(missing_plugins),
89+
plugin_json_file=str(plugin_json),
90+
)
91+
assert result == "tier-2"
92+
93+
94+
def test_all_fallbacks_fail_returns_hud_state_version(tmp_path):
95+
"""Wave 1-A: if both files are absent, fall through to hud_state."""
96+
missing_plugins = tmp_path / "no_plugins.json"
97+
missing_plugin_json = tmp_path / "no_plugin.json"
98+
result = hud_version.get_fresh_version(
99+
{"version": "9.9.9"},
100+
plugins_file=str(missing_plugins),
101+
plugin_json_file=str(missing_plugin_json),
102+
)
103+
assert result == "9.9.9"
104+
105+
106+
def test_plugin_json_malformed_skipped(tmp_path):
107+
"""Wave 1-A: malformed plugin.json must not crash — skip to hud_state."""
108+
missing_plugins = tmp_path / "no_plugins.json"
109+
bad_plugin_json = tmp_path / "plugin.json"
110+
bad_plugin_json.write_text("this is not json", encoding="utf-8")
111+
result = hud_version.get_fresh_version(
112+
{"version": "fallback"},
113+
plugins_file=str(missing_plugins),
114+
plugin_json_file=str(bad_plugin_json),
115+
)
116+
assert result == "fallback"
117+
118+
119+
def test_plugin_json_missing_version_key_skipped(tmp_path):
120+
"""Wave 1-A: plugin.json without version key skips to hud_state."""
121+
missing_plugins = tmp_path / "no_plugins.json"
122+
plugin_json = tmp_path / "plugin.json"
123+
plugin_json.write_text('{"name": "codingbuddy"}', encoding="utf-8")
124+
result = hud_version.get_fresh_version(
125+
{"version": "fallback"},
126+
plugins_file=str(missing_plugins),
127+
plugin_json_file=str(plugin_json),
128+
)
129+
assert result == "fallback"
130+
131+
132+
def test_default_plugin_json_path_resolves_to_real_file():
133+
"""Wave 1-A: __file__-relative default path must point at the real
134+
.claude-plugin/plugin.json in the repo so dev installs work."""
135+
import pathlib
136+
path = hud_version._default_plugin_json_path()
137+
assert os.path.isfile(path), (
138+
f"Expected plugin.json at {path}; hud_version default path is wrong."
139+
)
140+
# Smoke check: the file is parseable and has a version field
141+
version = hud_version._read_local_plugin_json(path)
142+
assert version, "plugin.json exists but version field is empty"
143+
144+
47145
def test_import_does_not_read_real_plugins_file(monkeypatch, tmp_path):
48146
"""Lock: module load must not touch ~/.claude/plugins/installed_plugins.json."""
49147
fake_home = tmp_path / "fake_home"

0 commit comments

Comments
 (0)