claude-buddy 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.
@@ -0,0 +1,22 @@
1
+ # Claude buddy commands
2
+
3
+ Claude Buddy — manage the animated taskbar companion.
4
+
5
+ Usage: /buddy [action]
6
+
7
+ Actions:
8
+ start — Launch the buddy on the taskbar (if not already running)
9
+ stop — Kill the running buddy
10
+ test — Send a test celebration to the running buddy
11
+ wave — Send a wave/attention signal to the running buddy
12
+ status — Check if the buddy is running
13
+
14
+ If no action is given, default to "start".
15
+
16
+ Implementation:
17
+ - `claude-buddy` is installed globally via `uv tool install`
18
+ - For "start": run `claude-buddy` in the background (detached)
19
+ - For "stop": find the buddy process via `netstat -ano | findstr 44556` to get the PID, then `taskkill /F /PID <pid>`
20
+ - For "test": run `claude-buddy --send "Test!"`
21
+ - For "wave": run `claude-buddy --wave`
22
+ - For "status": check if port 44556 is in use via `netstat -ano | findstr 44556`
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "Stop": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "claude-buddy --send done",
9
+ "timeout": 3000
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PermissionRequest": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "claude-buddy --wave",
20
+ "timeout": 3000
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+ *.whl
10
+
11
+ # Virtual environments
12
+ venv/
13
+ .venv/
14
+
15
+ # IDE
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+ *.swo
20
+
21
+ # OS
22
+ Thumbs.db
23
+ Desktop.ini
24
+ .DS_Store
25
+
26
+ # Env
27
+ .env
28
+ .env.local
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to Claude Buddy will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-04-12
8
+
9
+ ### Added
10
+
11
+ - Initial release
12
+ - Animated terminal character with idle, celebrate, and wave states
13
+ - Borderless transparent window positioned on the Windows taskbar
14
+ - Click-and-drag repositioning
15
+ - TCP socket listener (port 44556) for receiving signals
16
+ - `--send` flag to trigger celebration on a running instance
17
+ - `--wave` flag to trigger wave/attention animation
18
+ - `--test` flag to start with a celebration
19
+ - `--port` flag for custom TCP port
20
+ - `--no-topmost` flag to disable always-on-top
21
+ - Single-instance enforcement via lock socket
22
+ - System tray icon with context menu
23
+ - Claude Code hook support (`Stop` and `PermissionRequest` events)
24
+ - Confetti particle system during celebrations
25
+ - Floating pulsing "!" indicator during wave state
@@ -0,0 +1,82 @@
1
+ # Contributing to Claude Buddy
2
+
3
+ Thanks for your interest in contributing!
4
+
5
+ ## Development setup
6
+
7
+ ```bash
8
+ git clone https://github.com/ramym/claude-buddy.git
9
+ cd claude-buddy
10
+
11
+ # Create a venv and install in editable mode
12
+ uv venv
13
+ uv pip install -e ".[dev]"
14
+
15
+ # Or with pip
16
+ python -m venv venv
17
+ venv\Scripts\activate
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Running locally
22
+
23
+ ```bash
24
+ # Run from source
25
+ python -m claude_buddy.app
26
+
27
+ # Or if installed in editable mode
28
+ claude-buddy
29
+ ```
30
+
31
+ ## Project structure
32
+
33
+ ```text
34
+ claude-buddy/
35
+ ├── src/claude_buddy/
36
+ │ ├── __init__.py # Package metadata
37
+ │ └── app.py # All application code (rendering, state, socket, tray)
38
+ ├── .claude/
39
+ │ ├── settings.json # Claude Code hook definitions
40
+ │ └── commands/
41
+ │ └── buddy.md # /buddy slash command for Claude Code
42
+ ├── pyproject.toml # Package configuration
43
+ ├── README.md
44
+ ├── CHANGELOG.md
45
+ ├── CONTRIBUTING.md
46
+ └── LICENSE
47
+ ```
48
+
49
+ ## How the code is organized
50
+
51
+ Everything lives in `app.py` to keep the package simple:
52
+
53
+ - **Win32 helpers** — `ctypes` calls for transparency, positioning, taskbar detection
54
+ - **State machine** — `BuddyState` with three modes: `idle`, `celebrating`, `waving`
55
+ - **Drawing** — `draw_buddy()` renders the character based on current state and time
56
+ - **Socket listener** — TCP server on port 44556, parses JSON `{"action": "..."}` messages
57
+ - **System tray** — `pystray` icon with context menu
58
+ - **CLI** — `argparse` for `--send`, `--wave`, `--test`, etc.
59
+ - **Main loop** — pygame event loop at 60 FPS
60
+
61
+ ## Making changes
62
+
63
+ 1. Fork the repo and create a feature branch
64
+ 2. Make your changes in `src/claude_buddy/app.py`
65
+ 3. Test manually: `claude-buddy --test` (celebrate), `claude-buddy --wave` (wave signal)
66
+ 4. Update `CHANGELOG.md` under an `[Unreleased]` section
67
+ 5. Open a pull request
68
+
69
+ ## Adding a new animation state
70
+
71
+ 1. Add the state name to `BuddyState` (add a property and trigger method)
72
+ 2. Add drawing logic in `draw_buddy()` — follow the pattern of `cel`/`wav` branches
73
+ 3. Add a new action string in `socket_listener()` dispatch
74
+ 4. Add a CLI flag in `parse_args()` and handle it in `main()`
75
+ 5. Document the new hook in `README.md`
76
+
77
+ ## Guidelines
78
+
79
+ - Keep everything in `app.py` unless there's a strong reason to split
80
+ - No external assets — all rendering is procedural (pygame draw calls)
81
+ - Windows-only is fine for now; cross-platform support would need platform abstraction for the win32 calls
82
+ - Test all three states (idle, celebrate, wave) after any drawing changes
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramy M
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,266 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-buddy
3
+ Version: 0.1.0
4
+ Summary: Animated terminal pet that sits on your taskbar and reacts to Claude Code events
5
+ Project-URL: Homepage, https://github.com/ramym/claude-buddy
6
+ Project-URL: Repository, https://github.com/ramym/claude-buddy
7
+ Project-URL: Issues, https://github.com/ramym/claude-buddy/issues
8
+ Author-email: Ramy M <ramym@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: animation,buddy,claude,claude-code,pet,taskbar
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Win32 (MS Windows)
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pillow>=10.0
25
+ Requires-Dist: pygame-ce>=2.5
26
+ Requires-Dist: pystray>=0.19
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Claude Buddy
30
+
31
+ A tiny animated terminal pet that sits on your Windows taskbar and reacts to [Claude Code](https://claude.ai/code) events.
32
+
33
+ <!-- TODO: add a gif/screenshot here -->
34
+ <!-- ![Claude Buddy demo](docs/demo.gif) -->
35
+
36
+ ## What it does
37
+
38
+ Claude Buddy is a small always-on-top character that lives on your taskbar while you work with Claude Code:
39
+
40
+ | State | What happens |
41
+ | --- | --- |
42
+ | **Idle** | Gently bobs, blinks, breathes — your quiet companion |
43
+ | **Claude finishes** (`Stop` hook) | Celebrates with confetti, happy eyes, and waving arms |
44
+ | **Claude needs permission** (`PermissionRequest` hook) | Waves at you with a floating **!** so you know to check back |
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ # With uv (recommended — installs as an isolated tool)
50
+ uv tool install claude-buddy
51
+
52
+ # With pipx
53
+ pipx install claude-buddy
54
+
55
+ # With pip (into current environment)
56
+ pip install claude-buddy
57
+ ```
58
+
59
+ ### From source
60
+
61
+ ```bash
62
+ git clone https://github.com/ramym/claude-buddy.git
63
+ cd claude-buddy
64
+ uv tool install --from . claude-buddy
65
+ ```
66
+
67
+ ## Quick start
68
+
69
+ ### 1. Launch the buddy
70
+
71
+ ```bash
72
+ claude-buddy
73
+ ```
74
+
75
+ The buddy appears on your taskbar, centered at the bottom of the screen. It runs until you close it.
76
+
77
+ ### 2. Wire up Claude Code hooks
78
+
79
+ Add to your **global** Claude Code settings (`~/.claude/settings.json`) so every session triggers the buddy:
80
+
81
+ ```json
82
+ {
83
+ "hooks": {
84
+ "Stop": [
85
+ {
86
+ "hooks": [
87
+ {
88
+ "type": "command",
89
+ "command": "claude-buddy --send done",
90
+ "timeout": 3000
91
+ }
92
+ ]
93
+ }
94
+ ],
95
+ "PermissionRequest": [
96
+ {
97
+ "hooks": [
98
+ {
99
+ "type": "command",
100
+ "command": "claude-buddy --wave",
101
+ "timeout": 3000
102
+ }
103
+ ]
104
+ }
105
+ ]
106
+ }
107
+ }
108
+ ```
109
+
110
+ > **Note:** If you already have other hooks in your `settings.json`, merge the `Stop` and `PermissionRequest` entries into the existing `hooks` object.
111
+
112
+ ### 3. Done
113
+
114
+ Start a Claude Code session anywhere. When Claude finishes or needs your attention, the buddy reacts.
115
+
116
+ ## CLI reference
117
+
118
+ ```text
119
+ claude-buddy Start the buddy on your taskbar
120
+ claude-buddy --test Start with a celebration animation
121
+ claude-buddy --send MSG Signal a running buddy to celebrate
122
+ claude-buddy --wave Signal a running buddy to wave (needs attention)
123
+ claude-buddy --port PORT Use a custom TCP port (default: 44556)
124
+ claude-buddy --no-topmost Don't keep the window always-on-top
125
+ claude-buddy --help Show help
126
+ ```
127
+
128
+ ## Controls
129
+
130
+ | Input | Action |
131
+ | --- | --- |
132
+ | **Drag** | Click anywhere on the buddy and drag to reposition |
133
+ | **Space** | Trigger a test celebration |
134
+ | **Escape** | Quit the buddy |
135
+ | **Tray icon** | Right-click the system tray icon for a menu |
136
+
137
+ ## How it works
138
+
139
+ ### Architecture
140
+
141
+ ```text
142
+ Claude Code Claude Buddy
143
+ ----------- ------------
144
+ hooks/Stop ──> claude-buddy --send ──> TCP:44556 ──> celebrate animation
145
+ hooks/PermissionRequest ──> claude-buddy --wave ──> TCP:44556 ──> wave animation
146
+ ```
147
+
148
+ 1. **Claude Code hooks** fire shell commands when events happen (response done, permission needed).
149
+ 2. The `claude-buddy --send` / `--wave` CLI connects to `127.0.0.1:44556` and sends a JSON action.
150
+ 3. The running buddy process receives the signal and plays the animation.
151
+
152
+ ### Signal protocol
153
+
154
+ The buddy listens on a TCP socket (default port `44556`). Send a JSON payload to trigger actions:
155
+
156
+ ```json
157
+ {"action": "celebrate"}
158
+ ```
159
+
160
+ ```json
161
+ {"action": "wave"}
162
+ ```
163
+
164
+ You can send signals from any language:
165
+
166
+ ```python
167
+ import socket, json
168
+ s = socket.socket()
169
+ s.connect(("127.0.0.1", 44556))
170
+ s.sendall(json.dumps({"action": "celebrate"}).encode())
171
+ s.close()
172
+ ```
173
+
174
+ ```bash
175
+ echo '{"action": "wave"}' | nc localhost 44556
176
+ ```
177
+
178
+ ### Single instance
179
+
180
+ Only one buddy can run at a time. If you launch `claude-buddy` while one is already running, it sends a signal to the existing instance and exits.
181
+
182
+ ### System tray
183
+
184
+ The buddy adds a system tray icon with a right-click menu:
185
+
186
+ - **Test Celebration** — trigger the celebrate animation
187
+ - **Quit** — close the buddy
188
+
189
+ ## Animations
190
+
191
+ ### Idle
192
+
193
+ - Gentle vertical bobbing (sine wave)
194
+ - Periodic blinking (every ~3.5 seconds)
195
+ - Pupils wander slowly
196
+ - Small mouth line with subtle movement
197
+ - Arms sway gently at sides
198
+
199
+ ### Celebrate (on `Stop`)
200
+
201
+ - Fast bouncing
202
+ - Happy arc eyes (^ ^)
203
+ - Wide smile
204
+ - Both arms waving up
205
+ - Legs kicking
206
+ - Confetti burst (40 particles with gravity and drag)
207
+ - Duration: 3.5 seconds
208
+
209
+ ### Wave (on `PermissionRequest`)
210
+
211
+ - Medium bobbing
212
+ - Wide alert eyes (large pupils, staring)
213
+ - Surprised "o" mouth
214
+ - Right arm waving high
215
+ - Pulsing floating **!** indicator above head
216
+ - Duration: 5 seconds
217
+
218
+ ## Configuration
219
+
220
+ ### Custom port
221
+
222
+ If port `44556` is taken, use a different one:
223
+
224
+ ```bash
225
+ claude-buddy --port 55000
226
+ ```
227
+
228
+ Update your hooks to match:
229
+
230
+ ```json
231
+ "command": "claude-buddy --send done --port 55000"
232
+ ```
233
+
234
+ ### Disable always-on-top
235
+
236
+ ```bash
237
+ claude-buddy --no-topmost
238
+ ```
239
+
240
+ ## Troubleshooting
241
+
242
+ ### Buddy doesn't appear
243
+
244
+ - **Windows only**: Claude Buddy uses Windows-specific APIs (`user32`, `shell32`) for transparency and taskbar detection. It does not work on macOS or Linux.
245
+ - Make sure no other process is using port `44556`: `netstat -ano | findstr 44556`
246
+
247
+ ### Hook doesn't trigger the buddy
248
+
249
+ - Make sure the buddy is running (`claude-buddy` in a terminal).
250
+ - Test manually: `claude-buddy --send test` — if this says "No buddy on port 44556", the buddy isn't running.
251
+ - Check that `claude-buddy` is on your PATH: `which claude-buddy` or `where claude-buddy`.
252
+
253
+ ### Multiple buddies / port conflict
254
+
255
+ - The buddy uses a lock socket on port `44557` (main port + 1) to prevent duplicates.
256
+ - If a stale lock is stuck, kill all Python processes and restart: `taskkill /F /IM python.exe`
257
+
258
+ ## Requirements
259
+
260
+ - **OS**: Windows 10 / 11
261
+ - **Python**: 3.10+
262
+ - **Dependencies** (installed automatically): `pygame-ce`, `pystray`, `Pillow`
263
+
264
+ ## License
265
+
266
+ [MIT](LICENSE)
@@ -0,0 +1,238 @@
1
+ # Claude Buddy
2
+
3
+ A tiny animated terminal pet that sits on your Windows taskbar and reacts to [Claude Code](https://claude.ai/code) events.
4
+
5
+ <!-- TODO: add a gif/screenshot here -->
6
+ <!-- ![Claude Buddy demo](docs/demo.gif) -->
7
+
8
+ ## What it does
9
+
10
+ Claude Buddy is a small always-on-top character that lives on your taskbar while you work with Claude Code:
11
+
12
+ | State | What happens |
13
+ | --- | --- |
14
+ | **Idle** | Gently bobs, blinks, breathes — your quiet companion |
15
+ | **Claude finishes** (`Stop` hook) | Celebrates with confetti, happy eyes, and waving arms |
16
+ | **Claude needs permission** (`PermissionRequest` hook) | Waves at you with a floating **!** so you know to check back |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # With uv (recommended — installs as an isolated tool)
22
+ uv tool install claude-buddy
23
+
24
+ # With pipx
25
+ pipx install claude-buddy
26
+
27
+ # With pip (into current environment)
28
+ pip install claude-buddy
29
+ ```
30
+
31
+ ### From source
32
+
33
+ ```bash
34
+ git clone https://github.com/ramym/claude-buddy.git
35
+ cd claude-buddy
36
+ uv tool install --from . claude-buddy
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ### 1. Launch the buddy
42
+
43
+ ```bash
44
+ claude-buddy
45
+ ```
46
+
47
+ The buddy appears on your taskbar, centered at the bottom of the screen. It runs until you close it.
48
+
49
+ ### 2. Wire up Claude Code hooks
50
+
51
+ Add to your **global** Claude Code settings (`~/.claude/settings.json`) so every session triggers the buddy:
52
+
53
+ ```json
54
+ {
55
+ "hooks": {
56
+ "Stop": [
57
+ {
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "command": "claude-buddy --send done",
62
+ "timeout": 3000
63
+ }
64
+ ]
65
+ }
66
+ ],
67
+ "PermissionRequest": [
68
+ {
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "claude-buddy --wave",
73
+ "timeout": 3000
74
+ }
75
+ ]
76
+ }
77
+ ]
78
+ }
79
+ }
80
+ ```
81
+
82
+ > **Note:** If you already have other hooks in your `settings.json`, merge the `Stop` and `PermissionRequest` entries into the existing `hooks` object.
83
+
84
+ ### 3. Done
85
+
86
+ Start a Claude Code session anywhere. When Claude finishes or needs your attention, the buddy reacts.
87
+
88
+ ## CLI reference
89
+
90
+ ```text
91
+ claude-buddy Start the buddy on your taskbar
92
+ claude-buddy --test Start with a celebration animation
93
+ claude-buddy --send MSG Signal a running buddy to celebrate
94
+ claude-buddy --wave Signal a running buddy to wave (needs attention)
95
+ claude-buddy --port PORT Use a custom TCP port (default: 44556)
96
+ claude-buddy --no-topmost Don't keep the window always-on-top
97
+ claude-buddy --help Show help
98
+ ```
99
+
100
+ ## Controls
101
+
102
+ | Input | Action |
103
+ | --- | --- |
104
+ | **Drag** | Click anywhere on the buddy and drag to reposition |
105
+ | **Space** | Trigger a test celebration |
106
+ | **Escape** | Quit the buddy |
107
+ | **Tray icon** | Right-click the system tray icon for a menu |
108
+
109
+ ## How it works
110
+
111
+ ### Architecture
112
+
113
+ ```text
114
+ Claude Code Claude Buddy
115
+ ----------- ------------
116
+ hooks/Stop ──> claude-buddy --send ──> TCP:44556 ──> celebrate animation
117
+ hooks/PermissionRequest ──> claude-buddy --wave ──> TCP:44556 ──> wave animation
118
+ ```
119
+
120
+ 1. **Claude Code hooks** fire shell commands when events happen (response done, permission needed).
121
+ 2. The `claude-buddy --send` / `--wave` CLI connects to `127.0.0.1:44556` and sends a JSON action.
122
+ 3. The running buddy process receives the signal and plays the animation.
123
+
124
+ ### Signal protocol
125
+
126
+ The buddy listens on a TCP socket (default port `44556`). Send a JSON payload to trigger actions:
127
+
128
+ ```json
129
+ {"action": "celebrate"}
130
+ ```
131
+
132
+ ```json
133
+ {"action": "wave"}
134
+ ```
135
+
136
+ You can send signals from any language:
137
+
138
+ ```python
139
+ import socket, json
140
+ s = socket.socket()
141
+ s.connect(("127.0.0.1", 44556))
142
+ s.sendall(json.dumps({"action": "celebrate"}).encode())
143
+ s.close()
144
+ ```
145
+
146
+ ```bash
147
+ echo '{"action": "wave"}' | nc localhost 44556
148
+ ```
149
+
150
+ ### Single instance
151
+
152
+ Only one buddy can run at a time. If you launch `claude-buddy` while one is already running, it sends a signal to the existing instance and exits.
153
+
154
+ ### System tray
155
+
156
+ The buddy adds a system tray icon with a right-click menu:
157
+
158
+ - **Test Celebration** — trigger the celebrate animation
159
+ - **Quit** — close the buddy
160
+
161
+ ## Animations
162
+
163
+ ### Idle
164
+
165
+ - Gentle vertical bobbing (sine wave)
166
+ - Periodic blinking (every ~3.5 seconds)
167
+ - Pupils wander slowly
168
+ - Small mouth line with subtle movement
169
+ - Arms sway gently at sides
170
+
171
+ ### Celebrate (on `Stop`)
172
+
173
+ - Fast bouncing
174
+ - Happy arc eyes (^ ^)
175
+ - Wide smile
176
+ - Both arms waving up
177
+ - Legs kicking
178
+ - Confetti burst (40 particles with gravity and drag)
179
+ - Duration: 3.5 seconds
180
+
181
+ ### Wave (on `PermissionRequest`)
182
+
183
+ - Medium bobbing
184
+ - Wide alert eyes (large pupils, staring)
185
+ - Surprised "o" mouth
186
+ - Right arm waving high
187
+ - Pulsing floating **!** indicator above head
188
+ - Duration: 5 seconds
189
+
190
+ ## Configuration
191
+
192
+ ### Custom port
193
+
194
+ If port `44556` is taken, use a different one:
195
+
196
+ ```bash
197
+ claude-buddy --port 55000
198
+ ```
199
+
200
+ Update your hooks to match:
201
+
202
+ ```json
203
+ "command": "claude-buddy --send done --port 55000"
204
+ ```
205
+
206
+ ### Disable always-on-top
207
+
208
+ ```bash
209
+ claude-buddy --no-topmost
210
+ ```
211
+
212
+ ## Troubleshooting
213
+
214
+ ### Buddy doesn't appear
215
+
216
+ - **Windows only**: Claude Buddy uses Windows-specific APIs (`user32`, `shell32`) for transparency and taskbar detection. It does not work on macOS or Linux.
217
+ - Make sure no other process is using port `44556`: `netstat -ano | findstr 44556`
218
+
219
+ ### Hook doesn't trigger the buddy
220
+
221
+ - Make sure the buddy is running (`claude-buddy` in a terminal).
222
+ - Test manually: `claude-buddy --send test` — if this says "No buddy on port 44556", the buddy isn't running.
223
+ - Check that `claude-buddy` is on your PATH: `which claude-buddy` or `where claude-buddy`.
224
+
225
+ ### Multiple buddies / port conflict
226
+
227
+ - The buddy uses a lock socket on port `44557` (main port + 1) to prevent duplicates.
228
+ - If a stale lock is stuck, kill all Python processes and restart: `taskkill /F /IM python.exe`
229
+
230
+ ## Requirements
231
+
232
+ - **OS**: Windows 10 / 11
233
+ - **Python**: 3.10+
234
+ - **Dependencies** (installed automatically): `pygame-ce`, `pystray`, `Pillow`
235
+
236
+ ## License
237
+
238
+ [MIT](LICENSE)
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "claude-buddy"
3
+ version = "0.1.0"
4
+ description = "Animated terminal pet that sits on your taskbar and reacts to Claude Code events"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ { name = "Ramy M", email = "ramym@users.noreply.github.com" },
10
+ ]
11
+ keywords = ["claude", "claude-code", "buddy", "pet", "animation", "taskbar"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Win32 (MS Windows)",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = [
26
+ "pygame-ce>=2.5",
27
+ "pystray>=0.19",
28
+ "Pillow>=10.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/ramym/claude-buddy"
33
+ Repository = "https://github.com/ramym/claude-buddy"
34
+ Issues = "https://github.com/ramym/claude-buddy/issues"
35
+
36
+ [project.scripts]
37
+ claude-buddy = "claude_buddy.app:main"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/claude_buddy"]
@@ -0,0 +1 @@
1
+ """Claude Buddy — animated terminal pet that reacts to Claude Code events."""
@@ -0,0 +1,550 @@
1
+ """
2
+ Claude Buddy — A tiny animated terminal pet that sits on your taskbar.
3
+
4
+ Always visible. When Claude finishes a response, it celebrates!
5
+
6
+ Usage:
7
+ claude-buddy [OPTIONS]
8
+
9
+ Options:
10
+ --port PORT TCP port to listen on (default: 44556)
11
+ --no-topmost Don't keep the window always-on-top
12
+ --test Trigger a test celebration on startup
13
+ --send MESSAGE Signal a running buddy (celebrate) and exit
14
+ --wave Signal buddy to wave (attention needed) and exit
15
+ --help Show this help and exit
16
+
17
+ Controls:
18
+ Drag Click and drag to reposition
19
+ Space Test celebration
20
+ Escape Quit
21
+ Right-click Context menu (tray)
22
+ """
23
+
24
+ import sys
25
+ import os
26
+ import math
27
+ import time
28
+ import json
29
+ import random
30
+ import threading
31
+ import socket
32
+ import ctypes
33
+ import argparse
34
+
35
+ import pygame
36
+
37
+ # ── Platform: Windows transparency & positioning ──────────────────────
38
+ if sys.platform == "win32":
39
+ import ctypes.wintypes
40
+
41
+ user32 = ctypes.windll.user32
42
+
43
+ GWL_EXSTYLE = -20
44
+ WS_EX_LAYERED = 0x00080000
45
+ WS_EX_TOOLWINDOW = 0x00000080
46
+ LWA_COLORKEY = 0x00000001
47
+ SWP_NOMOVE = 0x0002
48
+ SWP_NOSIZE = 0x0001
49
+ HWND_TOPMOST = -1
50
+ HWND_NOTOPMOST = -2
51
+ SW_HIDE = 0
52
+ SW_SHOW = 5
53
+
54
+ # For taskbar detection
55
+ class APPBARDATA(ctypes.Structure):
56
+ _fields_ = [
57
+ ("cbSize", ctypes.wintypes.DWORD),
58
+ ("hWnd", ctypes.wintypes.HWND),
59
+ ("uCallbackMessage", ctypes.c_uint),
60
+ ("uEdge", ctypes.c_uint),
61
+ ("rc", ctypes.wintypes.RECT),
62
+ ("lParam", ctypes.wintypes.LPARAM),
63
+ ]
64
+
65
+ ABM_GETTASKBARPOS = 0x00000005
66
+
67
+
68
+ def _get_hwnd():
69
+ info = pygame.display.get_wm_info()
70
+ return info.get("window", 0)
71
+
72
+
73
+ def _make_transparent(hwnd, color_key):
74
+ style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
75
+ user32.SetWindowLongW(hwnd, GWL_EXSTYLE,
76
+ style | WS_EX_LAYERED | WS_EX_TOOLWINDOW)
77
+ r, g, b = color_key
78
+ user32.SetLayeredWindowAttributes(hwnd, r | (g << 8) | (b << 16), 0, LWA_COLORKEY)
79
+
80
+
81
+ def _set_topmost(hwnd, topmost=True):
82
+ flag = HWND_TOPMOST if topmost else HWND_NOTOPMOST
83
+ user32.SetWindowPos(hwnd, flag, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
84
+
85
+
86
+ def _get_window_rect(hwnd):
87
+ rect = ctypes.wintypes.RECT()
88
+ user32.GetWindowRect(hwnd, ctypes.byref(rect))
89
+ return rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top
90
+
91
+
92
+ def _move_window(hwnd, x, y):
93
+ _, _, w, h = _get_window_rect(hwnd)
94
+ user32.MoveWindow(hwnd, x, y, w, h, True)
95
+
96
+
97
+ def _get_taskbar_rect():
98
+ """Return (x, y, w, h) of the Windows taskbar."""
99
+ abd = APPBARDATA()
100
+ abd.cbSize = ctypes.sizeof(APPBARDATA)
101
+ ctypes.windll.shell32.SHAppBarMessage(ABM_GETTASKBARPOS, ctypes.byref(abd))
102
+ rc = abd.rc
103
+ return rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top
104
+
105
+
106
+ def _get_screen_size():
107
+ return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
108
+
109
+
110
+ # ── Dimensions ────────────────────────────────────────────────────────
111
+ WIN_W, WIN_H = 200, 260 # window size (room for confetti above)
112
+ CHAR_W, CHAR_H = 80, 62 # the body rectangle
113
+ FPS = 60
114
+
115
+ # Transparent key — never draw with this exact color
116
+ TKEY = (1, 1, 1)
117
+
118
+ # ── Palette (matching reference: dark body, bright eyes) ─────────────
119
+ BODY_OUTER = (35, 35, 48) # dark shell
120
+ BODY_INNER = (42, 42, 58) # slightly lighter fill
121
+ TITLE_BAR = (70, 70, 92) # light strip at top
122
+ SCREEN_BG = (22, 22, 32) # screen area
123
+ EYE_WHITE = (230, 235, 255) # big bright eyes
124
+ EYE_GLOW = (200, 210, 255, 60)
125
+ PUPIL_COLOR = (25, 25, 40)
126
+ MOUTH_COLOR = (120, 130, 160) # subtle dash
127
+ MOUTH_HAPPY = (255, 220, 80)
128
+ LIMB_COLOR = (35, 35, 48)
129
+ SHOE_COLOR = (50, 50, 68)
130
+ CONFETTI = [
131
+ (255, 107, 107), (78, 205, 196), (69, 183, 209),
132
+ (255, 230, 109), (199, 128, 232), (255, 159, 67),
133
+ ]
134
+
135
+ SOCK_HOST = "127.0.0.1"
136
+ SOCK_PORT = 44556
137
+
138
+
139
+ # ── State ─────────────────────────────────────────────────────────────
140
+ # Modes: "idle", "celebrating", "waving"
141
+ class BuddyState:
142
+ def __init__(self):
143
+ self.mode = "idle"
144
+ self.mode_start = 0.0
145
+ self.cel_dur = 3.5
146
+ self.wave_dur = 5.0
147
+ self.confetti = []
148
+ self.should_quit = False
149
+
150
+ @property
151
+ def celebrating(self):
152
+ return self.mode == "celebrating"
153
+
154
+ @property
155
+ def waving(self):
156
+ return self.mode == "waving"
157
+
158
+ def trigger(self, _msg=""):
159
+ self.mode = "celebrating"
160
+ self.mode_start = time.time()
161
+ self.confetti = _spawn_confetti(40)
162
+
163
+ def wave(self):
164
+ # Only wave if not already celebrating (celebrate takes priority)
165
+ if self.mode != "celebrating":
166
+ self.mode = "waving"
167
+ self.mode_start = time.time()
168
+
169
+ def update(self):
170
+ elapsed = time.time() - self.mode_start
171
+ if self.mode == "celebrating" and elapsed > self.cel_dur:
172
+ self.mode = "idle"
173
+ elif self.mode == "waving" and elapsed > self.wave_dur:
174
+ self.mode = "idle"
175
+
176
+
177
+ def _spawn_confetti(n):
178
+ cx = WIN_W // 2
179
+ return [
180
+ [cx + random.randint(-30, 30), WIN_H // 2 - 40,
181
+ random.uniform(-3, 3), random.uniform(-7, -2),
182
+ random.choice(CONFETTI), random.randint(3, 6)]
183
+ for _ in range(n)
184
+ ]
185
+
186
+
187
+ # ── Drawing ───────────────────────────────────────────────────────────
188
+ def rounded_rect(surf, color, rect, r):
189
+ x, y, w, h = rect
190
+ r = min(r, w // 2, h // 2)
191
+ pygame.draw.rect(surf, color, (x + r, y, w - 2 * r, h))
192
+ pygame.draw.rect(surf, color, (x, y + r, w, h - 2 * r))
193
+ for cx, cy in [(x+r,y+r),(x+w-r,y+r),(x+r,y+h-r),(x+w-r,y+h-r)]:
194
+ pygame.draw.circle(surf, color, (cx, cy), r)
195
+
196
+
197
+ def draw_buddy(surf, t, state, blink):
198
+ cel = state.celebrating
199
+ wav = state.waving
200
+ cx = WIN_W // 2
201
+ # Character vertical center — sits near bottom of window
202
+ base_y = WIN_H - 70
203
+ bob = math.sin(t * 2.2) * 1.5
204
+ if cel:
205
+ bob = math.sin(t * 10) * 6
206
+ elif wav:
207
+ bob = math.sin(t * 4) * 3
208
+
209
+ by = int(base_y - CHAR_H + bob) # body top
210
+
211
+ # ── Legs (behind body) ────────────────────────────────────────
212
+ leg_top = int(by + CHAR_H - 2)
213
+ leg_len = 18
214
+ if cel:
215
+ l_swing = math.sin(t * 7) * 8
216
+ r_swing = math.sin(t * 7 + math.pi) * 8
217
+ elif wav:
218
+ l_swing = math.sin(t * 3) * 3
219
+ r_swing = math.sin(t * 3 + math.pi) * 3
220
+ else:
221
+ l_swing = math.sin(t * 1.8) * 1.5
222
+ r_swing = math.sin(t * 1.8 + math.pi) * 1.5
223
+ for sx, sw in [(-14, l_swing), (14, r_swing)]:
224
+ fx = int(cx + sx + sw)
225
+ fy = leg_top + leg_len
226
+ pygame.draw.line(surf, LIMB_COLOR, (cx + sx, leg_top), (fx, fy), 5)
227
+ rounded_rect(surf, SHOE_COLOR, (fx - 7, fy - 2, 14, 8), 3)
228
+
229
+ # ── Arms ──────────────────────────────────────────────────────
230
+ arm_y = int(by + CHAR_H // 2 + bob)
231
+ arm_len = 22
232
+ if cel:
233
+ la = math.sin(t * 8) * 0.5 - 1.3 # both arms up celebrating
234
+ ra = math.sin(t * 8 + math.pi) * 0.5 + 0.3
235
+ elif wav:
236
+ la = math.sin(t * 1.2) * 0.1 - 0.2 # left arm idle
237
+ ra = math.sin(t * 6) * 0.4 - 1.0 # right arm waving high
238
+ else:
239
+ la = math.sin(t * 1.2) * 0.1 - 0.2 # gentle idle
240
+ ra = math.sin(t * 1.2 + 1) * 0.1 + 0.2
241
+
242
+ # left
243
+ lx1 = cx - CHAR_W // 2 - 2
244
+ lx2 = int(lx1 + math.cos(math.pi + la) * arm_len)
245
+ ly2 = int(arm_y + math.sin(math.pi + la) * arm_len)
246
+ pygame.draw.line(surf, LIMB_COLOR, (lx1, arm_y), (lx2, ly2), 5)
247
+ pygame.draw.circle(surf, LIMB_COLOR, (lx2, ly2), 4)
248
+ # right
249
+ rx1 = cx + CHAR_W // 2 + 2
250
+ rx2 = int(rx1 + math.cos(ra) * arm_len)
251
+ ry2 = int(arm_y + math.sin(ra) * arm_len)
252
+ pygame.draw.line(surf, LIMB_COLOR, (rx1, arm_y), (rx2, ry2), 5)
253
+ pygame.draw.circle(surf, LIMB_COLOR, (rx2, ry2), 4)
254
+
255
+ # ── Body (main terminal box) ──────────────────────────────────
256
+ bx = cx - CHAR_W // 2
257
+ rounded_rect(surf, BODY_OUTER, (bx, by, CHAR_W, CHAR_H), 8)
258
+ rounded_rect(surf, BODY_INNER, (bx + 2, by + 2, CHAR_W - 4, CHAR_H - 4), 7)
259
+
260
+ # Title bar strip
261
+ rounded_rect(surf, TITLE_BAR, (bx + 2, by + 2, CHAR_W - 4, 10), 6)
262
+ # Three little dots on title bar
263
+ for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
264
+ pygame.draw.circle(surf, c, (bx + 10 + i * 9, by + 7), 2)
265
+
266
+ # Screen area
267
+ scr = (bx + 6, by + 14, CHAR_W - 12, CHAR_H - 22)
268
+ rounded_rect(surf, SCREEN_BG, scr, 4)
269
+
270
+ # ── Eyes ──────────────────────────────────────────────────────
271
+ sx, sy, sw, sh = scr
272
+ ey = sy + sh // 2 - 2
273
+ lex = sx + sw // 3
274
+ rex = sx + 2 * sw // 3
275
+ er = 8 # eye radius
276
+
277
+ WAVE_EYE = (255, 190, 80) # amber attention color
278
+
279
+ if blink and not wav:
280
+ # Blink — horizontal line
281
+ for ex in (lex, rex):
282
+ pygame.draw.line(surf, EYE_WHITE, (ex - 6, ey), (ex + 6, ey), 2)
283
+ elif cel:
284
+ # Happy — upward arcs (^ ^)
285
+ for ex in (lex, rex):
286
+ pygame.draw.arc(surf, MOUTH_HAPPY, (ex - 7, ey - 5, 14, 10),
287
+ math.radians(0), math.radians(180), 3)
288
+ elif wav:
289
+ # Wide alert eyes — big and looking at user
290
+ for ex in (lex, rex):
291
+ pygame.draw.circle(surf, EYE_WHITE, (ex, ey), er + 1)
292
+ pygame.draw.circle(surf, PUPIL_COLOR, (ex, ey), 5)
293
+ pygame.draw.circle(surf, (255, 255, 255), (ex - 2, ey - 3), 2)
294
+ else:
295
+ for ex in (lex, rex):
296
+ # White of eye
297
+ pygame.draw.circle(surf, EYE_WHITE, (ex, ey), er)
298
+ # Pupil — slight wander
299
+ px = ex + math.sin(t * 0.6 + ex * 0.01) * 2
300
+ py = ey + math.cos(t * 0.8) * 1.5
301
+ pygame.draw.circle(surf, PUPIL_COLOR, (int(px), int(py)), 4)
302
+ # Highlight
303
+ pygame.draw.circle(surf, (255, 255, 255), (ex - 2, ey - 3), 2)
304
+
305
+ # ── Mouth ─────────────────────────────────────────────────────
306
+ my = sy + sh - 6
307
+ if cel:
308
+ # Happy open smile
309
+ pygame.draw.arc(surf, MOUTH_HAPPY, (cx - 10, my - 7, 20, 12),
310
+ math.radians(200), math.radians(340), 2)
311
+ elif wav:
312
+ # Small "o" mouth — surprised/calling
313
+ pygame.draw.circle(surf, WAVE_EYE, (cx, my - 2), 4, 2)
314
+ else:
315
+ # Small dash
316
+ w_m = 10 + math.sin(t * 1.5) * 1
317
+ pygame.draw.line(surf, MOUTH_COLOR,
318
+ (int(cx - w_m / 2), my), (int(cx + w_m / 2), my), 2)
319
+
320
+ # ── Attention indicator (floating "!" above head when waving) ─
321
+ if wav:
322
+ pulse = (math.sin(t * 5) + 1) / 2 # 0..1 pulsing
323
+ alpha_val = int(180 + 75 * pulse)
324
+ ix = cx + 30
325
+ iy = int(by - 18 + math.sin(t * 3) * 4)
326
+ # "!" exclamation
327
+ bang_surf = pygame.Surface((20, 28), pygame.SRCALPHA)
328
+ bang_color = (*WAVE_EYE, alpha_val)
329
+ pygame.draw.rect(bang_surf, bang_color, (7, 2, 6, 14), border_radius=3)
330
+ pygame.draw.circle(bang_surf, bang_color, (10, 22), 3)
331
+ surf.blit(bang_surf, (ix - 10, iy - 14))
332
+
333
+ # ── Confetti ──────────────────────────────────────────────────
334
+ alive = []
335
+ for p in state.confetti:
336
+ p[0] += p[2]; p[1] += p[3]; p[3] += 0.18; p[2] *= 0.99
337
+ if p[1] < WIN_H + 10:
338
+ alive.append(p)
339
+ pygame.draw.rect(surf, p[4], (int(p[0]), int(p[1]), p[5], p[5]))
340
+ state.confetti = alive
341
+
342
+
343
+ # ── Socket listener ───────────────────────────────────────────────────
344
+ def socket_listener(state, port):
345
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
346
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
347
+ try:
348
+ srv.bind((SOCK_HOST, port))
349
+ except OSError as e:
350
+ print(f"[buddy] Cannot bind {SOCK_HOST}:{port}: {e}")
351
+ return
352
+ srv.listen(5)
353
+ srv.settimeout(1.0)
354
+ print(f"[buddy] Listening on {SOCK_HOST}:{port}")
355
+
356
+ while True:
357
+ try:
358
+ conn, _ = srv.accept()
359
+ data = conn.recv(4096).decode("utf-8", errors="replace").strip()
360
+ conn.close()
361
+ if data:
362
+ action = "celebrate"
363
+ try:
364
+ msg = json.loads(data)
365
+ action = msg.get("action", "celebrate")
366
+ except (json.JSONDecodeError, AttributeError):
367
+ pass
368
+ print(f"[buddy] Signal: {action}")
369
+ if action == "wave":
370
+ state.wave()
371
+ else:
372
+ state.trigger()
373
+ except socket.timeout:
374
+ continue
375
+ except Exception as e:
376
+ print(f"[buddy] Socket error: {e}")
377
+
378
+
379
+ # ── System tray ───────────────────────────────────────────────────────
380
+ def create_tray(state):
381
+ import pystray
382
+ from PIL import Image, ImageDraw
383
+
384
+ img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
385
+ d = ImageDraw.Draw(img)
386
+ d.rounded_rectangle([12, 14, 52, 46], radius=5, fill=(42, 42, 58))
387
+ d.rounded_rectangle([12, 14, 52, 22], radius=5, fill=(70, 70, 92))
388
+ d.ellipse([22, 28, 30, 36], fill=(230, 235, 255))
389
+ d.ellipse([34, 28, 42, 36], fill=(230, 235, 255))
390
+ d.line([(28, 40), (36, 40)], fill=(120, 130, 160), width=2)
391
+ d.line([(24, 46), (22, 54)], fill=(35, 35, 48), width=3)
392
+ d.line([(40, 46), (42, 54)], fill=(35, 35, 48), width=3)
393
+
394
+ def on_celebrate(_icon, _item):
395
+ state.trigger()
396
+
397
+ def on_quit(icon, _item):
398
+ state.should_quit = True
399
+ icon.stop()
400
+
401
+ menu = pystray.Menu(
402
+ pystray.MenuItem("Test Celebration", on_celebrate),
403
+ pystray.MenuItem("Quit", on_quit),
404
+ )
405
+ pystray.Icon("claude-buddy", img, "Claude Buddy", menu).run()
406
+
407
+
408
+ # ── CLI ───────────────────────────────────────────────────────────────
409
+ def parse_args():
410
+ p = argparse.ArgumentParser(
411
+ prog="claude-buddy",
412
+ description="Claude Buddy — tiny terminal pet on your taskbar",
413
+ formatter_class=argparse.RawDescriptionHelpFormatter,
414
+ epilog=(
415
+ "Examples:\n"
416
+ " claude-buddy Start buddy on taskbar\n"
417
+ " claude-buddy --test Start with a celebration\n"
418
+ " claude-buddy --send Done! Signal a running buddy\n"
419
+ " claude-buddy --wave Wave for attention\n"
420
+ ),
421
+ )
422
+ p.add_argument("--port", type=int, default=SOCK_PORT,
423
+ help=f"TCP port (default: {SOCK_PORT})")
424
+ p.add_argument("--no-topmost", action="store_true",
425
+ help="Don't stay always-on-top")
426
+ p.add_argument("--test", action="store_true",
427
+ help="Celebrate on startup")
428
+ p.add_argument("--send", metavar="MSG", type=str,
429
+ help="Send celebrate signal to running buddy and exit")
430
+ p.add_argument("--wave", action="store_true",
431
+ help="Send wave/attention signal to running buddy and exit")
432
+ return p.parse_args()
433
+
434
+
435
+ # ── Main ──────────────────────────────────────────────────────────────
436
+ def main():
437
+ args = parse_args()
438
+ port = args.port
439
+
440
+ # --send / --wave mode
441
+ if args.send is not None or args.wave:
442
+ action = "wave" if args.wave else "celebrate"
443
+ payload = json.dumps({"action": action}).encode()
444
+ try:
445
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
446
+ s.connect((SOCK_HOST, port))
447
+ s.sendall(payload)
448
+ s.close()
449
+ print(f"[buddy] Sent: {action}")
450
+ except ConnectionRefusedError:
451
+ print(f"[buddy] No buddy on port {port}")
452
+ sys.exit(1)
453
+ sys.exit(0)
454
+
455
+ # Single instance lock
456
+ lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
457
+ try:
458
+ lock_sock.bind(("127.0.0.1", port + 1))
459
+ except OSError:
460
+ print("[buddy] Already running — sending signal.")
461
+ try:
462
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
463
+ s.connect((SOCK_HOST, port))
464
+ s.sendall(b'{"message": "hello"}')
465
+ s.close()
466
+ except Exception:
467
+ pass
468
+ sys.exit(0)
469
+
470
+ # Position window centered on taskbar, just above it
471
+ scr_w, scr_h = _get_screen_size()
472
+ tb_x, tb_y, tb_w, tb_h = _get_taskbar_rect()
473
+ win_x = scr_w // 2 - WIN_W // 2
474
+ win_y = tb_y - WIN_H + 28 # overlap slightly so feet "stand" on taskbar
475
+
476
+ os.environ["SDL_VIDEO_WINDOW_POS"] = f"{win_x},{win_y}"
477
+ pygame.init()
478
+
479
+ screen = pygame.display.set_mode((WIN_W, WIN_H), pygame.NOFRAME)
480
+ pygame.display.set_caption("Claude Buddy")
481
+ clock = pygame.time.Clock()
482
+
483
+ hwnd = _get_hwnd()
484
+ _make_transparent(hwnd, TKEY)
485
+ if not args.no_topmost:
486
+ _set_topmost(hwnd, True)
487
+
488
+ state = BuddyState()
489
+ if args.test:
490
+ state.trigger()
491
+
492
+ # Background threads
493
+ threading.Thread(target=socket_listener, args=(state, port), daemon=True).start()
494
+ threading.Thread(target=create_tray, args=(state,), daemon=True).start()
495
+
496
+ # Drag
497
+ dragging = False
498
+ drag_off = (0, 0)
499
+
500
+ # Blink
501
+ blink_timer = 0.0
502
+ blink_interval = 3.5
503
+ blink_dur = 0.12
504
+
505
+ running = True
506
+ while running:
507
+ dt = clock.tick(FPS) / 1000.0
508
+ t = time.time()
509
+
510
+ if state.should_quit:
511
+ break
512
+
513
+ for ev in pygame.event.get():
514
+ if ev.type == pygame.QUIT:
515
+ running = False
516
+ elif ev.type == pygame.KEYDOWN:
517
+ if ev.key == pygame.K_ESCAPE:
518
+ running = False
519
+ elif ev.key == pygame.K_SPACE:
520
+ state.trigger()
521
+ elif ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1:
522
+ dragging = True
523
+ drag_off = ev.pos
524
+ elif ev.type == pygame.MOUSEBUTTONUP and ev.button == 1:
525
+ dragging = False
526
+ elif ev.type == pygame.MOUSEMOTION and dragging:
527
+ mx, my = ev.pos
528
+ wx, wy, _, _ = _get_window_rect(hwnd)
529
+ _move_window(hwnd, wx + mx - drag_off[0],
530
+ wy + my - drag_off[1])
531
+
532
+ state.update()
533
+
534
+ # Blink
535
+ blink_timer += dt
536
+ phase = blink_timer % blink_interval
537
+ is_blink = phase > blink_interval - blink_dur
538
+
539
+ # Draw
540
+ screen.fill(TKEY)
541
+ draw_buddy(screen, t, state, is_blink)
542
+ pygame.display.flip()
543
+
544
+ pygame.quit()
545
+ lock_sock.close()
546
+ sys.exit()
547
+
548
+
549
+ if __name__ == "__main__":
550
+ main()