botapest 0.2.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,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ .playwright-mcp/
4
+ __pycache__/
botapest-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LeChristopher Blackwell
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,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: botapest
3
+ Version: 0.2.0
4
+ Summary: Botapest City — Habbo-style visualization of Claude Code agents building your repo as a city
5
+ Author-email: LeChristopher Blackwell <codeblackwell@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,claude-code,isometric,pixel-art,visualization
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: fastapi>=0.110
11
+ Requires-Dist: uvicorn>=0.29
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Botapest City
15
+
16
+ A Habbo-style isometric visualization of Claude Code at work, in two views on
17
+ one page. **The dispatch floor** (City Hall): every agent is a pixel worker —
18
+ your main session checks in at reception, subagents walk through the door when
19
+ spawned, and tool calls send workers to stations — the CRT terminal for `Bash`,
20
+ the archive shelf for `Read`/`Grep`, the workshop for `Edit`/`Write`, the
21
+ telephone booth for web tools. **The skyline above**: the city is the codebase
22
+ itself, seeded from git history and zoned by architecture (a zoning manifest
23
+ maps globs to components and layers; floors = cross-component centrality,
24
+ footprint = lines of code, window glow = recency; managed services float as
25
+ tethered clouds). As agents work, the city builds itself out — edits wrap
26
+ buildings in scaffolding, a `git commit` drops the scaffolds and adds floors,
27
+ new files pop up new buildings. `/city.html` is a full-screen explorer with
28
+ pan/zoom. The city re-seeds itself from git whenever HEAD moves.
29
+
30
+ ## Install & run
31
+
32
+ ```bash
33
+ uv tool install /path/to/botapest # installs the `botapest` CLI globally
34
+ botapest attach # one-time: fire-and-forget hooks into ~/.claude/settings.json
35
+
36
+ cd ~/code/any-repo
37
+ botapest # its city on http://localhost:4242
38
+ ```
39
+
40
+ Open http://localhost:4242, then start any Claude Code session — new sessions
41
+ report in automatically. No live session handy? http://localhost:4242/?demo
42
+ runs a scripted day in the city. `botapest detach` removes the hooks (a backup
43
+ of settings.json is written on attach). Flags: `--repo`, `--port`, `--zone`.
44
+
45
+ **Zoning:** if the repo has a `.botapest.json` manifest it defines the city's
46
+ components, layers, and clouds (see `city/maisight.json` for a full example);
47
+ otherwise botapest auto-zones from the repo's top-level directories. For repo
48
+ hackers: `just dev` serves ../maisight with its manifest.
49
+
50
+ ## How it works
51
+
52
+ ```
53
+ Claude Code hooks ──curl──▶ POST /hook ──▶ normalize ──SSE──▶ canvas renderer
54
+ ```
55
+
56
+ - `hooks.py` registers a `curl -m 1 ... || true` command on eight hook events
57
+ (SessionStart, UserPromptSubmit, Notification, Pre/PostToolUse, Stop,
58
+ SubagentStop, SessionEnd). It never blocks Claude Code — if the hotel isn't
59
+ running, the curl times out silently.
60
+ - `server.py` (FastAPI) trims each payload to `{event, session, tool, detail}`
61
+ (plus `agent_id`/`agent_type` for events fired inside subagents), keeps the
62
+ last 100 events, and broadcasts over SSE — late-joining browsers get a replay.
63
+ - `static/render.js` + `static/hotel.js` draw the room and guests on a plain
64
+ canvas — original pixel art, no Habbo assets. Subagent tool calls move their
65
+ own guest, a pulsing gold aura marks a session waiting on you (permission or
66
+ idle), and hovering a guest shows who they are and what they're doing.
67
+
68
+ ## Known limits (v0)
69
+
70
+ - Front-facing avatars only; no directional sprites yet.
@@ -0,0 +1,57 @@
1
+ # Botapest City
2
+
3
+ A Habbo-style isometric visualization of Claude Code at work, in two views on
4
+ one page. **The dispatch floor** (City Hall): every agent is a pixel worker —
5
+ your main session checks in at reception, subagents walk through the door when
6
+ spawned, and tool calls send workers to stations — the CRT terminal for `Bash`,
7
+ the archive shelf for `Read`/`Grep`, the workshop for `Edit`/`Write`, the
8
+ telephone booth for web tools. **The skyline above**: the city is the codebase
9
+ itself, seeded from git history and zoned by architecture (a zoning manifest
10
+ maps globs to components and layers; floors = cross-component centrality,
11
+ footprint = lines of code, window glow = recency; managed services float as
12
+ tethered clouds). As agents work, the city builds itself out — edits wrap
13
+ buildings in scaffolding, a `git commit` drops the scaffolds and adds floors,
14
+ new files pop up new buildings. `/city.html` is a full-screen explorer with
15
+ pan/zoom. The city re-seeds itself from git whenever HEAD moves.
16
+
17
+ ## Install & run
18
+
19
+ ```bash
20
+ uv tool install /path/to/botapest # installs the `botapest` CLI globally
21
+ botapest attach # one-time: fire-and-forget hooks into ~/.claude/settings.json
22
+
23
+ cd ~/code/any-repo
24
+ botapest # its city on http://localhost:4242
25
+ ```
26
+
27
+ Open http://localhost:4242, then start any Claude Code session — new sessions
28
+ report in automatically. No live session handy? http://localhost:4242/?demo
29
+ runs a scripted day in the city. `botapest detach` removes the hooks (a backup
30
+ of settings.json is written on attach). Flags: `--repo`, `--port`, `--zone`.
31
+
32
+ **Zoning:** if the repo has a `.botapest.json` manifest it defines the city's
33
+ components, layers, and clouds (see `city/maisight.json` for a full example);
34
+ otherwise botapest auto-zones from the repo's top-level directories. For repo
35
+ hackers: `just dev` serves ../maisight with its manifest.
36
+
37
+ ## How it works
38
+
39
+ ```
40
+ Claude Code hooks ──curl──▶ POST /hook ──▶ normalize ──SSE──▶ canvas renderer
41
+ ```
42
+
43
+ - `hooks.py` registers a `curl -m 1 ... || true` command on eight hook events
44
+ (SessionStart, UserPromptSubmit, Notification, Pre/PostToolUse, Stop,
45
+ SubagentStop, SessionEnd). It never blocks Claude Code — if the hotel isn't
46
+ running, the curl times out silently.
47
+ - `server.py` (FastAPI) trims each payload to `{event, session, tool, detail}`
48
+ (plus `agent_id`/`agent_type` for events fired inside subagents), keeps the
49
+ last 100 events, and broadcasts over SSE — late-joining browsers get a replay.
50
+ - `static/render.js` + `static/hotel.js` draw the room and guests on a plain
51
+ canvas — original pixel art, no Habbo assets. Subagent tool calls move their
52
+ own guest, a pulsing gold aura marks a session waiting on you (permission or
53
+ idle), and hovering a guest shows who they are and what they're doing.
54
+
55
+ ## Known limits (v0)
56
+
57
+ - Front-facing avatars only; no directional sprites yet.
File without changes
@@ -0,0 +1,49 @@
1
+ """botapest — run from any git repo to watch agents build its city."""
2
+ import argparse
3
+ import subprocess
4
+ import time
5
+
6
+ import uvicorn
7
+
8
+ from . import hooks, server
9
+
10
+
11
+ def free_port(port: int) -> None:
12
+ holders = lambda: subprocess.run(["lsof", "-ti", f":{port}"],
13
+ capture_output=True, text=True).stdout.split()
14
+ pids = holders()
15
+ if not pids:
16
+ return
17
+ print(f"freeing port {port} (pid {', '.join(pids)})")
18
+ for sig in ("-TERM", "-KILL"):
19
+ subprocess.run(["kill", sig, *pids], capture_output=True)
20
+ for _ in range(20):
21
+ if not holders():
22
+ return
23
+ time.sleep(.1)
24
+
25
+
26
+ def main() -> None:
27
+ parser = argparse.ArgumentParser(
28
+ prog="botapest",
29
+ description="Habbo-style visualization of Claude Code agents building your repo as a city.")
30
+ parser.add_argument("command", nargs="?", default="serve", choices=["serve", "attach", "detach"],
31
+ help="serve the city (default), or attach/detach Claude Code hooks")
32
+ parser.add_argument("--repo", default=".", help="git repo to map as the city (default: cwd)")
33
+ parser.add_argument("--zone", help="zoning manifest (default: <repo>/.botapest.json, else auto-zoned)")
34
+ parser.add_argument("--port", type=int, default=4242)
35
+ args = parser.parse_args()
36
+
37
+ if args.command == "attach":
38
+ hooks.attach(args.port)
39
+ elif args.command == "detach":
40
+ hooks.detach()
41
+ else:
42
+ free_port(args.port)
43
+ server.configure(args.repo, args.zone)
44
+ print(f"Botapest City on http://localhost:{args.port} (repo: {args.repo})")
45
+ uvicorn.run(server.app, port=args.port, log_level="warning")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,62 @@
1
+ """Attach/detach the city to Claude Code via ~/.claude/settings.json hooks.
2
+
3
+ Hooks fire-and-forget to localhost with a 1s timeout, so Claude Code is
4
+ never blocked when the city isn't running.
5
+ """
6
+ import json
7
+ from pathlib import Path
8
+
9
+ SETTINGS = Path.home() / ".claude" / "settings.json"
10
+ BACKUP = SETTINGS.with_suffix(".json.botapest.bak")
11
+ EVENTS = [
12
+ "SessionStart",
13
+ "UserPromptSubmit",
14
+ "Notification",
15
+ "PreToolUse",
16
+ "PostToolUse",
17
+ "Stop",
18
+ "SubagentStop",
19
+ "SessionEnd",
20
+ ]
21
+ def command(port: int) -> str:
22
+ return (
23
+ f"curl -sf -m 1 -X POST http://localhost:{port}/hook "
24
+ "-H 'Content-Type: application/json' --data-binary @- "
25
+ ">/dev/null 2>&1 || true"
26
+ )
27
+
28
+
29
+ def has_hotel_hook(entries: list) -> bool:
30
+ return any(
31
+ "/hook" in hook.get("command", "") and "curl" in hook.get("command", "")
32
+ for entry in entries
33
+ for hook in entry.get("hooks", [])
34
+ )
35
+
36
+
37
+ def attach(port: int = 4242) -> None:
38
+ settings = json.loads(SETTINGS.read_text()) if SETTINGS.exists() else {}
39
+ BACKUP.write_text(json.dumps(settings, indent=2))
40
+ hooks = settings.setdefault("hooks", {})
41
+ added = 0
42
+ for event in EVENTS:
43
+ entries = hooks.setdefault(event, [])
44
+ if not has_hotel_hook(entries):
45
+ entries.append({"matcher": "", "hooks": [{"type": "command", "command": command(port)}]})
46
+ added += 1
47
+ SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
48
+ print(f"attached {added} events ({SETTINGS}, backup at {BACKUP.name})")
49
+ print("new Claude Code sessions will now report to the city")
50
+
51
+
52
+ def detach() -> None:
53
+ if not SETTINGS.exists():
54
+ return
55
+ settings = json.loads(SETTINGS.read_text())
56
+ hooks = settings.get("hooks", {})
57
+ for event in list(hooks):
58
+ hooks[event] = [e for e in hooks[event] if not has_hotel_hook([e])]
59
+ if not hooks[event]:
60
+ del hooks[event]
61
+ SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
62
+ print("detached — botapest hooks removed")
@@ -0,0 +1,68 @@
1
+ """Build a city snapshot from a git repo + zoning manifest.
2
+
3
+ Per building: loc (mass), commits + age (attention), centrality (how many
4
+ other components it has co-committed with). A component may set "group": N
5
+ to aggregate files into one building per N-segment path prefix.
6
+ """
7
+ import subprocess
8
+ import time
9
+ from fnmatch import fnmatch
10
+ from pathlib import Path
11
+
12
+
13
+ def git(repo: str, *args: str) -> str:
14
+ return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True).stdout
15
+
16
+
17
+ def component_of(path: str, components: list) -> str | None:
18
+ for c in components:
19
+ if any(fnmatch(path, g) for g in c["globs"]):
20
+ return c["id"]
21
+ return None
22
+
23
+
24
+ def count_lines(path: Path) -> int:
25
+ try:
26
+ return sum(1 for _ in open(path, errors="ignore"))
27
+ except OSError:
28
+ return 0
29
+
30
+
31
+ def seed(repo: str, zone: dict) -> dict:
32
+ comp = {f: component_of(f, zone["components"]) for f in git(repo, "ls-files").splitlines()}
33
+ files = [f for f, c in comp.items() if c]
34
+
35
+ commits = dict.fromkeys(files, 0)
36
+ last = dict.fromkeys(files, 0)
37
+ cocomp = {f: set() for f in files}
38
+ timestamp = 0
39
+ touched: list[str] = []
40
+ for line in git(repo, "log", "--name-only", "--pretty=%ct").splitlines() + [""]:
41
+ if line.isdigit() or not line: # commit boundary: flush previous
42
+ comps = {comp[f] for f in touched}
43
+ for f in touched:
44
+ commits[f] += 1
45
+ last[f] = max(last[f], timestamp)
46
+ if len(touched) <= 15: # mega-commits aren't coupling signal
47
+ cocomp[f] |= comps - {comp[f]}
48
+ touched = []
49
+ if line.isdigit():
50
+ timestamp = int(line)
51
+ elif line in commits:
52
+ touched.append(line)
53
+
54
+ group = {c["id"]: c.get("group") for c in zone["components"]}
55
+ buildings: dict[str, dict] = {}
56
+ now = time.time()
57
+ for f in sorted(files):
58
+ depth = group[comp[f]]
59
+ key = "/".join(f.split("/")[:depth]) if depth else f
60
+ b = buildings.setdefault(key, {"path": key, "component": comp[f], "loc": 0,
61
+ "commits": 0, "centrality": 0, "age_days": 9999, "files": 0})
62
+ b["loc"] += count_lines(Path(repo, f))
63
+ b["commits"] += commits[f]
64
+ b["centrality"] = max(b["centrality"], len(cocomp[f]))
65
+ if last[f]:
66
+ b["age_days"] = min(b["age_days"], round((now - last[f]) / 86400))
67
+ b["files"] += 1
68
+ return {"zone": zone, "buildings": list(buildings.values())}
@@ -0,0 +1,112 @@
1
+ """Botapest City — event relay + city seeding.
2
+
3
+ Receives Claude Code hook payloads on POST /hook, normalizes them, and
4
+ broadcasts to browsers over SSE at GET /events. GET /city-data.json
5
+ seeds the configured repo's city on demand (cached per git HEAD).
6
+ """
7
+ import asyncio
8
+ import json
9
+ import os
10
+ from collections import deque
11
+ from pathlib import Path
12
+
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import StreamingResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+
17
+ from .seed import git, seed
18
+ from .zone import load_zone
19
+
20
+ app = FastAPI()
21
+ subscribers: set[asyncio.Queue] = set()
22
+ history: deque = deque(maxlen=100)
23
+ city = {"repo": ".", "zone_path": None, "head": None, "data": None}
24
+
25
+
26
+ def configure(repo: str, zone_path: str | None) -> None:
27
+ city.update(repo=repo, zone_path=zone_path, head=None, data=None)
28
+
29
+ MAX_DETAIL = 80
30
+
31
+
32
+ def normalize(raw: dict) -> dict:
33
+ event = {
34
+ "event": raw.get("hook_event_name", "unknown"),
35
+ "session": (raw.get("session_id") or "")[:8],
36
+ }
37
+ if raw.get("agent_id"): # event fired inside a subagent
38
+ event["agent_id"] = raw["agent_id"][:8]
39
+ event["agent_type"] = raw.get("agent_type", "agent")
40
+ tool = raw.get("tool_name")
41
+ if tool:
42
+ event["tool"] = tool
43
+ tool_input = raw.get("tool_input") or {}
44
+ detail = (
45
+ tool_input.get("file_path")
46
+ or tool_input.get("command")
47
+ or tool_input.get("pattern")
48
+ or tool_input.get("query")
49
+ or tool_input.get("url")
50
+ or tool_input.get("description")
51
+ or ""
52
+ )
53
+ if detail.startswith("/"):
54
+ detail = os.path.basename(detail)
55
+ event["detail"] = str(detail)[:MAX_DETAIL]
56
+ file_path, cwd = tool_input.get("file_path") or "", raw.get("cwd") or ""
57
+ if cwd and file_path.startswith(cwd + "/"):
58
+ event["path"] = file_path[len(cwd) + 1:]
59
+ if tool == "Bash" and "git commit" in str(tool_input.get("command") or ""):
60
+ event["commit"] = True
61
+ if tool in ("Task", "Agent"):
62
+ event["agent_type"] = tool_input.get("subagent_type", "agent")
63
+ event["agent_name"] = str(tool_input.get("description", "agent"))[:40]
64
+ if event["event"] == "UserPromptSubmit":
65
+ event["detail"] = str(raw.get("prompt") or "")[:MAX_DETAIL]
66
+ if event["event"] == "Notification":
67
+ event["detail"] = str(raw.get("message") or "")[:MAX_DETAIL]
68
+ return event
69
+
70
+
71
+ @app.post("/hook")
72
+ async def hook(request: Request) -> dict:
73
+ raw = await request.json()
74
+ event = normalize(raw)
75
+ history.append(event)
76
+ for queue in subscribers:
77
+ queue.put_nowait(event)
78
+ return {"ok": True}
79
+
80
+
81
+ @app.get("/city-data.json")
82
+ def city_data() -> dict:
83
+ head = git(city["repo"], "rev-parse", "HEAD").strip()
84
+ if city["head"] != head:
85
+ city["data"] = seed(city["repo"], load_zone(city["repo"], city["zone_path"]))
86
+ city["head"] = head
87
+ return city["data"]
88
+
89
+
90
+ @app.get("/events")
91
+ async def events() -> StreamingResponse:
92
+ queue: asyncio.Queue = asyncio.Queue()
93
+ for event in history: # replay so late joiners see state
94
+ queue.put_nowait(event)
95
+ subscribers.add(queue)
96
+
97
+ async def stream():
98
+ try:
99
+ yield "retry: 2000\n\n"
100
+ while True:
101
+ try:
102
+ event = await asyncio.wait_for(queue.get(), timeout=15)
103
+ yield f"data: {json.dumps(event)}\n\n"
104
+ except asyncio.TimeoutError:
105
+ yield ": ping\n\n"
106
+ finally:
107
+ subscribers.discard(queue)
108
+
109
+ return StreamingResponse(stream(), media_type="text/event-stream")
110
+
111
+
112
+ app.mount("/", StaticFiles(directory=Path(__file__).parent / "static", html=True), name="static")
@@ -0,0 +1,34 @@
1
+ // City backdrop on the main page: fixed view, grows from live events via cityHandle().
2
+ const cityCanvas = document.getElementById('citybg');
3
+ const cityCtx = cityCanvas.getContext('2d');
4
+ const cityCam = { ox: 0, oy: 0, s: 1 };
5
+ let cityState = null;
6
+
7
+ fetch('city-data.json').then(r => r.json()).then(data => {
8
+ cityState = City.layout(data);
9
+ City.fit(cityCam, cityCanvas, cityState, 115, 30, 1.18);
10
+ requestAnimationFrame(function frame(t) {
11
+ cityCtx.clearRect(0, 0, cityCanvas.width, cityCanvas.height);
12
+ City.draw(cityCtx, cityCam, cityState, t);
13
+ requestAnimationFrame(frame);
14
+ });
15
+ });
16
+
17
+ function cityHandle(e) {
18
+ if (cityState) City.applyEvent(cityState, e);
19
+ }
20
+
21
+ cityCanvas.addEventListener('mousemove', m => {
22
+ const r = cityCanvas.getBoundingClientRect();
23
+ const hit = cityState && City.pick(cityState,
24
+ (m.clientX - r.left) * (cityCanvas.width / r.width),
25
+ (m.clientY - r.top) * (cityCanvas.height / r.height));
26
+ const tip = document.getElementById('tooltip');
27
+ if (hit) {
28
+ tip.textContent = `${hit.path} · ${hit.floors} fl · ${hit.commits} commits`
29
+ + `${hit.scaffold ? ' · under construction' : ''}`;
30
+ tip.style.left = `${m.clientX + 14}px`;
31
+ tip.style.top = `${m.clientY + 14}px`;
32
+ tip.style.display = 'block';
33
+ } else tip.style.display = 'none';
34
+ });