Metadata-Version: 2.4
Name: kbd-signal
Version: 1.3.0
Summary: Turn a VIA-compatible RGB keyboard's backlight into a status lamp for Claude Code / Codex / Grok / Cursor (raw HID, stock firmware)
Author: sora (Sora-bluesky)
License: MIT
Project-URL: Repository, https://github.com/Sora-bluesky/kbd-signal
Keywords: keychron,via,qmk,rgb,claude-code,codex,grok,cursor,hid
Classifier: Environment :: Console
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Hardware
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: hidapi>=0.14
Dynamic: license-file

# kbd-signal

[English](README.md) | [日本語](README.ja.md)

Turn a **VIA-compatible RGB keyboard's backlight into a status lamp** for AI coding agents on Windows and macOS (defaults target the Keychron K8 Pro). When Claude Code, Codex, or Grok waits for your approval, your keyboard starts breathing orange — no need to watch the screen.

Works on **stock firmware** (no flashing) by speaking the VIA raw HID protocol directly.

## Signals

| State | Trigger | Effect |
|-------|---------|--------|
| `waiting` | Claude Code / Codex show a permission dialog (`PermissionRequest` hook); Grok shows one (`Notification` / `permission_prompt`) | Orange breathing |
| `done` | Main turn finished (`Stop` hook; Cursor: `stop` with `status: completed`) | Solid green for 5 s, then auto-restore |
| `error` | Manual `kbd-signal set error` | Fast red breathing |

Before signaling, the current lighting (effect / speed / brightness / color) is snapshotted and restored afterwards. **Nothing is ever written to EEPROM** (RAM-only changes), so a power cycle always returns the keyboard to your saved settings.

## Requirements & limitations

- Windows or macOS, Python 3.11+ (`hidapi` is the only dependency; its macOS wheel is self-contained via IOKit, so no Homebrew `hidapi` is needed)
- Keychron K8 Pro connected via **USB cable with the rear switch set to "Cable"**. Raw HID is not available over Bluetooth — measured: in BT mode with the cable attached, the USB HID collections enumerate but the `0xFF60` raw interface does not
- When the keyboard is absent (BT mode, unplugged), the hook-facing commands (`hook`, `set`, `restore`) silently no-op with exit 0 — hooks are never blocked. Diagnostic commands (`setup`, `export`, `detect`, `test`, `raw-effect`) report the missing device and exit 1
- Do not run the VIA app / Keychron Launcher at the same time (concurrent raw HID writes race)
- Codex requires a version with lifecycle hooks; run `codex features list` and confirm that `hooks` is enabled
- Grok requires Grok Build with lifecycle hooks (verified on 1.0.3); check with `/hooks` inside a grok session
- Cursor support is completion-notify only (green on a finished turn) — Cursor's hooks API has no approval-wait event, so `waiting` cannot be signaled there yet (verified on 3.16.17)
- Concurrent Claude Code / Codex / Grok / Cursor sessions and subagents are tracked independently; orange remains active while any approval is pending

## Platform support

| OS | Status | Hardware verification |
|----|--------|-----------------------|
| Windows | ✅ Supported | Keychron K8 Pro (default), real hardware |
| macOS | ✅ Supported | Keychron Q1 HE 8K, real hardware¹ |
| Linux | 🧪 Best-effort | CI only — no hardware verification |

¹ The macOS raw-HID path (`0xFF60` enumerate / open / protocol-probe, plus a Claude Code hook `waiting → done → restore`) is verified on a **Q1 HE 8K**. The **default K8 Pro has not been round-tripped on macOS** — the protocol layer is shared with the (verified) Windows path so it should work, but it is unconfirmed; a report either way is welcome.

Linux runs the same POSIX code path as macOS and is exercised in CI, but has no hardware verification. The PyPI classifiers list Windows and macOS only.

## Install

Recommended: [pipx](https://pipx.pypa.io/) — installs into an isolated environment and puts `kbd-signal` on PATH, which is exactly what the hook commands need:

```
pipx install kbd-signal
```

**Windows** — if you don't have pipx yet (one-time setup):

```powershell
py -m pip install --user pipx
py -m pipx ensurepath   # then open a new terminal
```

Plain pip also works (`py -m pip install .`); in that case invoke the hooks with the **same interpreter** you installed into: `py -m kbd_signal hook claude`.

**macOS** — install pipx with Homebrew if you don't have it (`brew install pipx && pipx ensurepath`), then `pipx install kbd-signal`. The `hidapi` wheel is self-contained (IOKit backend), so **no `brew install hidapi`** is required. Config, state, and log live in `~/Library/Application Support/kbd-signal/`.

## Usage

```
kbd-signal setup                 # interactive first-run config for a new keyboard
kbd-signal export                # print a docs/devices + examples preset skeleton
kbd-signal detect                # find the keyboard, show protocol & current lighting
kbd-signal set <waiting|done|error>
kbd-signal restore [--after N] [--gen G]
kbd-signal test                  # play all patterns, then restore
kbd-signal raw-effect <n>        # set a raw effect index (debug)
kbd-signal hook claude           # entry point for Claude Code hooks (JSON on stdin)
kbd-signal hook codex [<json>]   # Codex hooks (stdin) / legacy notify (argv)
kbd-signal hook grok             # Grok Build hooks (JSON on stdin)
kbd-signal hook cursor           # Cursor hooks (JSON on stdin; completion only)
```

### Restore mode (`config.json` in the state dir)

The state dir is `%LOCALAPPDATA%\kbd-signal` on Windows and `~/Library/Application Support/kbd-signal` on macOS.

```json
{"restore": "off"}
```

- `"baseline"` (default): restore the pre-signal effect and brightness
- `"off"`: restore to brightness 0 (for people who normally keep the backlight dark). The stored effect/color/speed are still written back, so waking the backlight with Fn shows your own settings

### What each signal looks like (`states` in `config.json`)

Colour, speed and brightness per state. Omit the block and you get the shipped
orange / green / red; override only the fields you want to change.

```json
{
  "states": {
    "waiting": {"effect": "solid", "hue": 0},
    "done": {"effect": "solid", "hue": 85}
  }
}
```

`effect` names an entry in `device.effects` rather than a raw index, so the
same block keeps working on a board whose enabled-animation list differs — the
device block says what an index means there, this says which meaning each
signal uses. To use an animation the defaults never named, add it to
`effects` (`"effects": {"solid": 1, "breathing": 2, "rainbow": 12}`) and name
it here.

Hues are the QMK wheel: red 0, orange 21, green 85. `hue`, `sat`, `speed` and
`brightness` are all 0-255. A value outside that range, an unknown effect name,
or a misspelled state is rejected with a message naming it — the block is only
ever hand-edited, so a typo that quietly kept the old colour would look like
the setting doing nothing.

The Fn backlight on/off flag is not readable over VIA, which is why `"off"` exists.

### Troubleshooting

If restores keep bringing back a signal color (or an Fn wake-up shows one), power-cycle the keyboard. Nothing is ever written to EEPROM, so it always comes back with your real settings.

If a setup that used to come back lit starts staying dark between signals in `"baseline"` mode (in `"off"` mode that is the configured behavior), the likely cause is the snapshot guard discarding a baseline that looked like a leftover signal (writing it back would restore the signal forever). kbd-signal falls back to the last capture that did not look like a signal; until one exists — right after an upgrade or a fresh install — power-cycle the keyboard or switch to a different lighting pattern so your own settings are actually visible, and the next signal captures them. An Fn wake-up alone is not enough here: it can raise the brightness on the leftover signal itself.

## Claude Code integration

Register the same command for `PermissionRequest`, `PostToolUse`, `Stop`, and `SessionEnd` in your user-scope `settings.json` (events are dispatched internally by `hook_event_name`):

```json
{"type": "command", "command": "kbd-signal hook claude", "timeout": 5}
```

Ready-to-merge: the `hooks` object in [examples/claude-hooks.json](examples/claude-hooks.json) covers all four events — merge it into your `settings.json` `hooks` (do not overwrite existing entries).

(pipx install — the `kbd-signal` shim is on PATH. With a plain pip install, use `py -m kbd_signal hook claude` instead, matching the interpreter you installed into.)

**Do not put a filesystem path in the program position.** Hook commands may run through either `cmd` or a POSIX shell: backslashed paths get eaten as escapes by the POSIX shell, and forward-slashed program paths fail under `cmd` with "Access is denied" — both silently, so the hook simply never signals (measured on Windows 11). PATH-resolved names (`kbd-signal`, `py -m kbd_signal`) work under both. The entry point is cheap when idle (the hidapi DLL is imported lazily), so the same command is fine for hot hooks like `PostToolUse`.

That `cmd`-vs-POSIX hazard is Windows-only. On macOS/Linux an absolute path in the program position is safe, so if `kbd-signal` is not on the PATH your hooks run under, use its absolute path — pipx installs the shim at `~/.local/bin/kbd-signal`.

## Codex integration (since v0.3.0)

Use Codex lifecycle hooks. They are separate from the `notify` entry in `~/.codex/config.toml`, so **leave the existing `notify` command unchanged**.

1. Run `codex features list` and confirm that `hooks` is enabled
2. Merge the events from [examples/codex-hooks.json](examples/codex-hooks.json) into the user-level `~/.codex/hooks.json`; do not overwrite an existing file
3. Start the Codex CLI and choose `Review hooks` from the startup `Hooks need review` prompt, or open `/hooks`. Verify the source, event, and command before trusting them. Trust is tied to the hook definition hash, so review it again after any change
4. In a new session, trigger an approval and verify orange while waiting and restoration after approval

Every event uses the same command:

```json
{
  "type": "command",
  "command": "kbd-signal hook codex",
  "timeout": 5
}
```

The configuration uses:

- `PermissionRequest` to add a pending approval
- `PostToolUse` to release only the agent that completed its tool
- `Stop` to release the main session and signal completion only when no other session is waiting
- `SubagentStop` to clean up a child without flashing green for the whole task
- `SessionStart` / `UserPromptSubmit` to clean up stale entries for the same session after an interrupted run

Codex does not expose `SessionEnd`. If Codex is force-closed while an approval is pending and that session is never resumed, orange can remain active; run `kbd-signal restore` to recover.

The old `agent-turn-complete` notify payload remains supported for compatibility, but it cannot report approval waits and competes with the desktop app's notifier, so it is not recommended for new installations.

### Concurrent sessions

Owners are keyed by product, `session_id`, and `agent_id`. A main-session completion therefore cannot clear another Claude/Codex/Grok/Cursor session or one of its subagents. Updates to `state.json` remain serialized by the existing interprocess lock.

To roll back, remove only the entries whose command invokes `kbd_signal hook codex` from `~/.codex/hooks.json`, then restart Codex. The desktop app's `notify` configuration remains untouched.

## Grok integration (since v1.2.0)

Grok Build (the xAI `grok` CLI) ships Claude Code-compatible lifecycle hooks, and kbd-signal reads them as a third source. Verified against grok 1.0.3.

1. Save [examples/grok-hooks.json](examples/grok-hooks.json) as `~/.grok/hooks/kbd-signal.json` — global hooks are always trusted, no per-project trust step
2. Start grok and run `/hooks`; the Hooks tab should list the kbd-signal entries. Press `r` there to reload after edits, no restart needed
3. Trigger an approval and check: orange while waiting, restoration after approving

Every event uses the same command:

```json
{"type": "command", "command": "kbd-signal hook grok", "timeout": 5}
```

### How Grok differs from Claude Code

- No `PermissionRequest` event exists. An approval wait arrives as `Notification` with type `permission_prompt` — that entry is the only one with a matcher. Only tool-permission prompts light up today; other attention waits (a plan waiting for review, for example) have their own notification types and are not signaled yet
- **Matchers are regular expressions.** Claude's `"*"` is an invalid regex here; an omitted matcher is what matches everything
- The payload is camelCase (`hookEventName`, `sessionId`) with lowercase-snake event values (`"stop"`). `kbd-signal hook grok` translates to the internal vocabulary, and Grok sessions get their own `grok:` owners
- `Stop` fires twice: on turn completion (`reason: "end_turn"`) and again, observe-only, when the session closes (measured: `reason: "shutdown"`). Green shows only for `end_turn`; the close fire just releases stale approvals
- Grok's `Stop` hook is a blocking gate with a 600 s default timeout. The example pins `timeout: 5`, and the hook never writes to stdout, so the lamp cannot delay a turn from ending
- Esc / Ctrl+C interrupts fire no `Stop` at all — a pending approval is cleared by the next prompt in that session, or by the one-hour TTL
- `PostToolUseFailure` and `PermissionDenied` are registered because Grok fires no `PostToolUse` for a failed or denied tool; without them, orange would outlive a denial
- `StopFailure` (a turn ended by an API error) releases the session's pending approvals without showing any color
- A Grok subagent runs as its own session (measured on 1.0.3), so child approvals are cleaned by the same session-scoped release. Note grok ships subagents off by default (`GROK_SUBAGENTS`)
- Headless runs (`grok -p`) fire hooks too, so scripted grok calls end with the same green flash as interactive ones. Drop the `Stop` entry from your copy if that is noise for you

### Claude settings scanning

Grok also reads `~/.claude/settings.json` hooks by default, so an existing `kbd-signal hook claude` entry fires inside Grok sessions as well. That is a no-op by design: the Claude entry point looks for snake_case keys, finds no `session_id` in Grok's camelCase payload, writes one log line, and exits 0 (measured). No `claude:` owner is created for a Grok session and nothing double-signals. Setting `[compat.claude] hooks = false` in `~/.grok/config.toml` silences those log lines, but it disables **all** your Claude hooks inside Grok — weigh that before flipping it.

To roll back, delete `~/.grok/hooks/kbd-signal.json` and press `r` in `/hooks` (or restart grok).

## Cursor integration (since v1.3.0) — completion notify only

Cursor's hooks API (beta, verified on Cursor 3.16.17) has **no event for "the agent is waiting for your approval"** — there are open feature requests ([166947](https://forum.cursor.com/t/fire-a-hook-when-agent-waits-for-command-tool-approval/166947), [159912](https://forum.cursor.com/t/expose-agent-approval-waiting-state-via-hooks-cli-events/159912)), but today the closest hooks (`beforeShellExecution` and friends) fire for auto-approved runs too, which would mean orange during every tool run. So Cursor support is deliberately narrower than the other three sources: **green on a completed turn, nothing else**. No orange. If Cursor ships an approval event, `waiting` gets added.

Setup: merge the single `stop` entry from [examples/cursor-hooks.json](examples/cursor-hooks.json) into your `~/.cursor/hooks.json` (`"version": 1` at top level; do not overwrite existing hooks). Cursor picks it up on the next session.

```json
{"command": "kbd-signal hook cursor", "timeout": 5}
```

Only `stop` is registered on purpose: Cursor hooks are synchronous and blocking, and with no waiting state to clean up, registering lifecycle events would just insert a Python launch into every prompt submission for nothing.

What to expect, measured on 3.16.17:

- A turn that ends normally (`status: "completed"`) flashes green for 5 s, then restores. An interrupted or failed turn shows nothing
- kbd-signal writes nothing to stdout — load-bearing here, because Cursor reads a `stop` hook's stdout as JSON and a `followup_message` in it would resume the agent. The flip side: if *another* stop hook of yours returns `followup_message`, the agent resumes after the green flash — same class of caveat as Claude's stop gate
- Cursor can also run hooks from your `~/.claude/settings.json` (third-party compatibility, gated by a Cursor setting). With that scan on, a `kbd-signal hook claude` entry fires inside Cursor sessions with Cursor's own payload; that is a logged no-op (the lowercase `"stop"` value matches no Claude branch — measured). Nothing double-signals once the cursor entry is installed
- The reverse scan exists too: Grok reads `~/.cursor/hooks.json`, so `kbd-signal hook cursor` fires inside Grok sessions with Grok payloads — also a no-op (different envelope, pinned by tests)
- `cursor-agent` (the Cursor CLI) reads the same hooks file but fires a subset of events; kbd-signal's cursor path is verified on the IDE only

To roll back, remove the `stop` entry whose command invokes `kbd-signal hook cursor` from `~/.cursor/hooks.json`.

## Protocol notes (verified on hardware)

- Shipped K8 Pro stock firmware speaks **VIA protocol 9 (v2)** — not the v3 custom-channel layout found on the `wireless_playground` branch. The protocol is probed at open time (command `0x01`) and both layouts are supported:
  - v2: `[report_id 0x00, cmd, value_id, data...]`, value ids `0x80` brightness / `0x81` effect / `0x82` speed / `0x83` color (hue, sat)
  - v3 (protocol ≥ 11): `[report_id 0x00, cmd, channel=3, value_id, data...]`, value ids 1–4
- Commands: set `0x07`, get `0x08`, save `0x09` (**save is never used**)
- Effect indices are identical across firmware generations (`info.json` animation list matches): None=0, **Solid Color=1, Breathing=2**, … Solid Splash=22
- Device detection: VID `0x3434` + usage page `0xFF60` / usage `0x61` (PIDs differ per layout variant)

## Other keyboards (since v0.2.0)

The protocol layer is not K8 Pro specific: VIA v2 value ids are fixed by the VIA spec and Solid Color is always effect 1 in QMK. Point kbd-signal at another VIA-compatible RGB keyboard via `config.json`:

```json
{
  "restore": "off",
  "device": {
    "vendor_id": "0x3434",
    "product_id": null,
    "product_match": "K8",
    "v3_channel": 3,
    "reset_on_effect": false,
    "effects": {"solid": 1, "breathing": 2}
  }
}
```

Workflow for a new board: run **`kbd-signal setup`**. It picks the device,
confirms that the configured VIA v3 custom channel actually drives it, measures
whether the firmware needs `reset_on_effect`, and asks you to look at the
keyboard and say which effect index is steady and which pulses — the firmware's enabled-animation list is not
readable over raw HID, so those two indices can only come from your eyes. It
then writes the `device` block (keeping your previous `config.json` as
`config.json.bak`). Finish with `kbd-signal test`.

`setup` refuses to run while a signal is showing, since it captures the current
lighting to restore afterwards.

Once your board works, **`kbd-signal export`** prints the two files a device page
is made of — `examples/config.<board>.json` and a `docs/devices/<board>.md`
skeleton — with the detected values filled in and every human-only field marked
`TODO`. Fill those in and open a pull request so the next owner of your keyboard
does not have to repeat the work. It only reads from the device, so unlike
`setup` it is safe to run at any time.

By hand instead:

1. `kbd-signal detect --all` — list every raw-HID (0xFF60) device and copy its VID/PID into `config.json`
2. `kbd-signal raw-effect <n>` — step through effect indices until you find solid/breathing, then set `effects`
3. On a VIA v3 board, set `v3_channel` to the keyboard's `id_qmk_rgb_matrix` channel from its VIA definition (v2 boards ignore it)
4. `kbd-signal test`

Some firmware resets the color (to red) and brightness (to full) ~50–150 ms
*after* an effect change. Most boards don't, so this workaround is a per-device
opt-in: set `"reset_on_effect": true` only if `done` flashes or sticks red on
your board, and kbd-signal then holds the LEDs dark while it settles the color
across the reset window. (The Keychron Q1 HE 8K is one board known to need it.)

Boards without RGB (single-color backlight) are out of scope — states are color-coded.

### Verified devices

Presets confirmed on real hardware. Each page lists the board's VID/PID, protocol, effect indices, and quirks; copy the linked config's `device` block into your `config.json`.

- **Keychron Q1 HE 8K** — [notes](docs/devices/keychron-q1-he-8k.md) · [`config.q1-he-8k.json`](examples/config.q1-he-8k.json)

## License

MIT
