claude-color 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_color-0.1.0/LICENSE +21 -0
- claude_color-0.1.0/PKG-INFO +143 -0
- claude_color-0.1.0/README.md +119 -0
- claude_color-0.1.0/pyproject.toml +36 -0
- claude_color-0.1.0/setup.cfg +4 -0
- claude_color-0.1.0/src/claude_color/__init__.py +1 -0
- claude_color-0.1.0/src/claude_color/__main__.py +4 -0
- claude_color-0.1.0/src/claude_color/cli.py +169 -0
- claude_color-0.1.0/src/claude_color/config.py +59 -0
- claude_color-0.1.0/src/claude_color/events.py +128 -0
- claude_color-0.1.0/src/claude_color/install.py +213 -0
- claude_color-0.1.0/src/claude_color/keyboard/__init__.py +0 -0
- claude_color-0.1.0/src/claude_color/keyboard/base.py +21 -0
- claude_color-0.1.0/src/claude_color/keyboard/factory.py +20 -0
- claude_color-0.1.0/src/claude_color/keyboard/hidapi.py +126 -0
- claude_color-0.1.0/src/claude_color/keyboard/qmkhid.py +53 -0
- claude_color-0.1.0/src/claude_color/lock.py +43 -0
- claude_color-0.1.0/src/claude_color/logging_setup.py +24 -0
- claude_color-0.1.0/src/claude_color/paths.py +45 -0
- claude_color-0.1.0/src/claude_color/procinfo.py +136 -0
- claude_color-0.1.0/src/claude_color/states.py +18 -0
- claude_color-0.1.0/src/claude_color/statestore.py +90 -0
- claude_color-0.1.0/src/claude_color.egg-info/PKG-INFO +143 -0
- claude_color-0.1.0/src/claude_color.egg-info/SOURCES.txt +44 -0
- claude_color-0.1.0/src/claude_color.egg-info/dependency_links.txt +1 -0
- claude_color-0.1.0/src/claude_color.egg-info/entry_points.txt +2 -0
- claude_color-0.1.0/src/claude_color.egg-info/requires.txt +4 -0
- claude_color-0.1.0/src/claude_color.egg-info/top_level.txt +1 -0
- claude_color-0.1.0/tests/test_baseline.py +44 -0
- claude_color-0.1.0/tests/test_cli_hook.py +74 -0
- claude_color-0.1.0/tests/test_cli_reconcile.py +58 -0
- claude_color-0.1.0/tests/test_cli_test.py +67 -0
- claude_color-0.1.0/tests/test_config.py +49 -0
- claude_color-0.1.0/tests/test_events.py +93 -0
- claude_color-0.1.0/tests/test_hidapi.py +94 -0
- claude_color-0.1.0/tests/test_install.py +117 -0
- claude_color-0.1.0/tests/test_install_task.py +27 -0
- claude_color-0.1.0/tests/test_liveness.py +129 -0
- claude_color-0.1.0/tests/test_lock.py +44 -0
- claude_color-0.1.0/tests/test_migration.py +39 -0
- claude_color-0.1.0/tests/test_notifications.py +94 -0
- claude_color-0.1.0/tests/test_procinfo.py +77 -0
- claude_color-0.1.0/tests/test_qmkhid.py +51 -0
- claude_color-0.1.0/tests/test_states.py +19 -0
- claude_color-0.1.0/tests/test_statestore.py +59 -0
- claude_color-0.1.0/tests/test_via_devices.py +127 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edocksil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-color
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drive any wired VIA-compatible RGB keyboard from Claude Code status
|
|
5
|
+
Author: Edocksil
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Edocksil/claude-color
|
|
8
|
+
Project-URL: Repository, https://github.com/Edocksil/claude-color
|
|
9
|
+
Project-URL: Issues, https://github.com/Edocksil/claude-color/issues
|
|
10
|
+
Keywords: claude-code,qmk,via,hid,rgb,keyboard,hooks,status
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: hidapi>=0.14
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# claude-color
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
**[Live docs & demo → edocksil.github.io/claude-color](https://edocksil.github.io/claude-color/)**
|
|
32
|
+
|
|
33
|
+
Drive a wired **VIA-compatible keyboard's** RGB from Claude Code status — see at a glance
|
|
34
|
+
whether Claude is working, waiting for you, or idle, without looking at the terminal.
|
|
35
|
+
|
|
36
|
+
- **working** → blue-purple
|
|
37
|
+
- **waiting for you** (permission / input) → amber
|
|
38
|
+
- **idle** (turn done, session alive) → deep green
|
|
39
|
+
- **Claude Code exits / no session running** → your normal lighting is restored
|
|
40
|
+
|
|
41
|
+
## Why claude-color
|
|
42
|
+
|
|
43
|
+
Built to be **correct under real conditions**, not just a demo:
|
|
44
|
+
|
|
45
|
+
- **Multi-session aware** — one keyboard, many Claude Code sessions, resolved by
|
|
46
|
+
priority (`waiting` > `working` > `idle`).
|
|
47
|
+
- **Survives crashes & sleep** — sessions are tracked by their Claude process;
|
|
48
|
+
dead ones are pruned, and a boot/logon/unlock reconcile restores your lighting
|
|
49
|
+
when no session is running.
|
|
50
|
+
- **Accurate status** — distinguishes a real permission prompt (amber) from a
|
|
51
|
+
finished-but-idle session (green) via Claude Code's `notification_type`.
|
|
52
|
+
- **Restores your lighting** — captures your real keyboard color and puts it back,
|
|
53
|
+
with a guard against capturing its own status colors.
|
|
54
|
+
- **Windows-first**, keyboard-generic (any wired VIA RGB keyboard), 85+ tests.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- **Windows 11**, Python 3.11+ — installed and run via [uv](https://docs.astral.sh/uv/)
|
|
59
|
+
(no system Python needed).
|
|
60
|
+
- **Any wired keyboard exposing a VIA raw-HID interface with an RGB matrix**,
|
|
61
|
+
connected by **USB cable**. VIA support is most commonly provided by QMK
|
|
62
|
+
firmware with VIA enabled, but only the VIA protocol is required — not QMK
|
|
63
|
+
itself. Developed and validated on the **Keychron Q6 Max**. **Wireless is not
|
|
64
|
+
supported** (wired only). See [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
|
65
|
+
- Optional but recommended: the [`qmk_hid`](https://github.com/FrameworkComputer/qmk_hid)
|
|
66
|
+
binary on your PATH (primary backend). Without it, the built-in Python `hidapi`
|
|
67
|
+
backend is used.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
uv run claude-color doctor # confirm the keyboard is detected
|
|
73
|
+
uv run claude-color test # cycle working/waiting/idle, then restore
|
|
74
|
+
uv run claude-color install # add hooks to %USERPROFILE%\.claude\settings.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
To make `claude-color` a global command (so Claude Code can invoke the hooks from any
|
|
78
|
+
terminal), install it as a uv tool — `--editable` keeps it tracking this repo:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
uv tool uninstall kbstatus # remove the old command (if previously installed)
|
|
82
|
+
uv tool install --editable . # exposes `claude-color` on PATH (~\.local\bin)
|
|
83
|
+
claude-color install # wire hooks (migrates any old kbstatus wiring)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Ensure the uv tool bin dir is on PATH so Claude Code and the scheduled task can
|
|
87
|
+
invoke `claude-color`.
|
|
88
|
+
|
|
89
|
+
## Demo
|
|
90
|
+
|
|
91
|
+
Run the color cycle any time to see (or show off) all three statuses. It cycles
|
|
92
|
+
**working → waiting → idle** and then restores your current lighting:
|
|
93
|
+
|
|
94
|
+
```powershell
|
|
95
|
+
claude-color test # ~2s per color (quick check)
|
|
96
|
+
claude-color test --dwell 30 # hold each color 30s (slow demo)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Configure
|
|
100
|
+
|
|
101
|
+
Edit `%LOCALAPPDATA%\claude-color\config.toml`:
|
|
102
|
+
|
|
103
|
+
- `backend` — `auto` (default), `qmk_hid`, or `hidapi`
|
|
104
|
+
- `solid_effect_index` — the VIA "solid color" effect index for your keymap. If
|
|
105
|
+
`claude-color test` shows an animation instead of a solid color, change this and
|
|
106
|
+
rerun `claude-color test`.
|
|
107
|
+
- `vid` / `pid` — hex, only needed if multiple HID devices match. Find yours with
|
|
108
|
+
`claude-color doctor` or `qmk_hid -l`.
|
|
109
|
+
- `[colors]` — H/S/V (0–255) per state
|
|
110
|
+
- `staleness_minutes` — a stuck session is forgotten after this (default 120)
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
```mermaid
|
|
115
|
+
flowchart LR
|
|
116
|
+
CC["Claude Code hooks"] -->|"SessionStart · UserPromptSubmit · Notification · Stop · SessionEnd"| K["claude-color hook"]
|
|
117
|
+
K --> S["state.json<br/>(per session, by PID)"]
|
|
118
|
+
S -->|"winner: waiting > working > idle"| KB["keyboard RGB<br/>(VIA raw-HID)"]
|
|
119
|
+
R["claude-color reconcile<br/>(logon / unlock)"] --> S
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Claude Code fires hooks (`SessionStart`, `UserPromptSubmit`, `Notification`,
|
|
123
|
+
`Stop`, `SessionEnd`). Each runs `claude-color hook`, which updates a per-session
|
|
124
|
+
state file (`%LOCALAPPDATA%\claude-color\state.json`) and pushes the highest-priority
|
|
125
|
+
color (`waiting` > `working` > `idle`) across all live sessions. Sessions are
|
|
126
|
+
tracked by their Claude process id; dead ones are pruned, and a scheduled
|
|
127
|
+
`claude-color reconcile` (logon/unlock) restores your lighting when no Claude is
|
|
128
|
+
running. When the last session ends, the lighting captured at first
|
|
129
|
+
`SessionStart` is restored.
|
|
130
|
+
|
|
131
|
+
## Troubleshooting
|
|
132
|
+
|
|
133
|
+
- **No color changes:** run `claude-color doctor`. If `available: False`, check the USB
|
|
134
|
+
cable (wireless is not supported) and try `backend = "hidapi"` or set `vid`/`pid`.
|
|
135
|
+
- **Animation instead of solid color:** fix `solid_effect_index`.
|
|
136
|
+
- **Colors never restore:** baseline capture may be unsupported on your firmware
|
|
137
|
+
via the active backend; try `backend = "hidapi"`. Logs are in
|
|
138
|
+
`%LOCALAPPDATA%\claude-color\claude-color.log`.
|
|
139
|
+
- **Uninstall:** `claude-color uninstall`.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE) — © 2026 Edocksil.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# claude-color
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
**[Live docs & demo → edocksil.github.io/claude-color](https://edocksil.github.io/claude-color/)**
|
|
8
|
+
|
|
9
|
+
Drive a wired **VIA-compatible keyboard's** RGB from Claude Code status — see at a glance
|
|
10
|
+
whether Claude is working, waiting for you, or idle, without looking at the terminal.
|
|
11
|
+
|
|
12
|
+
- **working** → blue-purple
|
|
13
|
+
- **waiting for you** (permission / input) → amber
|
|
14
|
+
- **idle** (turn done, session alive) → deep green
|
|
15
|
+
- **Claude Code exits / no session running** → your normal lighting is restored
|
|
16
|
+
|
|
17
|
+
## Why claude-color
|
|
18
|
+
|
|
19
|
+
Built to be **correct under real conditions**, not just a demo:
|
|
20
|
+
|
|
21
|
+
- **Multi-session aware** — one keyboard, many Claude Code sessions, resolved by
|
|
22
|
+
priority (`waiting` > `working` > `idle`).
|
|
23
|
+
- **Survives crashes & sleep** — sessions are tracked by their Claude process;
|
|
24
|
+
dead ones are pruned, and a boot/logon/unlock reconcile restores your lighting
|
|
25
|
+
when no session is running.
|
|
26
|
+
- **Accurate status** — distinguishes a real permission prompt (amber) from a
|
|
27
|
+
finished-but-idle session (green) via Claude Code's `notification_type`.
|
|
28
|
+
- **Restores your lighting** — captures your real keyboard color and puts it back,
|
|
29
|
+
with a guard against capturing its own status colors.
|
|
30
|
+
- **Windows-first**, keyboard-generic (any wired VIA RGB keyboard), 85+ tests.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- **Windows 11**, Python 3.11+ — installed and run via [uv](https://docs.astral.sh/uv/)
|
|
35
|
+
(no system Python needed).
|
|
36
|
+
- **Any wired keyboard exposing a VIA raw-HID interface with an RGB matrix**,
|
|
37
|
+
connected by **USB cable**. VIA support is most commonly provided by QMK
|
|
38
|
+
firmware with VIA enabled, but only the VIA protocol is required — not QMK
|
|
39
|
+
itself. Developed and validated on the **Keychron Q6 Max**. **Wireless is not
|
|
40
|
+
supported** (wired only). See [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
|
41
|
+
- Optional but recommended: the [`qmk_hid`](https://github.com/FrameworkComputer/qmk_hid)
|
|
42
|
+
binary on your PATH (primary backend). Without it, the built-in Python `hidapi`
|
|
43
|
+
backend is used.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
uv run claude-color doctor # confirm the keyboard is detected
|
|
49
|
+
uv run claude-color test # cycle working/waiting/idle, then restore
|
|
50
|
+
uv run claude-color install # add hooks to %USERPROFILE%\.claude\settings.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To make `claude-color` a global command (so Claude Code can invoke the hooks from any
|
|
54
|
+
terminal), install it as a uv tool — `--editable` keeps it tracking this repo:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
uv tool uninstall kbstatus # remove the old command (if previously installed)
|
|
58
|
+
uv tool install --editable . # exposes `claude-color` on PATH (~\.local\bin)
|
|
59
|
+
claude-color install # wire hooks (migrates any old kbstatus wiring)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Ensure the uv tool bin dir is on PATH so Claude Code and the scheduled task can
|
|
63
|
+
invoke `claude-color`.
|
|
64
|
+
|
|
65
|
+
## Demo
|
|
66
|
+
|
|
67
|
+
Run the color cycle any time to see (or show off) all three statuses. It cycles
|
|
68
|
+
**working → waiting → idle** and then restores your current lighting:
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
claude-color test # ~2s per color (quick check)
|
|
72
|
+
claude-color test --dwell 30 # hold each color 30s (slow demo)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Configure
|
|
76
|
+
|
|
77
|
+
Edit `%LOCALAPPDATA%\claude-color\config.toml`:
|
|
78
|
+
|
|
79
|
+
- `backend` — `auto` (default), `qmk_hid`, or `hidapi`
|
|
80
|
+
- `solid_effect_index` — the VIA "solid color" effect index for your keymap. If
|
|
81
|
+
`claude-color test` shows an animation instead of a solid color, change this and
|
|
82
|
+
rerun `claude-color test`.
|
|
83
|
+
- `vid` / `pid` — hex, only needed if multiple HID devices match. Find yours with
|
|
84
|
+
`claude-color doctor` or `qmk_hid -l`.
|
|
85
|
+
- `[colors]` — H/S/V (0–255) per state
|
|
86
|
+
- `staleness_minutes` — a stuck session is forgotten after this (default 120)
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
```mermaid
|
|
91
|
+
flowchart LR
|
|
92
|
+
CC["Claude Code hooks"] -->|"SessionStart · UserPromptSubmit · Notification · Stop · SessionEnd"| K["claude-color hook"]
|
|
93
|
+
K --> S["state.json<br/>(per session, by PID)"]
|
|
94
|
+
S -->|"winner: waiting > working > idle"| KB["keyboard RGB<br/>(VIA raw-HID)"]
|
|
95
|
+
R["claude-color reconcile<br/>(logon / unlock)"] --> S
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Claude Code fires hooks (`SessionStart`, `UserPromptSubmit`, `Notification`,
|
|
99
|
+
`Stop`, `SessionEnd`). Each runs `claude-color hook`, which updates a per-session
|
|
100
|
+
state file (`%LOCALAPPDATA%\claude-color\state.json`) and pushes the highest-priority
|
|
101
|
+
color (`waiting` > `working` > `idle`) across all live sessions. Sessions are
|
|
102
|
+
tracked by their Claude process id; dead ones are pruned, and a scheduled
|
|
103
|
+
`claude-color reconcile` (logon/unlock) restores your lighting when no Claude is
|
|
104
|
+
running. When the last session ends, the lighting captured at first
|
|
105
|
+
`SessionStart` is restored.
|
|
106
|
+
|
|
107
|
+
## Troubleshooting
|
|
108
|
+
|
|
109
|
+
- **No color changes:** run `claude-color doctor`. If `available: False`, check the USB
|
|
110
|
+
cable (wireless is not supported) and try `backend = "hidapi"` or set `vid`/`pid`.
|
|
111
|
+
- **Animation instead of solid color:** fix `solid_effect_index`.
|
|
112
|
+
- **Colors never restore:** baseline capture may be unsupported on your firmware
|
|
113
|
+
via the active backend; try `backend = "hidapi"`. Logs are in
|
|
114
|
+
`%LOCALAPPDATA%\claude-color\claude-color.log`.
|
|
115
|
+
- **Uninstall:** `claude-color uninstall`.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
[MIT](LICENSE) — © 2026 Edocksil.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "claude-color"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Drive any wired VIA-compatible RGB keyboard from Claude Code status"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Edocksil" }]
|
|
9
|
+
keywords = ["claude-code", "qmk", "via", "hid", "rgb", "keyboard", "hooks", "status"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Operating System :: Microsoft :: Windows",
|
|
16
|
+
"Topic :: Utilities",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["hidapi>=0.14"]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
claude-color = "claude_color.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Edocksil/claude-color"
|
|
28
|
+
Repository = "https://github.com/Edocksil/claude-color"
|
|
29
|
+
Issues = "https://github.com/Edocksil/claude-color/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["setuptools>=68"]
|
|
33
|
+
build-backend = "setuptools.build_meta"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from claude_color import config, events, install, paths, procinfo, statestore
|
|
7
|
+
from claude_color.keyboard.factory import get_backend
|
|
8
|
+
from claude_color.keyboard.hidapi import list_via_devices
|
|
9
|
+
from claude_color.lock import DirLock
|
|
10
|
+
from claude_color.logging_setup import get_logger
|
|
11
|
+
|
|
12
|
+
log = get_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def now() -> float:
|
|
16
|
+
return time.time()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_hook() -> int:
|
|
20
|
+
try:
|
|
21
|
+
raw = sys.stdin.read()
|
|
22
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
23
|
+
event_name = payload.get("hook_event_name")
|
|
24
|
+
session_id = payload.get("session_id") or "default"
|
|
25
|
+
if not event_name:
|
|
26
|
+
return 0
|
|
27
|
+
cfg = config.load_config()
|
|
28
|
+
keyboard = get_backend(cfg)
|
|
29
|
+
pid = procinfo.session_pid()
|
|
30
|
+
live = procinfo.live_claude_pids()
|
|
31
|
+
boot = procinfo.boot_time()
|
|
32
|
+
with DirLock(paths.lock_path()) as acquired:
|
|
33
|
+
if not acquired:
|
|
34
|
+
return 0
|
|
35
|
+
state = statestore.load_state(paths.state_path())
|
|
36
|
+
before = json.dumps(state, sort_keys=True)
|
|
37
|
+
events.handle_event(
|
|
38
|
+
event_name, session_id, now(), cfg, state, keyboard,
|
|
39
|
+
notification_type=payload.get("notification_type"),
|
|
40
|
+
pid=pid, live_pids=live, boot_time=boot,
|
|
41
|
+
)
|
|
42
|
+
after = json.dumps(state, sort_keys=True)
|
|
43
|
+
if before != after: # write-on-change
|
|
44
|
+
statestore.save_state(paths.state_path(), state)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
log.warning("hook error: %s", exc, exc_info=True)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_reconcile() -> int:
|
|
51
|
+
"""Prune sessions whose Claude process has exited and restore the user's
|
|
52
|
+
lighting if none remain. Meant to run on boot/unlock (scheduled task) so the
|
|
53
|
+
keyboard recovers after ungraceful exits that never fired SessionEnd."""
|
|
54
|
+
try:
|
|
55
|
+
cfg = config.load_config()
|
|
56
|
+
keyboard = get_backend(cfg)
|
|
57
|
+
live = procinfo.live_claude_pids()
|
|
58
|
+
boot = procinfo.boot_time()
|
|
59
|
+
with DirLock(paths.lock_path()) as acquired:
|
|
60
|
+
if not acquired:
|
|
61
|
+
return 0
|
|
62
|
+
state = statestore.load_state(paths.state_path())
|
|
63
|
+
before = json.dumps(state, sort_keys=True)
|
|
64
|
+
events.reconcile(cfg, state, keyboard, live, now(), boot)
|
|
65
|
+
after = json.dumps(state, sort_keys=True)
|
|
66
|
+
if before != after:
|
|
67
|
+
statestore.save_state(paths.state_path(), state)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
log.warning("reconcile error: %s", exc, exc_info=True)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _device_label(manufacturer: str, product: str) -> str:
|
|
74
|
+
"""Human-readable device name, avoiding a doubled brand when the product
|
|
75
|
+
string already starts with the manufacturer (e.g. "Keychron" + "Keychron
|
|
76
|
+
Q6 Max" -> "Keychron Q6 Max")."""
|
|
77
|
+
m, p = (manufacturer or "").strip(), (product or "").strip()
|
|
78
|
+
if not m:
|
|
79
|
+
return p
|
|
80
|
+
if not p:
|
|
81
|
+
return m
|
|
82
|
+
if p.lower().startswith(m.lower()):
|
|
83
|
+
return p
|
|
84
|
+
return f"{m} {p}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_doctor() -> int:
|
|
88
|
+
cfg = config.load_config()
|
|
89
|
+
keyboard = get_backend(cfg)
|
|
90
|
+
print(f"backend: {type(keyboard).__name__}")
|
|
91
|
+
print(f"available: {keyboard.available()}")
|
|
92
|
+
current = keyboard.get_hsv()
|
|
93
|
+
print(f"current lighting (baseline capture): {current}")
|
|
94
|
+
print(f"solid_effect_index (config): {cfg.get('solid_effect_index')}")
|
|
95
|
+
devices = list_via_devices()
|
|
96
|
+
if devices:
|
|
97
|
+
print("Detected VIA devices (usage page 0xFF60):")
|
|
98
|
+
for d in devices:
|
|
99
|
+
label = _device_label(d["manufacturer"], d["product"])
|
|
100
|
+
print(f" vid={d['vid']} pid={d['pid']} {label}".rstrip())
|
|
101
|
+
print("If the wrong device is picked, set vid/pid in config.toml.")
|
|
102
|
+
else:
|
|
103
|
+
print("No VIA devices detected (check the USB cable; wireless is not supported).")
|
|
104
|
+
print("If colors look wrong, adjust solid_effect_index in config.toml and rerun `claude-color test`.")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_test(dwell: float = 2.0) -> int:
|
|
109
|
+
"""Demo/test: cycle working -> waiting -> idle on the keyboard, holding each
|
|
110
|
+
color `dwell` seconds, then restore the current lighting as the baseline."""
|
|
111
|
+
cfg = config.load_config()
|
|
112
|
+
keyboard = get_backend(cfg)
|
|
113
|
+
effect = cfg.get("solid_effect_index", 1)
|
|
114
|
+
baseline = keyboard.get_hsv()
|
|
115
|
+
for name in ("working", "waiting", "idle"):
|
|
116
|
+
h, s, v = cfg["colors"][name]
|
|
117
|
+
print(f"{name:>8}: hsv=({h},{s},{v}) (holding {dwell}s)", flush=True)
|
|
118
|
+
keyboard.set_hsv(effect, h, s, v)
|
|
119
|
+
time.sleep(dwell)
|
|
120
|
+
if baseline is not None:
|
|
121
|
+
print(f"restoring baseline: {baseline}", flush=True)
|
|
122
|
+
keyboard.set_hsv(baseline.effect, baseline.hue, baseline.sat, baseline.val)
|
|
123
|
+
else:
|
|
124
|
+
print("baseline unavailable (backend can't read current lighting) - left on idle", flush=True)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_status() -> int:
|
|
129
|
+
state = statestore.load_state(paths.state_path())
|
|
130
|
+
print(json.dumps(state, indent=2))
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: list[str] | None = None) -> int:
|
|
135
|
+
parser = argparse.ArgumentParser(prog="claude-color")
|
|
136
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
137
|
+
sub.add_parser("hook", help="hook entrypoint (reads JSON on stdin)")
|
|
138
|
+
sub.add_parser("install", help="add hooks to Claude Code settings.json")
|
|
139
|
+
sub.add_parser("uninstall", help="remove hooks from Claude Code settings.json")
|
|
140
|
+
sub.add_parser("doctor", help="detect keyboard and report status")
|
|
141
|
+
test_parser = sub.add_parser(
|
|
142
|
+
"test", help="demo: cycle status colors on the keyboard, then restore"
|
|
143
|
+
)
|
|
144
|
+
test_parser.add_argument(
|
|
145
|
+
"--dwell", type=float, default=2.0,
|
|
146
|
+
help="seconds to hold each color (default: 2.0; e.g. 30 for a slow demo)",
|
|
147
|
+
)
|
|
148
|
+
sub.add_parser("status", help="print current state.json")
|
|
149
|
+
sub.add_parser(
|
|
150
|
+
"reconcile",
|
|
151
|
+
help="prune dead sessions and restore lighting if no Claude is running",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
args = parser.parse_args(argv)
|
|
155
|
+
if args.command == "hook":
|
|
156
|
+
return cmd_hook()
|
|
157
|
+
if args.command == "reconcile":
|
|
158
|
+
return cmd_reconcile()
|
|
159
|
+
if args.command == "install":
|
|
160
|
+
return install.cmd_install()
|
|
161
|
+
if args.command == "uninstall":
|
|
162
|
+
return install.cmd_uninstall()
|
|
163
|
+
if args.command == "doctor":
|
|
164
|
+
return cmd_doctor()
|
|
165
|
+
if args.command == "test":
|
|
166
|
+
return cmd_test(args.dwell)
|
|
167
|
+
if args.command == "status":
|
|
168
|
+
return cmd_status()
|
|
169
|
+
return 1
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import tomllib
|
|
3
|
+
|
|
4
|
+
from claude_color import paths
|
|
5
|
+
|
|
6
|
+
DEFAULTS: dict = {
|
|
7
|
+
"backend": "auto", # auto | qmk_hid | hidapi
|
|
8
|
+
"qmk_hid_path": "qmk_hid", # binary name or full path
|
|
9
|
+
"vid": "", # hex string, e.g. "3434"; "" = no filter
|
|
10
|
+
"pid": "", # hex string; "" = no filter
|
|
11
|
+
"solid_effect_index": 1, # VIA RGB-matrix "solid color" effect index
|
|
12
|
+
"staleness_minutes": 120,
|
|
13
|
+
"colors": {
|
|
14
|
+
"working": [180, 255, 200],
|
|
15
|
+
"waiting": [30, 255, 220],
|
|
16
|
+
"idle": [90, 255, 120],
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_TEMPLATE = """\
|
|
21
|
+
# claude-color configuration
|
|
22
|
+
backend = "auto" # auto | qmk_hid | hidapi
|
|
23
|
+
qmk_hid_path = "qmk_hid" # binary name on PATH, or a full path
|
|
24
|
+
vid = "" # hex, e.g. "3434"; empty = no device filter
|
|
25
|
+
pid = "" # hex; empty = no device filter
|
|
26
|
+
solid_effect_index = 1 # VIA RGB-matrix solid-color effect index (confirm via `claude-color doctor`)
|
|
27
|
+
staleness_minutes = 120
|
|
28
|
+
|
|
29
|
+
[colors] # H, S, V on QMK 0-255 scale
|
|
30
|
+
working = [180, 255, 200]
|
|
31
|
+
waiting = [30, 255, 220]
|
|
32
|
+
idle = [90, 255, 120]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _merge(base: dict, over: dict) -> dict:
|
|
37
|
+
out = copy.deepcopy(base)
|
|
38
|
+
for k, v in over.items():
|
|
39
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
40
|
+
out[k] = _merge(out[k], v)
|
|
41
|
+
else:
|
|
42
|
+
out[k] = v
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config() -> dict:
|
|
47
|
+
path = paths.config_path()
|
|
48
|
+
if not path.exists():
|
|
49
|
+
try:
|
|
50
|
+
path.write_text(_TEMPLATE, encoding="utf-8")
|
|
51
|
+
except Exception:
|
|
52
|
+
return copy.deepcopy(DEFAULTS)
|
|
53
|
+
return copy.deepcopy(DEFAULTS)
|
|
54
|
+
try:
|
|
55
|
+
with path.open("rb") as fh:
|
|
56
|
+
loaded = tomllib.load(fh)
|
|
57
|
+
return _merge(DEFAULTS, loaded)
|
|
58
|
+
except Exception:
|
|
59
|
+
return copy.deepcopy(DEFAULTS)
|