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.
- claude_buddy-0.1.0/.claude/commands/buddy.md +22 -0
- claude_buddy-0.1.0/.claude/settings.json +26 -0
- claude_buddy-0.1.0/.gitignore +28 -0
- claude_buddy-0.1.0/CHANGELOG.md +25 -0
- claude_buddy-0.1.0/CONTRIBUTING.md +82 -0
- claude_buddy-0.1.0/LICENSE +21 -0
- claude_buddy-0.1.0/PKG-INFO +266 -0
- claude_buddy-0.1.0/README.md +238 -0
- claude_buddy-0.1.0/pyproject.toml +44 -0
- claude_buddy-0.1.0/src/claude_buddy/__init__.py +1 -0
- claude_buddy-0.1.0/src/claude_buddy/app.py +550 -0
|
@@ -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
|
+
<!--  -->
|
|
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
|
+
<!--  -->
|
|
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()
|