diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index eebaff2..cb0f5bb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,5 +14,5 @@ "license": "BSD-3-Clause", "name": "yas", "repository": "https://github.com/tmck-code/yet-another-statusline", - "version": "0.4.7" + "version": "0.4.8" } diff --git a/README.md b/README.md index 19fee9b..383b2f1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Requires Python 3.10+, and a [Nerd Font](https://www.nerdfonts.com/font-download curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/main/ops/install.sh | bash ``` +Alongside `statusLine.command`, the installer also wires a `UserPromptSubmit` +hook (`yas-prompt-hook.py`) that records per-session prompt timestamps, enabling +**accurate turn-scoped subagent display**. It is upserted idempotently — foreign +hooks are preserved, stale paths are rewritten on upgrade — and removed on +uninstall. + Or install manually: ```bash @@ -275,6 +281,23 @@ Wire `statusLine.command` in `~/.claude/settings.json` to point at the checkout: > `statusLine.command` back to the plugin cache path. Either uninstall the plugin or bump > the version in `.claude-plugin/plugin.json` before reinstalling to keep your local path. +For accurate turn-scoped subagent display, also wire the `UserPromptSubmit` hook +(`hooks/yas-prompt-hook.py`) so per-session prompt timestamps are recorded: +```json +"hooks": { + "UserPromptSubmit": [ + { "matcher": "", "hooks": [ + { "type": "command", "command": "python3 \"/path/to/yet-another-statusline/hooks/yas-prompt-hook.py\"" } + ] } + ] +} +``` + +> The `curl … | bash` installer wires this hook for you (upserted idempotently — +> foreign hooks preserved, stale paths rewritten on upgrade — and removed on +> uninstall); it is only a manual step for a git-clone install. Without it, the +> statusline falls back to a time-window heuristic for which subagents to show. + This prompts before setting `core.hooksPath`. CI runs the same checks on every push, so the hook is fast local feedback rather than the gate. diff --git a/ops/install.sh b/ops/install.sh index f45bbd2..69e7d6c 100755 --- a/ops/install.sh +++ b/ops/install.sh @@ -517,6 +517,79 @@ elif op == "set-statusline": } sys.stdout.write(json.dumps(data, indent=2)) +elif op == "wire": + # wire FILE STATUSLINE_CMD HOOK_CMD + # set .statusLine AND upsert the YAS UserPromptSubmit hook in ONE write, so + # both mutations share a single backup/validate/restore. Foreign hooks (e.g. + # a user's docker-skill-nudge.py) are preserved; the YAS entry is matched by + # the "yas-prompt-hook.py" substring and replaced (idempotent / stale-path). + # Double-fire note: a plugin-enabled user also gets the hook via hooks.json, + # firing it twice per prompt — harmless (identical last-write-wins timestamp). + data = load(sys.argv[2]) + if not isinstance(data, dict): + data = {} + data["statusLine"] = { + "async": True, + "command": sys.argv[3], + "refreshInterval": 1, + "type": "command", + "padding": 1, + } + hooks = data.get("hooks") + if not isinstance(hooks, dict): + hooks = {} + ups = hooks.get("UserPromptSubmit") + if not isinstance(ups, list): + ups = [] + def _is_yas(entry): + if not isinstance(entry, dict): + return False + return any( + isinstance(h, dict) and "yas-prompt-hook.py" in str(h.get("command", "")) + for h in entry.get("hooks", []) or [] + ) + ups = [e for e in ups if not _is_yas(e)] + ups.append({"matcher": "", "hooks": [{"type": "command", "command": sys.argv[4]}]}) + hooks["UserPromptSubmit"] = ups + data["hooks"] = hooks + sys.stdout.write(json.dumps(data, indent=2)) + +elif op == "get-hook": + # get-hook FILE print the current YAS hook command (empty if absent) + data = load(sys.argv[2]) + out = "" + if isinstance(data, dict): + hooks = data.get("hooks") or {} + for entry in (hooks.get("UserPromptSubmit") or []): + if isinstance(entry, dict): + for h in entry.get("hooks", []) or []: + if isinstance(h, dict) and "yas-prompt-hook.py" in str(h.get("command", "")): + out = str(h.get("command", "")) + sys.stdout.write(out + ("\n" if out else "")) + +elif op == "del-hook": + # del-hook FILE remove the YAS UserPromptSubmit hook; print serialized JSON. + # Foreign hooks survive; empty UserPromptSubmit / hooks collapse away. + data = load(sys.argv[2]) + if isinstance(data, dict): + hooks = data.get("hooks") + if isinstance(hooks, dict) and isinstance(hooks.get("UserPromptSubmit"), list): + def _is_yas(entry): + if not isinstance(entry, dict): + return False + return any( + isinstance(h, dict) and "yas-prompt-hook.py" in str(h.get("command", "")) + for h in entry.get("hooks", []) or [] + ) + ups = [e for e in hooks["UserPromptSubmit"] if not _is_yas(e)] + if ups: + hooks["UserPromptSubmit"] = ups + else: + hooks.pop("UserPromptSubmit", None) + if not hooks: + data.pop("hooks", None) + sys.stdout.write(json.dumps(data, indent=2)) + elif op == "del-key": # del-key FILE KEY remove top-level KEY, print serialized JSON data = load(sys.argv[2]) @@ -800,6 +873,7 @@ do_wire() { fi SCRIPT="$PLUGIN_ROOT/claude/statusline_command.py" + HOOK_SCRIPT="$PLUGIN_ROOT/hooks/yas-prompt-hook.py" printf '%b Plugin root: %s%b\n' "$C_DIM" "$PLUGIN_ROOT" "$C_RESET" # Legacy cleanup @@ -830,12 +904,16 @@ do_wire() { run_wizard fi - # Exact-match skip - local NEW_CMD OLD_CMD + # Exact-match skip — require BOTH the statusLine command AND the prompt hook + # to already be current before we skip, otherwise an upgrade that only moved + # the hook path (or a first-time hook wire) would be silently missed. + local NEW_CMD OLD_CMD NEW_HOOK_CMD OLD_HOOK_CMD NEW_CMD="\"$PYTHON_BIN\" \"$SCRIPT\"" + NEW_HOOK_CMD="\"$PYTHON_BIN\" \"$HOOK_SCRIPT\"" OLD_CMD=$(json_py get-key "$SETTINGS" statusLine.command 2>/dev/null || printf '') - if [ "$OLD_CMD" = "$NEW_CMD" ]; then - ok " statusLine already set to current version — skipping." + OLD_HOOK_CMD=$(json_py get-hook "$SETTINGS" 2>/dev/null || printf '') + if [ "$OLD_CMD" = "$NEW_CMD" ] && [ "$OLD_HOOK_CMD" = "$NEW_HOOK_CMD" ]; then + ok " statusLine + prompt hook already current — skipping." printf '%b Config dir: %s%b\n' "$C_DIM" "$CLAUDE_CONFIG_DIR" "$C_RESET" ok " Done. Reload Claude Code to activate the statusline." return 0 @@ -844,6 +922,7 @@ do_wire() { # dry-run wiring if [ "$DRY_RUN" = "1" ]; then printf '%b Would wire statusLine.command → %s%b\n' "$C_DIM" "$NEW_CMD" "$C_RESET" + printf '%b Would wire UserPromptSubmit hook → %s%b\n' "$C_DIM" "$NEW_HOOK_CMD" "$C_RESET" exit 0 fi @@ -859,7 +938,7 @@ do_wire() { fi local _result - if ! _result=$(json_py set-statusline "$SETTINGS" "$NEW_CMD") || [ -z "$_result" ]; then + if ! _result=$(json_py wire "$SETTINGS" "$NEW_CMD" "$NEW_HOOK_CMD") || [ -z "$_result" ]; then fail "! JSON merge failed — settings.json unchanged"; exit 1 fi @@ -875,7 +954,9 @@ do_wire() { fi [ -n "$OLD_CMD" ] && [ "$OLD_CMD" != "$NEW_CMD" ] && printf '%b Replaced stale path: %s%b\n' "$C_DIM" "$OLD_CMD" "$C_RESET" + [ -n "$OLD_HOOK_CMD" ] && [ "$OLD_HOOK_CMD" != "$NEW_HOOK_CMD" ] && printf '%b Replaced stale hook path: %s%b\n' "$C_DIM" "$OLD_HOOK_CMD" "$C_RESET" printf '%b statusLine set → %s%b\n' "$C_GREEN" "$NEW_CMD" "$C_RESET" + printf '%b prompt hook set → %s%b\n' "$C_GREEN" "$NEW_HOOK_CMD" "$C_RESET" printf '%b Config dir: %s%b\n' "$C_DIM" "$CLAUDE_CONFIG_DIR" "$C_RESET" ok " Done. Reload Claude Code to activate the statusline." } @@ -899,26 +980,40 @@ do_uninstall() { fi done - # Remove statusLine key from settings.json + # Remove statusLine key AND the YAS prompt hook from settings.json. Symmetric + # with do_wire: the hook is matched by the "yas-prompt-hook.py" substring so + # foreign UserPromptSubmit hooks (e.g. a user's docker-skill-nudge.py) survive, + # and empty UserPromptSubmit / hooks containers collapse away (see del-hook). if [ ! -f "$SETTINGS" ]; then step " settings.json not found — nothing to unwire." else - local HAS_KEY + local HAS_KEY HAS_HOOK HAS_KEY=$(json_py has-key "$SETTINGS" statusLine 2>/dev/null) || HAS_KEY="false" - if [ "$HAS_KEY" != "true" ]; then + HAS_HOOK=$(json_py get-hook "$SETTINGS" 2>/dev/null || printf '') + if [ "$HAS_KEY" != "true" ] && [ -z "$HAS_HOOK" ]; then step " statusLine not present in settings.json — nothing to unwire." elif [ "$DRY_RUN" = "1" ]; then - printf '%b Would remove statusLine from %s%b\n' "$C_DIM" "$SETTINGS" "$C_RESET" + [ "$HAS_KEY" = "true" ] && printf '%b Would remove statusLine from %s%b\n' "$C_DIM" "$SETTINGS" "$C_RESET" + [ -n "$HAS_HOOK" ] && printf '%b Would remove prompt hook from %s%b\n' "$C_DIM" "$SETTINGS" "$C_RESET" else local BAK BAK="${SETTINGS}.bak-yas-$(date -u +%Y%m%dT%H%M%SZ)" cp "$SETTINGS" "$BAK" printf '%b Backed up → %s%b\n' "$C_DIM" "$(basename "$BAK")" "$C_RESET" - local _result + # Two ops, one atomic swap. del-key/del-hook each read a file path, so + # stage the intermediate JSON through a temp file, then mv the final + # result over settings.json exactly once (backup already taken above). + local _result _stage if ! _result=$(json_py del-key "$SETTINGS" statusLine) || [ -z "$_result" ]; then fail "! JSON edit failed — settings.json unchanged"; exit 1 fi + _stage=$(mktemp "${SETTINGS}.XXXXXXXXXX") + printf '%s\n' "$_result" > "$_stage" || { rm -f "$_stage"; fail "! write failed — settings.json unchanged"; exit 1; } + if ! _result=$(json_py del-hook "$_stage") || [ -z "$_result" ]; then + rm -f "$_stage"; fail "! JSON edit failed — settings.json unchanged"; exit 1 + fi + rm -f "$_stage" local _tmp _tmp=$(mktemp "${SETTINGS}.XXXXXXXXXX") @@ -930,7 +1025,8 @@ do_uninstall() { cp "$BAK" "$SETTINGS" exit 1 fi - ok " Removed statusLine from settings.json" + [ "$HAS_KEY" = "true" ] && ok " Removed statusLine from settings.json" + [ -n "$HAS_HOOK" ] && ok " Removed prompt hook from settings.json" fi fi diff --git a/pyproject.toml b/pyproject.toml index 9f29aaa..a797c14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yet-another-statusline" -version = "0.4.7" +version = "0.4.8" description = "Claude Code statusline showing info at a glance: tokens, context, model, subagents, burn rate, skills, plugins, OpenSpec specs, task lists, and more" readme = "README.md" diff --git a/test/test_prompt_hook.py b/test/test_prompt_hook.py index 7b86b89..de5a7a6 100644 --- a/test/test_prompt_hook.py +++ b/test/test_prompt_hook.py @@ -8,6 +8,8 @@ import importlib.util import json import os +import re +import subprocess import sys import time from pathlib import Path @@ -16,6 +18,40 @@ from yas.info.subagents import read_last_prompt_ts +_INSTALL_SH = Path(__file__).resolve().parent.parent / 'ops' / 'install.sh' + + +def _json_py_body() -> str: + '''Extract the json_py python heredoc from ops/install.sh. + + Testing the real op body (rather than a re-implementation) proves the exact + code the installer runs, so a divergence in the shipped heredoc is caught. + ''' + src = _INSTALL_SH.read_text() + match = re.search(r"<<'PY'\n(.*?)\nPY\n", src, re.S) + assert match, 'json_py heredoc not found in install.sh' + return match.group(1) + + +def _run_json_py(op: str, path: Path, *rest: str) -> str: + '''Invoke a json_py op as a subprocess against the real heredoc body.''' + proc = subprocess.run( + [sys.executable, '-', op, str(path), *rest], + input = _json_py_body(), + capture_output = True, + text = True, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout + + +_STATUSLINE_CMD = '"python3" "/plugin/claude/statusline_command.py"' +_HOOK_CMD = '"python3" "/plugin/hooks/yas-prompt-hook.py"' +_FOREIGN = { + 'matcher': '', + 'hooks': [{'type': 'command', 'command': '"python3" "/x/docker-skill-nudge.py"'}], +} + # --------------------------------------------------------------------------- # Hook script helpers @@ -210,3 +246,159 @@ def test_hook_missing_session_id_does_not_crash(tmp_path: Path) -> None: os.environ[k] = v assert not state.exists() + + +# --------------------------------------------------------------------------- +# Installer json_py ops: wire / get-hook / del-hook +# --------------------------------------------------------------------------- + +class TestInstallerHookOps: + '''The install.sh json_py wire/get-hook/del-hook ops that manage the + UserPromptSubmit hook alongside statusLine, exercised via the real heredoc.''' + + def test_wire_on_empty_sets_statusline_and_hook(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text('{}') + + # run + result = json.loads(_run_json_py('wire', settings, _STATUSLINE_CMD, _HOOK_CMD)) + + # expected + expected = { + 'statusLine': { + 'async': True, + 'command': _STATUSLINE_CMD, + 'refreshInterval': 1, + 'type': 'command', + 'padding': 1, + }, + 'hooks': { + 'UserPromptSubmit': [ + {'matcher': '', 'hooks': [{'type': 'command', 'command': _HOOK_CMD}]}, + ], + }, + } + + # assert + assert result == expected + + def test_wire_is_idempotent(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text('{}') + settings.write_text(_run_json_py('wire', settings, _STATUSLINE_CMD, _HOOK_CMD)) + + # run + result = json.loads(_run_json_py('wire', settings, _STATUSLINE_CMD, _HOOK_CMD)) + + # expected + expected = [ + {'matcher': '', 'hooks': [{'type': 'command', 'command': _HOOK_CMD}]}, + ] + + # assert + assert result['hooks']['UserPromptSubmit'] == expected + + def test_wire_preserves_foreign_hooks(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text(json.dumps({'hooks': {'UserPromptSubmit': [_FOREIGN]}})) + + # run + result = json.loads(_run_json_py('wire', settings, _STATUSLINE_CMD, _HOOK_CMD)) + + # expected + expected = [ + _FOREIGN, + {'matcher': '', 'hooks': [{'type': 'command', 'command': _HOOK_CMD}]}, + ] + + # assert + assert result['hooks']['UserPromptSubmit'] == expected + + def test_wire_replaces_stale_path(self, tmp_path): + # setup + stale = { + 'matcher': '', + 'hooks': [{'type': 'command', 'command': '"python3" "/old/0.2.0/hooks/yas-prompt-hook.py"'}], + } + settings = tmp_path / 'settings.json' + settings.write_text(json.dumps({'hooks': {'UserPromptSubmit': [stale]}})) + + # run + result = json.loads(_run_json_py('wire', settings, _STATUSLINE_CMD, _HOOK_CMD)) + + # expected + expected = [ + {'matcher': '', 'hooks': [{'type': 'command', 'command': _HOOK_CMD}]}, + ] + + # assert + assert result['hooks']['UserPromptSubmit'] == expected + + def test_get_hook_returns_current_command(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text(_run_json_py('wire', settings, _STATUSLINE_CMD, _HOOK_CMD)) + + # run + result = _run_json_py('get-hook', settings).strip() + + # expected + expected = _HOOK_CMD + + # assert + assert result == expected + + def test_get_hook_empty_when_absent(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text('{}') + + # run + result = _run_json_py('get-hook', settings) + + # expected + expected = '' + + # assert + assert result == expected + + def test_del_hook_removes_only_yas_and_collapses(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text(json.dumps({ + 'theme': 'dark', + 'hooks': {'UserPromptSubmit': [ + {'matcher': '', 'hooks': [{'type': 'command', 'command': _HOOK_CMD}]}, + ]}, + })) + + # run + result = json.loads(_run_json_py('del-hook', settings)) + + # expected — sole YAS entry gone, empty UserPromptSubmit/hooks collapse away + expected = {'theme': 'dark'} + + # assert + assert result == expected + + def test_del_hook_preserves_foreign_hooks(self, tmp_path): + # setup + settings = tmp_path / 'settings.json' + settings.write_text(json.dumps({ + 'hooks': {'UserPromptSubmit': [ + _FOREIGN, + {'matcher': '', 'hooks': [{'type': 'command', 'command': _HOOK_CMD}]}, + ]}, + })) + + # run + result = json.loads(_run_json_py('del-hook', settings)) + + # expected + expected = {'hooks': {'UserPromptSubmit': [_FOREIGN]}} + + # assert + assert result == expected diff --git a/uv.lock b/uv.lock index 259e336..2466251 100644 --- a/uv.lock +++ b/uv.lock @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "yet-another-statusline" -version = "0.4.7" +version = "0.4.8" source = { virtual = "." } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" },