From a9aac74fb90dcc425ce438e98b8ed995b583373b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20Ruair=C3=AD=20Bullard?= Date: Mon, 17 Aug 2026 12:51:18 +0200 Subject: [PATCH] Update claude-bionify to 1.0.6 The vendored copy was at 1.0.1, five releases behind. Two of the missing releases are Windows fixes: without 1.0.5 the hook never starts on most native Windows machines, and without 1.0.3 non-ASCII characters garble. Syncs plugins/claude-bionify/ to 1.0.6 and updates the version in both .claude-plugin/marketplace.json and the plugin manifest. --- .claude-plugin/marketplace.json | 2 +- .../claude-bionify/.claude-plugin/plugin.json | 2 +- plugins/claude-bionify/CHANGELOG.md | 66 ++++++++++ plugins/claude-bionify/README.md | 11 +- plugins/claude-bionify/commands/status.md | 2 +- plugins/claude-bionify/hooks/hooks.json | 6 + plugins/claude-bionify/scripts/bionify.py | 77 ++++++++---- plugins/claude-bionify/scripts/control.py | 36 +++++- .../claude-bionify/scripts/interpreters.py | 117 ++++++++++++++++++ plugins/claude-bionify/scripts/overrides.py | 5 +- plugins/claude-bionify/scripts/settings.py | 2 +- .../skills/claude-bionify/SKILL.md | 30 +++++ 12 files changed, 319 insertions(+), 37 deletions(-) mode change 100644 => 100755 plugins/claude-bionify/scripts/control.py create mode 100644 plugins/claude-bionify/scripts/interpreters.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cd886fc..bbea8cd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -2147,7 +2147,7 @@ "name": "claude-bionify", "source": "./plugins/claude-bionify", "description": "Bionic reading for Claude Code responses that bolds the leading part of each word to improve readability and focus.", - "version": "1.0.1", + "version": "1.0.6", "author": { "name": "Samuel Ruairí Bullard", "url": "https://github.com/abullard1" diff --git a/plugins/claude-bionify/.claude-plugin/plugin.json b/plugins/claude-bionify/.claude-plugin/plugin.json index 3098d97..2513316 100644 --- a/plugins/claude-bionify/.claude-plugin/plugin.json +++ b/plugins/claude-bionify/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-bionify", "displayName": "claude-bionify", - "version": "1.0.1", + "version": "1.0.6", "description": "Bionic reading for Claude's responses that bolds the leading part of each word to guide your eyes and enable you to read faster.", "author": { "name": "Samuel Ruairí Bullard", diff --git a/plugins/claude-bionify/CHANGELOG.md b/plugins/claude-bionify/CHANGELOG.md index 1bd76cd..acb66f8 100644 --- a/plugins/claude-bionify/CHANGELOG.md +++ b/plugins/claude-bionify/CHANGELOG.md @@ -3,6 +3,72 @@ All notable changes to claude-bionify are documented here. This project follows [semantic versioning](https://semver.org) and [Keep a Changelog](https://keepachangelog.com). +## [1.0.6] - 2026-08-17 + +### Fixed +- `/claude-bionify:status` no longer reports that nothing is being bolded when it is. The + check read the interpreter's entire output, so a banner printed ahead of it by a + `sitecustomize` or a conda activation hid the version line and a perfectly good Python was + counted as unusable. It now reads the last line the probe prints. +- A malformed `hooks.json` no longer leaves `/claude-bionify:status` silent. An unreadable + file now says so, and an oddly shaped one is treated as declaring no interpreters rather + than raising. + +## [1.0.5] - 2026-08-15 + +### Fixed +- The hook now starts on native Windows. It ran as `python3`, a name that is usually not + Python there: the python.org installer creates no `python3.exe`, and Windows ships a + Microsoft Store placeholder of that name which exits without running anything. Hooks are + spawned with no shell, so there was no fallback, and a failed `MessageDisplay` hook shows + the original text without reporting an error. The hook is now declared as both `python3` + and `py -3`, and the first that starts does the bolding. Thanks to @aermak for the report. +- `/claude-bionify:status` no longer reports ON while nothing is being bolded. + +### Known limitation +- Where `python3` is the Store placeholder, bolding works but the `/claude-bionify:*` + commands do not, since they run through a shell and no fallback syntax suits both Git Bash + and PowerShell 5.1. Installing Git for Windows, or a real `python3` on `PATH`, restores them. + +## [1.0.4] - 2026-08-13 + +### Fixed +- Code blocks are less likely to be bolded as prose when Claude streams quickly. + Claude Code runs up to three flushes of one message at once, and the file that + remembers whether a code fence is open was truncated before being rewritten, so + an overlapping flush could read it as empty. It is now written to a temporary + file and moved into place, which no reader can observe half-finished. +- The hook reads the message identifier from `message_id`, the field Claude Code + actually sends. It looked for `messageId`, never found it, and fell back to the + session id, which keyed fence state per session rather than per message. +- Fence state is cleared when a message ends on a newline. That final flush + carries no text, and the hook returned before reaching its own cleanup, leaving + a stale file behind for the rest of the session. +- Stale temporary files from an interrupted flush are collected alongside stale + fence state at the start of the next message. + +## [1.0.3] - 2026-07-26 + +### Fixed +- Non-ASCII characters no longer garble on Windows. Python decodes a pipe with + the system ANSI codepage rather than UTF-8, so em dashes and curly quotes in + Claude's replies arrived corrupted before being bolded. The hook now reads its + event as bytes and lets JSON decode it. Thanks to @aermak for the report. +- `/claude-bionify:status` and the other control commands no longer emit an + undecodable separator on Windows. The status line is now written as UTF-8 + bytes instead of being encoded with the platform codepage, which produced a + broken glyph on Western systems and failed outright on Japanese ones. +- `assets/generate_themes.py` reads and writes UTF-8 explicitly, so regenerating + `themes.svg` produces the same file on any platform. + +## [1.0.2] - 2026-07-12 + +### Changed +- The claude-bionify skill now confirms the plugin is installed before giving + settings or command guidance. Skill marketplaces can surface the skill on its + own, so when the plugin is missing the skill now says so and points to the + install commands instead of walking through controls that are not there. + ## [1.0.1] - 2026-07-04 ### Fixed diff --git a/plugins/claude-bionify/README.md b/plugins/claude-bionify/README.md index 9d5e167..f4d9933 100644 --- a/plugins/claude-bionify/README.md +++ b/plugins/claude-bionify/README.md @@ -10,7 +10,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Claude Code](https://img.shields.io/badge/Claude%20Code-plugin-d97757) ![Python](https://img.shields.io/badge/python-3.10%2B-blue) -![Version](https://img.shields.io/badge/version-1.0.1-success) +![Version](https://img.shields.io/badge/version-1.0.6-success) @@ -81,7 +81,14 @@ falls back to the original. ## Requirements -- Claude Code with plugin support · `python3` on your `PATH` · a terminal that renders markdown bold +- Claude Code 2.1.152+ (which added the `MessageDisplay` hook), Python 3.10+, and a terminal + that renders markdown bold + +The hook starts Python as `python3`, and on Windows also through the `py` launcher, because the +python.org installer ships no `python3.exe` and Windows' own Microsoft Store placeholder of that +name exits without running Python. On such a machine the bolding works but the slash commands do +not, since they go through a shell where no fallback syntax suits both Git Bash and PowerShell; +installing Git for Windows or putting a real `python3` on `PATH` restores them. ## Terminal compatibility diff --git a/plugins/claude-bionify/commands/status.md b/plugins/claude-bionify/commands/status.md index faf5b77..f28a709 100644 --- a/plugins/claude-bionify/commands/status.md +++ b/plugins/claude-bionify/commands/status.md @@ -5,4 +5,4 @@ allowed-tools: Bash(python3 *) !`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/control.py" status` -The command above printed claude-bionify's current state. Relay that single line to the user and take no further action. +The command above printed claude-bionify's current state, and a warning line if the hook cannot start. Relay exactly what it printed to the user and take no further action. diff --git a/plugins/claude-bionify/hooks/hooks.json b/plugins/claude-bionify/hooks/hooks.json index 917b528..353234b 100644 --- a/plugins/claude-bionify/hooks/hooks.json +++ b/plugins/claude-bionify/hooks/hooks.json @@ -9,6 +9,12 @@ "command": "python3", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/bionify.py"], "timeout": 10 + }, + { + "type": "command", + "command": "py", + "args": ["-3", "${CLAUDE_PLUGIN_ROOT}/scripts/bionify.py"], + "timeout": 10 } ] } diff --git a/plugins/claude-bionify/scripts/bionify.py b/plugins/claude-bionify/scripts/bionify.py index 871b0f7..60f6b6f 100755 --- a/plugins/claude-bionify/scripts/bionify.py +++ b/plugins/claude-bionify/scripts/bionify.py @@ -12,6 +12,7 @@ Code falls back to the original text. Set CLAUDE_BIONIFY_DEBUG=1 to re-raise instead. """ +import contextlib import json import os import re @@ -55,10 +56,8 @@ def _fence_path(data_dir: str, message_id: str) -> str: def _remove_quietly(path: str) -> None: - try: + with contextlib.suppress(OSError): os.remove(path) - except OSError: - pass def read_fence_state(message_id: str, index: int | None) -> bool: @@ -77,28 +76,44 @@ def read_fence_state(message_id: str, index: int | None) -> bool: return False -def write_fence_state(message_id: str, inside_fence: bool, final: bool) -> None: - """Persist fence state for the next delta, or clear it when the message ends.""" +def write_fence_state(message_id: str, inside_fence: bool) -> None: + """Persist fence state for the next delta. + + Written to a temporary file and moved into place, because Claude Code allows + several flushes of one message to be in flight at once. A plain truncating + write would let a concurrent reader see an empty file and treat a code block + as prose. + """ data_dir = _fence_dir() if not data_dir or not message_id: return path = _fence_path(data_dir, message_id) + tmp = f"{path}.tmp-{os.getpid()}" try: - if final: - _remove_quietly(path) - else: - os.makedirs(data_dir, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write("1" if inside_fence else "0") + os.makedirs(data_dir, exist_ok=True) + with open(tmp, "w", encoding="utf-8") as f: + f.write("1" if inside_fence else "0") + os.replace(tmp, path) except OSError: - pass + _remove_quietly(tmp) + + +def clear_fence_state(message_id: str) -> None: + """Drop the fence file once a message has ended.""" + data_dir = _fence_dir() + if not data_dir or not message_id: + return + _remove_quietly(_fence_path(data_dir, message_id)) def sweep_stale_state(current_message_id: str) -> None: """Drop fence files left by earlier messages that never sent a final delta. - A session streams one message at a time, so when a new message starts every - other fence file is safe to remove. + Also collects temporary files orphaned by a killed process, which is why the + match is on the `fence-` prefix alone rather than the `.state` suffix. The + keep test is a prefix match so the current message's in-flight temporary + files survive too: a concurrent flush may be between writing its temporary + file and moving it into place while this sweep runs. """ data_dir = _fence_dir() if not data_dir: @@ -107,8 +122,7 @@ def sweep_stale_state(current_message_id: str) -> None: if current_message_id else None) try: for entry in os.listdir(data_dir): - if (entry.startswith("fence-") and entry.endswith(".state") - and entry != keep): + if entry.startswith("fence-") and not (keep and entry.startswith(keep)): _remove_quietly(os.path.join(data_dir, entry)) except OSError: pass @@ -117,9 +131,9 @@ def sweep_stale_state(current_message_id: str) -> None: class DisplayEvent(NamedTuple): """The MessageDisplay payload, parsed from Claude Code's raw hook event. - Claude Code streams an assistant message as a sequence of these and names its - fields in camelCase (`messageId`); `parse_event` is the one place that maps - them onto the names the rest of the module uses. + Claude Code streams an assistant message as a sequence of these, one per + flush of newly completed lines. `parse_event` is the one place that reads the + wire format. """ delta: str message_id: str # keys the per-message fence state @@ -130,13 +144,14 @@ class DisplayEvent(NamedTuple): def parse_event(raw: dict) -> DisplayEvent: """Read the fields the hook needs from a raw MessageDisplay event. - `messageId` is Claude Code's field; `session_id` is a guaranteed fallback so - the fence-state key is never empty, since an empty key would let code blocks - that span streamed deltas get bolded. + Claude Code sends `message_id`; `messageId` is accepted for older builds. + `session_id` is the floor because the base hook payload always carries it, + and an empty key would let code blocks spanning deltas get bolded. """ return DisplayEvent( delta=raw.get("delta") or "", - message_id=str(raw.get("messageId") or raw.get("session_id") or ""), + message_id=str(raw.get("message_id") or raw.get("messageId") + or raw.get("session_id") or ""), index=raw.get("index"), final=bool(raw.get("final")), ) @@ -144,8 +159,14 @@ def parse_event(raw: dict) -> DisplayEvent: def main() -> None: try: - event = parse_event(json.loads(sys.stdin.read() or "{}")) + # JSON is UTF-8 on the wire; sys.stdin would apply the locale encoding. + event = parse_event(json.loads(sys.stdin.buffer.read() or b"{}")) if not event.delta: + # Only the final flush can arrive empty, and it does whenever the + # message ends on a newline. Nothing is left to bold, but the fence + # file still has to go, since no later flush will clear it. + if event.final: + clear_fence_state(event.message_id) return style = load_config() @@ -156,14 +177,18 @@ def main() -> None: sweep_stale_state(event.message_id) inside_fence = read_fence_state(event.message_id, event.index) display, inside_fence = core.transform(event.delta, inside_fence, style) - write_fence_state(event.message_id, inside_fence, event.final) + if event.final: + clear_fence_state(event.message_id) + else: + write_fence_state(event.message_id, inside_fence) + # ensure_ascii keeps the payload ASCII, so stdout encodes under any locale. json.dump({ "hookSpecificOutput": { "hookEventName": "MessageDisplay", "displayContent": display, } - }, sys.stdout) + }, sys.stdout, ensure_ascii=True) except Exception: # Crash-safe: emit nothing so Claude Code renders the original text. if os.environ.get("CLAUDE_BIONIFY_DEBUG"): diff --git a/plugins/claude-bionify/scripts/control.py b/plugins/claude-bionify/scripts/control.py old mode 100644 new mode 100755 index fb392ba..acf6cbc --- a/plugins/claude-bionify/scripts/control.py +++ b/plugins/claude-bionify/scripts/control.py @@ -14,15 +14,42 @@ command never errors. """ +import os import sys +import interpreters import overrides import settings +_PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +_PROBE_TIMEOUT = 5 # shorter than the hook's, so a wedged interpreter cannot stall a command + + +def health_warning() -> str | None: + """Warn when this command runs but the hook's interpreters do not. + + A shell resolves names the hook's exec-form spawn cannot, so without this + `status` reports ON on a machine where nothing is being bolded. + """ + try: + healthy = interpreters.any_usable(_PLUGIN_ROOT, timeout=_PROBE_TIMEOUT) + except (OSError, ValueError): + # Unreadable hooks.json means nothing is bolding either, and saying so + # beats the silence that hid this class of failure in the first place. + return "claude-bionify: WARNING the hook configuration could not be read" + except Exception: + return None + if not healthy: + return ("claude-bionify: WARNING nothing is being bolded, because no " + "interpreter the hook declares could be started") + return None + def _apply_set(state: dict, rest: list) -> tuple[dict, str]: if len(rest) < 2: - return state, "claude-bionify: set " + return state, ("claude-bionify: set " + " ") key, value = rest[0].lower(), rest[1] setting = settings.by_cli_key(key) if setting is None: @@ -62,7 +89,12 @@ def main(argv: list) -> None: overrides.clear() else: overrides.save(new_state) - print(message) + if argv and argv[0].lower() == "status": + warning = health_warning() + if warning: + message = f"{message}\n{warning}" + # Output is read as UTF-8; print() would apply the locale encoding. + sys.stdout.buffer.write(message.encode("utf-8") + b"\n") if __name__ == "__main__": diff --git a/plugins/claude-bionify/scripts/interpreters.py b/plugins/claude-bionify/scripts/interpreters.py new file mode 100644 index 0000000..bf9a66c --- /dev/null +++ b/plugins/claude-bionify/scripts/interpreters.py @@ -0,0 +1,117 @@ +"""Whether the MessageDisplay hook can start. + +hooks.json launches its interpreters in exec form, which Claude Code spawns with +no shell, so the name must resolve to a real executable. Windows ships a +Microsoft Store placeholder named python3.exe that spawns and exits without +running Python, so a candidate is judged by running it, not by finding it. +""" + +import json +import os +import shutil +import subprocess +import sys +from typing import NamedTuple + +MINIMUM = (3, 10) +PROBE_TIMEOUT = 10 +_PROBE = "import sys; print(sys.version_info[0], sys.version_info[1])" + + +class Candidate(NamedTuple): + """An interpreter the hook could be launched with.""" + + command: str + args: tuple[str, ...] = () + + +def candidate_of(hook: dict) -> Candidate | None: + """The interpreter one hooks.json entry launches, flags included.""" + command = hook.get("command") + if not isinstance(command, str) or not command: + return None + if not isinstance(hook.get("args"), list): + return None + flags = tuple(str(a) for a in hook["args"] if not str(a).endswith(".py")) + return Candidate(command, flags) + + +def _entries(hooks_json: dict) -> list[dict]: + """The MessageDisplay hook entries, ignoring anything oddly shaped. + + A malformed file yields no entries rather than an exception, so it cannot + take a slash command down with it. + """ + if not isinstance(hooks_json, dict) or not isinstance(hooks_json.get("hooks"), dict): + return [] + groups = hooks_json["hooks"].get("MessageDisplay") + if not isinstance(groups, list): + return [] + return [hook + for group in groups if isinstance(group, dict) + and isinstance(group.get("hooks"), list) + for hook in group["hooks"] if isinstance(hook, dict)] + + +def declared(hooks_json: dict) -> tuple[Candidate, ...]: + """Every interpreter the MessageDisplay hook declares, in order.""" + found = [] + for hook in _entries(hooks_json): + candidate = candidate_of(hook) + if candidate is not None and candidate not in found: + found.append(candidate) + return tuple(found) + + +def parse_version(raw: bytes) -> tuple[int, int] | None: + """Read `(major, minor)` from the last line the probe printed. + + Only the last line counts: a sitecustomize or a conda banner can print ahead + of it, and reading the whole stream would call a healthy interpreter dead. + """ + lines = raw.decode("utf-8", "replace").strip().splitlines() + parts = lines[-1].split() if lines else [] + try: + return int(parts[0]), int(parts[1]) + except (IndexError, ValueError): + return None + + +def found_only_beside_prober(command: str) -> bool: + """Whether Windows resolved this name from our own directory rather than PATH. + + CreateProcess searches the calling process's directory first, so a Python + probing for `python` finds its own sibling, which the hook cannot reach. + """ + if os.name != "nt": + return False + beside = os.path.join(os.path.dirname(sys.executable), f"{command}.exe") + if not os.path.exists(beside): + return False + on_path = shutil.which(command) + return on_path is None or not os.path.samefile(on_path, beside) + + +def starts(candidate: Candidate, timeout: int = PROBE_TIMEOUT) -> bool: + """Whether running `candidate` yields a Python the hook could use.""" + argv = [candidate.command, *candidate.args, "-c", _PROBE] + try: + done = subprocess.run(argv, capture_output=True, timeout=timeout) + except (OSError, subprocess.SubprocessError): + return False + if done.returncode != 0: # the Store placeholder lands here, exiting 9009 + return False + if found_only_beside_prober(candidate.command): + return False + version = parse_version(done.stdout) + return version is not None and version >= MINIMUM + + +def load_hooks(plugin_root: str) -> dict: + with open(os.path.join(plugin_root, "hooks", "hooks.json"), encoding="utf-8") as f: + return json.load(f) + + +def any_usable(plugin_root: str, timeout: int = PROBE_TIMEOUT) -> bool: + """Whether the hook can start, stopping at the first interpreter that works.""" + return any(starts(c, timeout) for c in declared(load_hooks(plugin_root))) diff --git a/plugins/claude-bionify/scripts/overrides.py b/plugins/claude-bionify/scripts/overrides.py index debdab9..bace1f0 100644 --- a/plugins/claude-bionify/scripts/overrides.py +++ b/plugins/claude-bionify/scripts/overrides.py @@ -7,6 +7,7 @@ optional `enabled` flag; this module stays agnostic about its contents. """ +import contextlib import json import os @@ -48,7 +49,5 @@ def save(state: dict) -> None: def clear() -> None: """Remove the override file, reverting to the configured defaults.""" - try: + with contextlib.suppress(OSError): os.remove(path()) - except OSError: - pass diff --git a/plugins/claude-bionify/scripts/settings.py b/plugins/claude-bionify/scripts/settings.py index 92650b2..a542a06 100644 --- a/plugins/claude-bionify/scripts/settings.py +++ b/plugins/claude-bionify/scripts/settings.py @@ -7,9 +7,9 @@ than across several modules. """ +import re from collections.abc import Callable from dataclasses import dataclass -import re from typing import NamedTuple DEFAULT_FIXATION = 0.5 diff --git a/plugins/claude-bionify/skills/claude-bionify/SKILL.md b/plugins/claude-bionify/skills/claude-bionify/SKILL.md index c86d8cc..e181649 100644 --- a/plugins/claude-bionify/skills/claude-bionify/SKILL.md +++ b/plugins/claude-bionify/skills/claude-bionify/SKILL.md @@ -11,6 +11,36 @@ Background knowledge for answering questions about the claude-bionify plugin, wh bolds the leading part of each word in Claude's responses (cosmetic bionic reading). Use it to recommend the right command and the exact key a user needs. +## Prerequisite: confirm the plugin is installed + +This skill is only useful when the **claude-bionify plugin** is installed. The skill and +the plugin are separate: skill marketplaces can surface this `SKILL.md` on its own, so a +user may have loaded the skill without the plugin that actually does the bolding. Without +the plugin there is no `MessageDisplay` hook and no `/claude-bionify:*` commands, so every +instruction below is inert. + +Before advising on settings or commands, verify the plugin is present: + +```bash +grep -q 'claude-bionify' ~/.claude/plugins/installed_plugins.json 2>/dev/null \ + && echo "plugin: installed" \ + || echo "plugin: MISSING" +``` + +If the check fails (or the file does not exist), the plugin is not installed. Tell the +user plainly that they have the skill but not the plugin, then give the two install steps +and offer to run them: + +``` +/plugin marketplace add abullard1/claude-bionify +/plugin install claude-bionify@claude-bionify +``` + +These are slash commands the user runs in Claude Code; you cannot invoke them yourself, so +present them for the user to run (or paste) and confirm once bolding appears on the next +response. Only continue with the settings and command guidance below once the plugin is +confirmed installed. + ## When to use Load this skill when the user wants to: