Skip to content
Open
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 .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-bionify/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions plugins/claude-bionify/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions plugins/claude-bionify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

</div>

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-bionify/commands/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions plugins/claude-bionify/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Expand Down
77 changes: 51 additions & 26 deletions plugins/claude-bionify/scripts/bionify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -130,22 +144,29 @@ 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")),
)


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()
Expand All @@ -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"):
Expand Down
36 changes: 34 additions & 2 deletions plugins/claude-bionify/scripts/control.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fixation|boundary|minlen|acronyms|urls|headings> <value>"
return state, ("claude-bionify: set "
"<fixation|boundary|minlen|acronyms|urls|headings> <value>")
key, value = rest[0].lower(), rest[1]
setting = settings.by_cli_key(key)
if setting is None:
Expand Down Expand Up @@ -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__":
Expand Down
Loading