claude-arcade 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_arcade-0.1.0/.claude/settings.local.json +11 -0
- claude_arcade-0.1.0/PKG-INFO +97 -0
- claude_arcade-0.1.0/README.md +75 -0
- claude_arcade-0.1.0/pyproject.toml +36 -0
- claude_arcade-0.1.0/src/claude_arcade/__init__.py +3 -0
- claude_arcade-0.1.0/src/claude_arcade/bird_game.py +355 -0
- claude_arcade-0.1.0/src/claude_arcade/cli.py +89 -0
- claude_arcade-0.1.0/src/claude_arcade/setup_hooks.py +115 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(pip install:*)",
|
|
5
|
+
"Bash(claude-arcade:*)",
|
|
6
|
+
"Bash(python3 -m json.tool)",
|
|
7
|
+
"Bash(python3 -c \"import json; s=json.load\\(open\\('/Users/apple/.claude/settings.json'\\)\\); print\\('hooks remaining:', s.get\\('hooks', {}\\)\\)\")",
|
|
8
|
+
"Bash(python3:*)"
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-arcade
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Play games while Claude thinks — a collection of terminal mini-games that hook into Claude Code
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/claude-arcade
|
|
6
|
+
Project-URL: Issues, https://github.com/yourusername/claude-arcade/issues
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: claude,claude-code,cli,curses,game,terminal
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Games/Entertainment
|
|
19
|
+
Classifier: Topic :: Terminals
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# claude-arcade
|
|
24
|
+
|
|
25
|
+
Play mini-games in your terminal while Claude thinks.
|
|
26
|
+
|
|
27
|
+
Hooks into [Claude Code](https://claude.ai/code) so a game launches automatically every time Claude runs a tool — and disappears the moment it's done. Your terminal is fully restored, nothing is lost.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install claude-arcade
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> `pipx` is recommended so the package gets its own isolated environment.
|
|
36
|
+
> Plain `pip install claude-arcade` works too.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1. One-time setup — writes hooks into ~/.claude/settings.json
|
|
42
|
+
claude-arcade setup
|
|
43
|
+
|
|
44
|
+
# 2. Use Claude Code as normal
|
|
45
|
+
claude
|
|
46
|
+
|
|
47
|
+
# The bird game now appears automatically whenever Claude uses a tool.
|
|
48
|
+
# It disappears and restores your terminal when Claude is done.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Play manually
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude-arcade play # Bird Hunt (default)
|
|
55
|
+
claude-arcade play bird # same thing
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Bird Hunt controls
|
|
59
|
+
|
|
60
|
+
| Key | Action |
|
|
61
|
+
|-----|--------|
|
|
62
|
+
| Arrow keys / WASD | Move crosshair |
|
|
63
|
+
| `SPACE` | Shoot |
|
|
64
|
+
| `Q` | Quit |
|
|
65
|
+
|
|
66
|
+
Birds fly across the screen at different speeds and are worth different points:
|
|
67
|
+
|
|
68
|
+
| Bird | Points | Speed |
|
|
69
|
+
|------|--------|-------|
|
|
70
|
+
| `>->` | 3 | Fast |
|
|
71
|
+
| `>~~>` | 1 | Normal |
|
|
72
|
+
| `>°~°>` | 2 | Slow |
|
|
73
|
+
|
|
74
|
+
## Remove hooks
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
claude-arcade unsetup
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
81
|
+
|
|
82
|
+
`claude-arcade setup` adds three hooks to `~/.claude/settings.json`:
|
|
83
|
+
|
|
84
|
+
- **PreToolUse** → `claude-arcade start` — forks a background process that takes over the terminal alternate screen buffer and runs the game.
|
|
85
|
+
- **PostToolUse** + **Stop** → `claude-arcade stop` — signals the game to exit and restores your terminal exactly as you left it.
|
|
86
|
+
|
|
87
|
+
Because the game runs on the [alternate screen buffer](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-The-Alternate-Screen-Buffer), all of Claude's output is preserved underneath.
|
|
88
|
+
|
|
89
|
+
## Requirements
|
|
90
|
+
|
|
91
|
+
- macOS or Linux
|
|
92
|
+
- Python 3.9+
|
|
93
|
+
- Claude Code CLI
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# claude-arcade
|
|
2
|
+
|
|
3
|
+
Play mini-games in your terminal while Claude thinks.
|
|
4
|
+
|
|
5
|
+
Hooks into [Claude Code](https://claude.ai/code) so a game launches automatically every time Claude runs a tool — and disappears the moment it's done. Your terminal is fully restored, nothing is lost.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install claude-arcade
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> `pipx` is recommended so the package gets its own isolated environment.
|
|
14
|
+
> Plain `pip install claude-arcade` works too.
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 1. One-time setup — writes hooks into ~/.claude/settings.json
|
|
20
|
+
claude-arcade setup
|
|
21
|
+
|
|
22
|
+
# 2. Use Claude Code as normal
|
|
23
|
+
claude
|
|
24
|
+
|
|
25
|
+
# The bird game now appears automatically whenever Claude uses a tool.
|
|
26
|
+
# It disappears and restores your terminal when Claude is done.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Play manually
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude-arcade play # Bird Hunt (default)
|
|
33
|
+
claude-arcade play bird # same thing
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Bird Hunt controls
|
|
37
|
+
|
|
38
|
+
| Key | Action |
|
|
39
|
+
|-----|--------|
|
|
40
|
+
| Arrow keys / WASD | Move crosshair |
|
|
41
|
+
| `SPACE` | Shoot |
|
|
42
|
+
| `Q` | Quit |
|
|
43
|
+
|
|
44
|
+
Birds fly across the screen at different speeds and are worth different points:
|
|
45
|
+
|
|
46
|
+
| Bird | Points | Speed |
|
|
47
|
+
|------|--------|-------|
|
|
48
|
+
| `>->` | 3 | Fast |
|
|
49
|
+
| `>~~>` | 1 | Normal |
|
|
50
|
+
| `>°~°>` | 2 | Slow |
|
|
51
|
+
|
|
52
|
+
## Remove hooks
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
claude-arcade unsetup
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
`claude-arcade setup` adds three hooks to `~/.claude/settings.json`:
|
|
61
|
+
|
|
62
|
+
- **PreToolUse** → `claude-arcade start` — forks a background process that takes over the terminal alternate screen buffer and runs the game.
|
|
63
|
+
- **PostToolUse** + **Stop** → `claude-arcade stop` — signals the game to exit and restores your terminal exactly as you left it.
|
|
64
|
+
|
|
65
|
+
Because the game runs on the [alternate screen buffer](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-The-Alternate-Screen-Buffer), all of Claude's output is preserved underneath.
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
- macOS or Linux
|
|
70
|
+
- Python 3.9+
|
|
71
|
+
- Claude Code CLI
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "claude-arcade"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Play games while Claude thinks — a collection of terminal mini-games that hook into Claude Code"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["claude", "terminal", "game", "cli", "curses", "claude-code"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Games/Entertainment",
|
|
24
|
+
"Topic :: Terminals",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
claude-arcade = "claude_arcade.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/yourusername/claude-arcade"
|
|
33
|
+
Issues = "https://github.com/yourusername/claude-arcade/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/claude_arcade"]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Bird Hunt — shoot ASCII birds flying across your terminal."""
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
PID_FILE = "/tmp/.claude-arcade.pid"
|
|
11
|
+
STOP_FILE = "/tmp/.claude-arcade-stop"
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Game objects
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
class Bird:
|
|
18
|
+
TYPES = [
|
|
19
|
+
{"art_r": ">->", "art_l": "<-<", "speed": 2.0, "points": 3},
|
|
20
|
+
{"art_r": ">~~>", "art_l": "<~~<", "speed": 1.0, "points": 1},
|
|
21
|
+
{"art_r": ">°~°>", "art_l": "<°~°<", "speed": 0.6, "points": 2},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self, width: int, height: int) -> None:
|
|
25
|
+
t = random.choice(Bird.TYPES)
|
|
26
|
+
self.art_r = t["art_r"]
|
|
27
|
+
self.art_l = t["art_l"]
|
|
28
|
+
self.speed = t["speed"]
|
|
29
|
+
self.points = t["points"]
|
|
30
|
+
self.direction = random.choice(("right", "left"))
|
|
31
|
+
self.y = random.randint(2, max(3, height - 4))
|
|
32
|
+
self._frac_x: float # sub-pixel position for fractional speeds
|
|
33
|
+
|
|
34
|
+
if self.direction == "right":
|
|
35
|
+
self._frac_x = float(-len(self.art_r))
|
|
36
|
+
else:
|
|
37
|
+
self._frac_x = float(width)
|
|
38
|
+
|
|
39
|
+
# ---- computed properties ----
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def art(self) -> str:
|
|
43
|
+
return self.art_r if self.direction == "right" else self.art_l
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def x(self) -> int:
|
|
47
|
+
return int(self._frac_x)
|
|
48
|
+
|
|
49
|
+
# ---- logic ----
|
|
50
|
+
|
|
51
|
+
def update(self, dt: float) -> None:
|
|
52
|
+
if self.direction == "right":
|
|
53
|
+
self._frac_x += self.speed * dt * 20 # scale to ~chars/sec
|
|
54
|
+
else:
|
|
55
|
+
self._frac_x -= self.speed * dt * 20
|
|
56
|
+
|
|
57
|
+
def is_offscreen(self, width: int) -> bool:
|
|
58
|
+
if self.direction == "right":
|
|
59
|
+
return self.x > width
|
|
60
|
+
return self.x + len(self.art) < 0
|
|
61
|
+
|
|
62
|
+
def draw(self, stdscr, color: int) -> None:
|
|
63
|
+
h, w = stdscr.getmaxyx()
|
|
64
|
+
x, y = self.x, self.y
|
|
65
|
+
art = self.art
|
|
66
|
+
if 1 <= y < h - 1 and 0 <= x < w - len(art):
|
|
67
|
+
try:
|
|
68
|
+
stdscr.addstr(y, x, art, color)
|
|
69
|
+
except curses.error:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Bullet:
|
|
74
|
+
def __init__(self, x: int, y: int) -> None:
|
|
75
|
+
self.x = x
|
|
76
|
+
self.y = float(y)
|
|
77
|
+
|
|
78
|
+
def update(self, dt: float) -> None:
|
|
79
|
+
self.y -= 40 * dt # fast upward travel
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def iy(self) -> int:
|
|
83
|
+
return int(self.y)
|
|
84
|
+
|
|
85
|
+
def is_offscreen(self) -> bool:
|
|
86
|
+
return self.iy < 1
|
|
87
|
+
|
|
88
|
+
def hits(self, bird: Bird) -> bool:
|
|
89
|
+
return bird.x <= self.x <= bird.x + len(bird.art) - 1 and self.iy == bird.y
|
|
90
|
+
|
|
91
|
+
def draw(self, stdscr, color: int) -> None:
|
|
92
|
+
h, w = stdscr.getmaxyx()
|
|
93
|
+
if 1 <= self.iy < h - 1 and 0 <= self.x < w:
|
|
94
|
+
try:
|
|
95
|
+
stdscr.addstr(self.iy, self.x, "|", color)
|
|
96
|
+
except curses.error:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Explosion:
|
|
101
|
+
FRAMES = ["***", " * ", " "]
|
|
102
|
+
|
|
103
|
+
def __init__(self, x: int, y: int) -> None:
|
|
104
|
+
self.x = x
|
|
105
|
+
self.y = y
|
|
106
|
+
self.frame = 0
|
|
107
|
+
|
|
108
|
+
def update(self) -> None:
|
|
109
|
+
self.frame += 1
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def done(self) -> bool:
|
|
113
|
+
return self.frame >= len(self.FRAMES)
|
|
114
|
+
|
|
115
|
+
def draw(self, stdscr, color: int) -> None:
|
|
116
|
+
if self.done:
|
|
117
|
+
return
|
|
118
|
+
h, w = stdscr.getmaxyx()
|
|
119
|
+
art = self.FRAMES[self.frame]
|
|
120
|
+
if 1 <= self.y < h - 1 and 0 <= self.x < w - len(art):
|
|
121
|
+
try:
|
|
122
|
+
stdscr.addstr(self.y, self.x, art, color | curses.A_BOLD)
|
|
123
|
+
except curses.error:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Main game loop
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def bird_game(stdscr) -> None:
|
|
132
|
+
# Terminal setup
|
|
133
|
+
curses.curs_set(0)
|
|
134
|
+
stdscr.nodelay(True)
|
|
135
|
+
stdscr.keypad(True)
|
|
136
|
+
|
|
137
|
+
has_colors = curses.has_colors()
|
|
138
|
+
if has_colors:
|
|
139
|
+
curses.start_color()
|
|
140
|
+
curses.use_default_colors()
|
|
141
|
+
curses.init_pair(1, curses.COLOR_YELLOW, -1) # birds
|
|
142
|
+
curses.init_pair(2, curses.COLOR_GREEN, -1) # crosshair
|
|
143
|
+
curses.init_pair(3, curses.COLOR_RED, -1) # bullets
|
|
144
|
+
curses.init_pair(4, curses.COLOR_CYAN, -1) # header
|
|
145
|
+
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_CYAN) # status bar
|
|
146
|
+
curses.init_pair(6, curses.COLOR_MAGENTA, -1) # explosions
|
|
147
|
+
curses.init_pair(7, curses.COLOR_WHITE, -1) # neutral
|
|
148
|
+
|
|
149
|
+
C_BIRD = curses.color_pair(1)
|
|
150
|
+
C_AIM = curses.color_pair(2) | curses.A_BOLD
|
|
151
|
+
C_SHOT = curses.color_pair(3)
|
|
152
|
+
C_HDR = curses.color_pair(4) | curses.A_BOLD
|
|
153
|
+
C_BAR = curses.color_pair(5)
|
|
154
|
+
C_BOOM = curses.color_pair(6)
|
|
155
|
+
else:
|
|
156
|
+
C_BIRD = C_AIM = C_SHOT = C_HDR = C_BAR = C_BOOM = curses.A_NORMAL
|
|
157
|
+
|
|
158
|
+
h, w = stdscr.getmaxyx()
|
|
159
|
+
|
|
160
|
+
score = 0
|
|
161
|
+
birds: list[Bird] = []
|
|
162
|
+
bullets: list[Bullet] = []
|
|
163
|
+
booms: list[Explosion] = []
|
|
164
|
+
|
|
165
|
+
cx = w // 2 # crosshair column
|
|
166
|
+
cy = h // 2 # crosshair row
|
|
167
|
+
|
|
168
|
+
spawn_interval = 1.8
|
|
169
|
+
last_spawn = time.monotonic()
|
|
170
|
+
last_tick = time.monotonic()
|
|
171
|
+
|
|
172
|
+
running = True
|
|
173
|
+
|
|
174
|
+
def on_signal(signum, frame): # noqa: ANN001
|
|
175
|
+
nonlocal running
|
|
176
|
+
running = False
|
|
177
|
+
|
|
178
|
+
signal.signal(signal.SIGTERM, on_signal)
|
|
179
|
+
signal.signal(signal.SIGINT, on_signal)
|
|
180
|
+
|
|
181
|
+
while running:
|
|
182
|
+
# ---- stop-file check (set by `claude-arcade stop`) ----
|
|
183
|
+
if os.path.exists(STOP_FILE):
|
|
184
|
+
try:
|
|
185
|
+
os.unlink(STOP_FILE)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
now = time.monotonic()
|
|
191
|
+
dt = now - last_tick
|
|
192
|
+
last_tick = now
|
|
193
|
+
h, w = stdscr.getmaxyx()
|
|
194
|
+
|
|
195
|
+
# ---- spawn ----
|
|
196
|
+
if now - last_spawn >= spawn_interval:
|
|
197
|
+
birds.append(Bird(w, h))
|
|
198
|
+
last_spawn = now
|
|
199
|
+
spawn_interval = max(0.7, spawn_interval - 0.04)
|
|
200
|
+
|
|
201
|
+
# ---- update birds ----
|
|
202
|
+
for bird in birds[:]:
|
|
203
|
+
bird.update(dt)
|
|
204
|
+
if bird.is_offscreen(w):
|
|
205
|
+
birds.remove(bird)
|
|
206
|
+
|
|
207
|
+
# ---- update bullets + collision ----
|
|
208
|
+
for blt in bullets[:]:
|
|
209
|
+
blt.update(dt)
|
|
210
|
+
if blt.is_offscreen():
|
|
211
|
+
bullets.remove(blt)
|
|
212
|
+
continue
|
|
213
|
+
hit = False
|
|
214
|
+
for bird in birds[:]:
|
|
215
|
+
if blt.hits(bird):
|
|
216
|
+
score += bird.points
|
|
217
|
+
booms.append(Explosion(bird.x, bird.y))
|
|
218
|
+
birds.remove(bird)
|
|
219
|
+
hit = True
|
|
220
|
+
break
|
|
221
|
+
if hit and blt in bullets:
|
|
222
|
+
bullets.remove(blt)
|
|
223
|
+
|
|
224
|
+
# ---- update explosions ----
|
|
225
|
+
for b in booms[:]:
|
|
226
|
+
b.update()
|
|
227
|
+
if b.done:
|
|
228
|
+
booms.remove(b)
|
|
229
|
+
|
|
230
|
+
# ---- draw ----
|
|
231
|
+
stdscr.erase()
|
|
232
|
+
|
|
233
|
+
# header bar
|
|
234
|
+
header = f" BIRD HUNT | Score: {score} | Arrows/WASD: Aim | SPACE: Shoot | Q: Quit "
|
|
235
|
+
try:
|
|
236
|
+
stdscr.addstr(0, 0, header[: w - 1].ljust(w - 1), C_HDR)
|
|
237
|
+
except curses.error:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
for bird in birds:
|
|
241
|
+
bird.draw(stdscr, C_BIRD)
|
|
242
|
+
for blt in bullets:
|
|
243
|
+
blt.draw(stdscr, C_SHOT)
|
|
244
|
+
for boom in booms:
|
|
245
|
+
boom.draw(stdscr, C_BOOM)
|
|
246
|
+
|
|
247
|
+
# crosshair
|
|
248
|
+
for dx, ch in [(-1, "["), (0, "+"), (1, "]")]:
|
|
249
|
+
nx = cx + dx
|
|
250
|
+
if 1 <= cy < h - 1 and 0 <= nx < w:
|
|
251
|
+
try:
|
|
252
|
+
stdscr.addstr(cy, nx, ch, C_AIM)
|
|
253
|
+
except curses.error:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# footer bar
|
|
257
|
+
footer = " Claude is thinking... Hang tight and shoot some birds! "
|
|
258
|
+
try:
|
|
259
|
+
stdscr.addstr(h - 1, 0, footer[: w - 1].ljust(w - 1), C_BAR)
|
|
260
|
+
except curses.error:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
stdscr.refresh()
|
|
264
|
+
|
|
265
|
+
# ---- input ----
|
|
266
|
+
key = stdscr.getch()
|
|
267
|
+
if key in (curses.KEY_UP, ord("w"), ord("W")) and cy > 1:
|
|
268
|
+
cy -= 1
|
|
269
|
+
elif key in (curses.KEY_DOWN, ord("s"), ord("S")) and cy < h - 2:
|
|
270
|
+
cy += 1
|
|
271
|
+
elif key in (curses.KEY_LEFT, ord("a"), ord("A")) and cx > 1:
|
|
272
|
+
cx -= 1
|
|
273
|
+
elif key in (curses.KEY_RIGHT, ord("d"), ord("D")) and cx < w - 2:
|
|
274
|
+
cx += 1
|
|
275
|
+
elif key == ord(" "):
|
|
276
|
+
bullets.append(Bullet(cx, cy - 1))
|
|
277
|
+
elif key in (ord("q"), ord("Q")):
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
time.sleep(0.033) # ~30 fps cap
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Background start / stop (called by Claude Code hooks)
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def start_background() -> None:
|
|
288
|
+
"""Fork a background process that runs the game on /dev/tty."""
|
|
289
|
+
# If a game is already running, skip.
|
|
290
|
+
if os.path.exists(PID_FILE):
|
|
291
|
+
try:
|
|
292
|
+
with open(PID_FILE) as fh:
|
|
293
|
+
pid = int(fh.read().strip())
|
|
294
|
+
os.kill(pid, 0)
|
|
295
|
+
return # already alive
|
|
296
|
+
except (OSError, ValueError):
|
|
297
|
+
pass # stale PID — continue
|
|
298
|
+
|
|
299
|
+
# Clean up any stale stop-file.
|
|
300
|
+
try:
|
|
301
|
+
os.unlink(STOP_FILE)
|
|
302
|
+
except OSError:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
pid = os.fork()
|
|
306
|
+
if pid > 0:
|
|
307
|
+
# Parent: record child PID and return immediately so the hook exits fast.
|
|
308
|
+
with open(PID_FILE, "w") as fh:
|
|
309
|
+
fh.write(str(pid))
|
|
310
|
+
sys.exit(0)
|
|
311
|
+
|
|
312
|
+
# ---- child process ----
|
|
313
|
+
try:
|
|
314
|
+
os.setsid() # new session — detach from parent process group
|
|
315
|
+
|
|
316
|
+
# Connect fd 0/1/2 to the real terminal so curses can draw on it.
|
|
317
|
+
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
|
318
|
+
for fd in (0, 1, 2):
|
|
319
|
+
os.dup2(tty_fd, fd)
|
|
320
|
+
if tty_fd > 2:
|
|
321
|
+
os.close(tty_fd)
|
|
322
|
+
|
|
323
|
+
curses.wrapper(bird_game)
|
|
324
|
+
|
|
325
|
+
except Exception:
|
|
326
|
+
pass # silently swallow errors in background child
|
|
327
|
+
finally:
|
|
328
|
+
# Clean up PID file on any exit path.
|
|
329
|
+
try:
|
|
330
|
+
os.unlink(PID_FILE)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
os._exit(0)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def stop_background() -> None:
|
|
337
|
+
"""Signal the background game to exit and clean up."""
|
|
338
|
+
# Write the stop-file so the game's main loop sees it gracefully.
|
|
339
|
+
try:
|
|
340
|
+
open(STOP_FILE, "w").close()
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
# Also send SIGTERM in case the process is stuck.
|
|
345
|
+
if os.path.exists(PID_FILE):
|
|
346
|
+
try:
|
|
347
|
+
with open(PID_FILE) as fh:
|
|
348
|
+
pid = int(fh.read().strip())
|
|
349
|
+
os.kill(pid, signal.SIGTERM)
|
|
350
|
+
except (OSError, ValueError):
|
|
351
|
+
pass
|
|
352
|
+
try:
|
|
353
|
+
os.unlink(PID_FILE)
|
|
354
|
+
except OSError:
|
|
355
|
+
pass
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""CLI entry point for claude-arcade."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DESCRIPTION = """\
|
|
8
|
+
claude-arcade — play games while Claude thinks
|
|
9
|
+
|
|
10
|
+
Play a mini-game automatically every time Claude Code runs a tool,
|
|
11
|
+
or launch one manually whenever you feel like it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
examples:
|
|
16
|
+
claude-arcade setup configure Claude Code hooks (one-time setup)
|
|
17
|
+
claude-arcade play launch Bird Hunt right now
|
|
18
|
+
claude-arcade unsetup remove hooks from Claude Code settings
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None: # noqa: C901
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
print(
|
|
25
|
+
"claude-arcade requires a Unix-like terminal (macOS / Linux).\n"
|
|
26
|
+
"Windows support is not available yet."
|
|
27
|
+
)
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="claude-arcade",
|
|
32
|
+
description=DESCRIPTION,
|
|
33
|
+
epilog=EPILOG,
|
|
34
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
35
|
+
)
|
|
36
|
+
sub = parser.add_subparsers(dest="cmd", metavar="<command>")
|
|
37
|
+
|
|
38
|
+
sub.add_parser("setup", help="Configure Claude Code hooks (one-time setup)")
|
|
39
|
+
sub.add_parser("unsetup", help="Remove Claude Code hooks")
|
|
40
|
+
|
|
41
|
+
play_p = sub.add_parser("play", help="Play a game right now")
|
|
42
|
+
play_p.add_argument(
|
|
43
|
+
"game",
|
|
44
|
+
nargs="?",
|
|
45
|
+
default="bird",
|
|
46
|
+
choices=["bird"],
|
|
47
|
+
help="Which game to play (default: bird)",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# "start" and "stop" are called internally by Claude Code hooks.
|
|
51
|
+
# They are intentionally omitted from the help listing.
|
|
52
|
+
|
|
53
|
+
# Handle internal hook commands before full arg parsing to keep them hidden.
|
|
54
|
+
if len(sys.argv) == 2 and sys.argv[1] in ("start", "stop"):
|
|
55
|
+
if sys.argv[1] == "start":
|
|
56
|
+
from .bird_game import start_background
|
|
57
|
+
start_background()
|
|
58
|
+
else:
|
|
59
|
+
from .bird_game import stop_background
|
|
60
|
+
stop_background()
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
args = parser.parse_args()
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ setup
|
|
66
|
+
if args.cmd == "setup":
|
|
67
|
+
from .setup_hooks import setup
|
|
68
|
+
setup()
|
|
69
|
+
|
|
70
|
+
elif args.cmd == "unsetup":
|
|
71
|
+
from .setup_hooks import unsetup
|
|
72
|
+
unsetup()
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ play
|
|
75
|
+
elif args.cmd == "play":
|
|
76
|
+
import curses
|
|
77
|
+
from .bird_game import bird_game
|
|
78
|
+
try:
|
|
79
|
+
curses.wrapper(bird_game)
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ help
|
|
84
|
+
else:
|
|
85
|
+
parser.print_help()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Configure (and remove) Claude Code hooks for claude-arcade."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_settings() -> dict:
|
|
12
|
+
if not os.path.exists(SETTINGS_PATH):
|
|
13
|
+
return {}
|
|
14
|
+
try:
|
|
15
|
+
with open(SETTINGS_PATH) as fh:
|
|
16
|
+
return json.load(fh)
|
|
17
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
18
|
+
print(f"Warning: could not read {SETTINGS_PATH}: {exc}")
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _save_settings(settings: dict) -> None:
|
|
23
|
+
os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True)
|
|
24
|
+
with open(SETTINGS_PATH, "w") as fh:
|
|
25
|
+
json.dump(settings, fh, indent=2)
|
|
26
|
+
fh.write("\n")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _arcade_bin() -> str:
|
|
30
|
+
path = shutil.which("claude-arcade")
|
|
31
|
+
if not path:
|
|
32
|
+
print("Error: 'claude-arcade' not found in PATH.")
|
|
33
|
+
print("Install it first: pipx install claude-arcade")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_arcade_hook(hook_list: list, bin_path: str) -> bool:
|
|
39
|
+
for entry in hook_list:
|
|
40
|
+
for h in entry.get("hooks", []):
|
|
41
|
+
if bin_path in h.get("command", ""):
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def setup() -> None:
|
|
47
|
+
"""Write claude-arcade hooks into ~/.claude/settings.json."""
|
|
48
|
+
bin_path = _arcade_bin()
|
|
49
|
+
settings = _load_settings()
|
|
50
|
+
settings.setdefault("hooks", {})
|
|
51
|
+
|
|
52
|
+
start_entry = {
|
|
53
|
+
"matcher": "",
|
|
54
|
+
"hooks": [{"type": "command", "command": f"{bin_path} start"}],
|
|
55
|
+
}
|
|
56
|
+
stop_entry = {
|
|
57
|
+
"matcher": "",
|
|
58
|
+
"hooks": [{"type": "command", "command": f"{bin_path} stop"}],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
added = []
|
|
62
|
+
for event, entry in [
|
|
63
|
+
("PreToolUse", start_entry),
|
|
64
|
+
("PostToolUse", stop_entry),
|
|
65
|
+
("Stop", stop_entry),
|
|
66
|
+
]:
|
|
67
|
+
settings["hooks"].setdefault(event, [])
|
|
68
|
+
if not _has_arcade_hook(settings["hooks"][event], bin_path):
|
|
69
|
+
settings["hooks"][event].append(entry)
|
|
70
|
+
added.append(event)
|
|
71
|
+
|
|
72
|
+
_save_settings(settings)
|
|
73
|
+
|
|
74
|
+
if added:
|
|
75
|
+
print(f"claude-arcade hooks added for: {', '.join(added)}")
|
|
76
|
+
else:
|
|
77
|
+
print("claude-arcade hooks were already configured — nothing changed.")
|
|
78
|
+
|
|
79
|
+
print()
|
|
80
|
+
print(f"Settings file: {SETTINGS_PATH}")
|
|
81
|
+
print()
|
|
82
|
+
print("How it works:")
|
|
83
|
+
print(" • Start Claude Code as usual: claude")
|
|
84
|
+
print(" • Whenever Claude calls a tool the bird game launches automatically.")
|
|
85
|
+
print(" • The game closes and your terminal is restored when Claude finishes.")
|
|
86
|
+
print()
|
|
87
|
+
print("Play manually at any time: claude-arcade play")
|
|
88
|
+
print("Remove hooks: claude-arcade unsetup")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def unsetup() -> None:
|
|
92
|
+
"""Remove claude-arcade hooks from ~/.claude/settings.json."""
|
|
93
|
+
if not os.path.exists(SETTINGS_PATH):
|
|
94
|
+
print("No Claude Code settings file found — nothing to remove.")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
bin_path = _arcade_bin()
|
|
98
|
+
settings = _load_settings()
|
|
99
|
+
removed = []
|
|
100
|
+
|
|
101
|
+
for event in ("PreToolUse", "PostToolUse", "Stop"):
|
|
102
|
+
original = settings.get("hooks", {}).get(event, [])
|
|
103
|
+
filtered = [
|
|
104
|
+
e for e in original
|
|
105
|
+
if not _has_arcade_hook([e], bin_path)
|
|
106
|
+
]
|
|
107
|
+
if len(filtered) < len(original):
|
|
108
|
+
settings["hooks"][event] = filtered
|
|
109
|
+
removed.append(event)
|
|
110
|
+
|
|
111
|
+
if removed:
|
|
112
|
+
_save_settings(settings)
|
|
113
|
+
print(f"claude-arcade hooks removed from: {', '.join(removed)}")
|
|
114
|
+
else:
|
|
115
|
+
print("No claude-arcade hooks found — nothing to remove.")
|