agentopolis 0.6.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.
- agentopolis-0.6.0/.gitignore +5 -0
- agentopolis-0.6.0/CHANGELOG.md +74 -0
- agentopolis-0.6.0/LICENSE +21 -0
- agentopolis-0.6.0/PKG-INFO +73 -0
- agentopolis-0.6.0/README.md +57 -0
- agentopolis-0.6.0/agentopolis/__init__.py +0 -0
- agentopolis-0.6.0/agentopolis/cli.py +72 -0
- agentopolis-0.6.0/agentopolis/hooks.py +69 -0
- agentopolis-0.6.0/agentopolis/nation.py +129 -0
- agentopolis-0.6.0/agentopolis/seed.py +263 -0
- agentopolis-0.6.0/agentopolis/server.py +171 -0
- agentopolis-0.6.0/agentopolis/static/city-live.js +97 -0
- agentopolis-0.6.0/agentopolis/static/city-render.js +781 -0
- agentopolis-0.6.0/agentopolis/static/city-scape.js +275 -0
- agentopolis-0.6.0/agentopolis/static/hall-tiers.js +83 -0
- agentopolis-0.6.0/agentopolis/static/hall.js +210 -0
- agentopolis-0.6.0/agentopolis/static/hotel.js +232 -0
- agentopolis-0.6.0/agentopolis/static/index.html +241 -0
- agentopolis-0.6.0/agentopolis/static/nation.js +747 -0
- agentopolis-0.6.0/agentopolis/static/render.js +175 -0
- agentopolis-0.6.0/agentopolis/zone.py +70 -0
- agentopolis-0.6.0/city/maisight.json +35 -0
- agentopolis-0.6.0/justfile +27 -0
- agentopolis-0.6.0/packaging/homebrew/agentopolis.rb +87 -0
- agentopolis-0.6.0/pyproject.toml +26 -0
- agentopolis-0.6.0/uv.lock +268 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.6.0] - 2026-06-13
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Renamed the project from Botapest to **Agentopolis**. The CLI is now
|
|
11
|
+
`agentopolis`, the PyPI package is `agentopolis`, the zoning manifests are
|
|
12
|
+
`.agentopolis.json` / `.agentopolis-nation.json`, and the dev env vars are
|
|
13
|
+
`AGENTOPOLIS_*`. Re-run `agentopolis attach` after upgrading.
|
|
14
|
+
|
|
15
|
+
## [0.5.0] - 2026-06-13
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Single-view dashboard combining the city map and the dispatch floor on one page,
|
|
19
|
+
with eighth-turn rotation and pan/zoom on the main page.
|
|
20
|
+
- Dispatch room reimagined as a zoomed city-hall interior with departmental
|
|
21
|
+
stations — Information, Records, Operations, Permits & Works, and Switchboard.
|
|
22
|
+
- Hover tooltips on the freight train, container ships, and the package track;
|
|
23
|
+
hovering the track lists every package dependency.
|
|
24
|
+
- Dynamic district color key and a cloud-services section in the legend.
|
|
25
|
+
- Automatic cloud-provider detection from dependencies and config files
|
|
26
|
+
(AWS, GCP, Azure, Cloudflare, Stripe, OpenAI, Anthropic, GitHub Actions,
|
|
27
|
+
Terraform, and more), each tethered to the district that uses it.
|
|
28
|
+
- Rotate / zoom / reset controls panel in the top-right of both the main map and
|
|
29
|
+
the standalone `city.html` explorer.
|
|
30
|
+
|
|
31
|
+
## [0.4.0] - 2026-06-12
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- Diamond-ring radial city layout: concentric district rings around a civic plaza,
|
|
35
|
+
canals between layers, a radial street grid, a freight train, and rotation.
|
|
36
|
+
|
|
37
|
+
## [0.3.1] - 2026-06-12
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- Organic, structure-driven layout with repo-relative thresholds.
|
|
41
|
+
- River with bridges, package freight line, docker port, and a cemetery for
|
|
42
|
+
deleted files.
|
|
43
|
+
|
|
44
|
+
## [0.3.0] - 2026-06-12
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- Organic blocks: carved edge lots and per-lot building jitter.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- Quieter exit on Ctrl+C.
|
|
51
|
+
|
|
52
|
+
## [0.2.1] - 2026-06-12
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- Kind-based building archetypes, language signs, and an isometric city hall.
|
|
56
|
+
- GitHub repository linked in the package metadata.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
- Ctrl+C hung while browsers held SSE streams open.
|
|
60
|
+
|
|
61
|
+
## [0.2.0] - 2026-06-12
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
- Installable `botapest` CLI, published to PyPI.
|
|
65
|
+
- Auto-free the port on startup.
|
|
66
|
+
|
|
67
|
+
## [0.1.0] - 2026-06-12
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
- Initial release: The Grand Botapest Hotel — a Habbo-style visualizer for Claude
|
|
71
|
+
Code agents, with subagent attribution, replay, waiting aura, and tooltips.
|
|
72
|
+
- Botapest City prototype: an architecture skyline generated from a git repo.
|
|
73
|
+
- Combined dispatch floor and live skyline, with a continuous cityscape and
|
|
74
|
+
waterfront.
|
|
@@ -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,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentopolis
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Agentopolis — Habbo-style visualization of Claude Code agents building your repo as a city
|
|
5
|
+
Project-URL: Homepage, https://github.com/CodeBlackwell/agentopolis
|
|
6
|
+
Project-URL: Source, https://github.com/CodeBlackwell/agentopolis
|
|
7
|
+
Project-URL: Issues, https://github.com/CodeBlackwell/agentopolis/issues
|
|
8
|
+
Author-email: LeChristopher Blackwell <codeblackwell@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,claude-code,isometric,pixel-art,visualization
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: fastapi>=0.110
|
|
14
|
+
Requires-Dist: uvicorn>=0.29
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Agentopolis
|
|
18
|
+
|
|
19
|
+
A Habbo-style isometric visualization of Claude Code at work, in two views on
|
|
20
|
+
one page. **The dispatch floor** (City Hall): every agent is a pixel worker —
|
|
21
|
+
your main session checks in at reception, subagents walk through the door when
|
|
22
|
+
spawned, and tool calls send workers to stations — the CRT terminal for `Bash`,
|
|
23
|
+
the archive shelf for `Read`/`Grep`, the workshop for `Edit`/`Write`, the
|
|
24
|
+
telephone booth for web tools. **The skyline above**: the city is the codebase
|
|
25
|
+
itself, seeded from git history and zoned by architecture (a zoning manifest
|
|
26
|
+
maps globs to components and layers; floors = cross-component centrality,
|
|
27
|
+
footprint = lines of code, window glow = recency; managed services float as
|
|
28
|
+
tethered clouds). As agents work, the city builds itself out — edits wrap
|
|
29
|
+
buildings in scaffolding, a `git commit` drops the scaffolds and adds floors,
|
|
30
|
+
new files pop up new buildings. `/city.html` is a full-screen explorer with
|
|
31
|
+
pan/zoom. The city re-seeds itself from git whenever HEAD moves.
|
|
32
|
+
|
|
33
|
+
## Install & run
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install agentopolis # or: pipx install agentopolis
|
|
37
|
+
agentopolis attach # one-time: fire-and-forget hooks into ~/.claude/settings.json
|
|
38
|
+
|
|
39
|
+
cd ~/code/any-repo
|
|
40
|
+
agentopolis # its city on http://localhost:4242
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open http://localhost:4242, then start any Claude Code session — new sessions
|
|
44
|
+
report in automatically. No live session handy? http://localhost:4242/?demo
|
|
45
|
+
runs a scripted day in the city. `agentopolis detach` removes the hooks (a backup
|
|
46
|
+
of settings.json is written on attach). Flags: `--repo`, `--port`, `--zone`.
|
|
47
|
+
|
|
48
|
+
**Zoning:** if the repo has a `.agentopolis.json` manifest it defines the city's
|
|
49
|
+
components, layers, and clouds (see `city/maisight.json` for a full example);
|
|
50
|
+
otherwise agentopolis auto-zones from the repo's top-level directories. For repo
|
|
51
|
+
hackers: `just dev` serves ../maisight with its manifest.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Claude Code hooks ──curl──▶ POST /hook ──▶ normalize ──SSE──▶ canvas renderer
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- `hooks.py` registers a `curl -m 1 ... || true` command on eight hook events
|
|
60
|
+
(SessionStart, UserPromptSubmit, Notification, Pre/PostToolUse, Stop,
|
|
61
|
+
SubagentStop, SessionEnd). It never blocks Claude Code — if the hotel isn't
|
|
62
|
+
running, the curl times out silently.
|
|
63
|
+
- `server.py` (FastAPI) trims each payload to `{event, session, tool, detail}`
|
|
64
|
+
(plus `agent_id`/`agent_type` for events fired inside subagents), keeps the
|
|
65
|
+
last 100 events, and broadcasts over SSE — late-joining browsers get a replay.
|
|
66
|
+
- `static/render.js` + `static/hotel.js` draw the room and guests on a plain
|
|
67
|
+
canvas — original pixel art, no Habbo assets. Subagent tool calls move their
|
|
68
|
+
own guest, a pulsing gold aura marks a session waiting on you (permission or
|
|
69
|
+
idle), and hovering a guest shows who they are and what they're doing.
|
|
70
|
+
|
|
71
|
+
## Known limits (v0)
|
|
72
|
+
|
|
73
|
+
- Front-facing avatars only; no directional sprites yet.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Agentopolis
|
|
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 agentopolis # or: pipx install agentopolis
|
|
21
|
+
agentopolis attach # one-time: fire-and-forget hooks into ~/.claude/settings.json
|
|
22
|
+
|
|
23
|
+
cd ~/code/any-repo
|
|
24
|
+
agentopolis # 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. `agentopolis 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 `.agentopolis.json` manifest it defines the city's
|
|
33
|
+
components, layers, and clouds (see `city/maisight.json` for a full example);
|
|
34
|
+
otherwise agentopolis 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,72 @@
|
|
|
1
|
+
"""agentopolis — run from any git repo to watch agents build its city."""
|
|
2
|
+
import argparse
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from . import hooks, nation, server
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def free_port(port: int) -> None:
|
|
14
|
+
holders = lambda: subprocess.run(["lsof", "-ti", f":{port}"],
|
|
15
|
+
capture_output=True, text=True).stdout.split()
|
|
16
|
+
pids = holders()
|
|
17
|
+
if not pids:
|
|
18
|
+
return
|
|
19
|
+
print(f"freeing port {port} (pid {', '.join(pids)})")
|
|
20
|
+
for sig in ("-TERM", "-KILL"):
|
|
21
|
+
subprocess.run(["kill", sig, *pids], capture_output=True)
|
|
22
|
+
for _ in range(20):
|
|
23
|
+
if not holders():
|
|
24
|
+
return
|
|
25
|
+
time.sleep(.1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="agentopolis",
|
|
31
|
+
description="Habbo-style visualization of Claude Code agents building your repo as a city.")
|
|
32
|
+
parser.add_argument("command", nargs="?", default="serve", choices=["serve", "attach", "detach"],
|
|
33
|
+
help="serve the city (default), or attach/detach Claude Code hooks")
|
|
34
|
+
parser.add_argument("--repo", default=".", help="git repo to map as the city (default: cwd)")
|
|
35
|
+
parser.add_argument("--zone", help="zoning manifest (default: <repo>/.agentopolis.json, else auto-zoned)")
|
|
36
|
+
parser.add_argument("--root", help="map every git repo under this dir as a nation of cities")
|
|
37
|
+
parser.add_argument("--port", type=int, default=4242)
|
|
38
|
+
parser.add_argument("--no-open", action="store_true", help="don't open the city in a browser")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
if args.command == "attach":
|
|
42
|
+
hooks.attach(args.port)
|
|
43
|
+
elif args.command == "detach":
|
|
44
|
+
hooks.detach()
|
|
45
|
+
else:
|
|
46
|
+
free_port(args.port)
|
|
47
|
+
server.configure(args.repo, args.zone)
|
|
48
|
+
root = args.root or (nation.is_mother(args.repo) and args.repo) # a mother repo is a nation
|
|
49
|
+
if root:
|
|
50
|
+
server.configure_nation(root, None)
|
|
51
|
+
where = f"nation: {root}" if root else f"repo: {args.repo}"
|
|
52
|
+
url = f"http://localhost:{args.port}"
|
|
53
|
+
print(f"Agentopolis {'Nation' if root else 'City'} on {url} ({where})")
|
|
54
|
+
if not hooks.is_attached():
|
|
55
|
+
print("tip: run `agentopolis attach` so Claude Code sessions report to the city")
|
|
56
|
+
if not args.no_open:
|
|
57
|
+
opener = threading.Timer(1, lambda: webbrowser.open(url))
|
|
58
|
+
opener.daemon = True
|
|
59
|
+
opener.start()
|
|
60
|
+
# SSE streams watch runner.should_exit so open browsers don't block Ctrl+C;
|
|
61
|
+
# the graceful-shutdown timeout is the backstop for any other slow request
|
|
62
|
+
runner = uvicorn.Server(uvicorn.Config(server.app, port=args.port,
|
|
63
|
+
log_level="warning", timeout_graceful_shutdown=2))
|
|
64
|
+
server.runner = runner
|
|
65
|
+
try:
|
|
66
|
+
runner.run()
|
|
67
|
+
except KeyboardInterrupt: # uvicorn re-raises the Ctrl+C after shutdown
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
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.agentopolis.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 is_attached() -> bool:
|
|
38
|
+
if not SETTINGS.exists():
|
|
39
|
+
return False
|
|
40
|
+
hooks = json.loads(SETTINGS.read_text()).get("hooks", {})
|
|
41
|
+
return any(has_hotel_hook(entries) for entries in hooks.values())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def attach(port: int = 4242) -> None:
|
|
45
|
+
settings = json.loads(SETTINGS.read_text()) if SETTINGS.exists() else {}
|
|
46
|
+
BACKUP.write_text(json.dumps(settings, indent=2))
|
|
47
|
+
hooks = settings.setdefault("hooks", {})
|
|
48
|
+
added = 0
|
|
49
|
+
for event in EVENTS:
|
|
50
|
+
entries = hooks.setdefault(event, [])
|
|
51
|
+
if not has_hotel_hook(entries):
|
|
52
|
+
entries.append({"matcher": "", "hooks": [{"type": "command", "command": command(port)}]})
|
|
53
|
+
added += 1
|
|
54
|
+
SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
|
|
55
|
+
print(f"attached {added} events ({SETTINGS}, backup at {BACKUP.name})")
|
|
56
|
+
print("new Claude Code sessions will now report to the city")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detach() -> None:
|
|
60
|
+
if not SETTINGS.exists():
|
|
61
|
+
return
|
|
62
|
+
settings = json.loads(SETTINGS.read_text())
|
|
63
|
+
hooks = settings.get("hooks", {})
|
|
64
|
+
for event in list(hooks):
|
|
65
|
+
hooks[event] = [e for e in hooks[event] if not has_hotel_hook([e])]
|
|
66
|
+
if not hooks[event]:
|
|
67
|
+
del hooks[event]
|
|
68
|
+
SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
|
|
69
|
+
print("detached — agentopolis hooks removed")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Nation view: many repos as one map, grouped into states.
|
|
2
|
+
|
|
3
|
+
discover_repos() finds git repos one level under a root. summarize() reads
|
|
4
|
+
cheap git stats per repo (no file-content reads, so it scales to dozens).
|
|
5
|
+
load_nation() folds in an optional .agentopolis-nation.json that names the
|
|
6
|
+
states (repo clusters).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .seed import git, tracked
|
|
14
|
+
|
|
15
|
+
PALETTE = ["#16a085", "#5b8dd9", "#c0395b", "#d4a953", "#8e5d9f",
|
|
16
|
+
"#b5651d", "#2980b9", "#4a6b5c", "#c9b78a", "#5c6b73"]
|
|
17
|
+
|
|
18
|
+
FAMILY_NAMES = {
|
|
19
|
+
"backend": "Logic Highlands",
|
|
20
|
+
"frontend": "Interface Coast",
|
|
21
|
+
"infra": "Iron Province",
|
|
22
|
+
"data": "The Mines",
|
|
23
|
+
"docs": "The Scriptorium",
|
|
24
|
+
"neutral": "The Hinterlands",
|
|
25
|
+
}
|
|
26
|
+
FAMILY_COLOR = {
|
|
27
|
+
"backend": "#2980b9",
|
|
28
|
+
"frontend": "#27ae60",
|
|
29
|
+
"infra": "#7f8c8d",
|
|
30
|
+
"data": "#d4a953",
|
|
31
|
+
"docs": "#c9b78a",
|
|
32
|
+
"neutral": "#7d6b8a",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# extension → archetype family; picks the family with the most tracked files (assets ignored)
|
|
36
|
+
FAMILY_EXT = {
|
|
37
|
+
"backend": {"py", "go", "rb", "rs", "java", "php", "cs", "kt", "scala", "ex", "clj"},
|
|
38
|
+
"frontend": {"js", "ts", "jsx", "tsx", "vue", "svelte"},
|
|
39
|
+
"docs": {"md", "rst", "txt", "adoc"},
|
|
40
|
+
"infra": {"tf", "yml", "yaml", "sh", "toml", "dockerfile"},
|
|
41
|
+
"data": {"sql", "csv", "parquet", "ipynb"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def family_of(exts: Counter) -> str:
|
|
46
|
+
best, score = "neutral", 0
|
|
47
|
+
for fam, group in FAMILY_EXT.items():
|
|
48
|
+
n = sum(exts[e] for e in group)
|
|
49
|
+
if n > score:
|
|
50
|
+
best, score = fam, n
|
|
51
|
+
return best
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def discover_repos(root: str) -> list[str]:
|
|
55
|
+
out = []
|
|
56
|
+
for entry in sorted(Path(root).iterdir()):
|
|
57
|
+
if entry.is_symlink() or not entry.is_dir():
|
|
58
|
+
continue
|
|
59
|
+
if (entry / ".git").exists():
|
|
60
|
+
out.append(entry.name)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_mother(repo: str) -> bool:
|
|
65
|
+
"""A git repo that nests ≥2 git repos of its own — a metropolis, not a city."""
|
|
66
|
+
return (Path(repo) / ".git").exists() and len(discover_repos(repo)) >= 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def summarize(repo: str, exclude: set | None = None) -> dict:
|
|
70
|
+
files = tracked(repo, exclude)
|
|
71
|
+
last = git(repo, "log", "-1", "--format=%ct").strip()
|
|
72
|
+
commits = git(repo, "rev-list", "--count", "HEAD").strip()
|
|
73
|
+
exts = Counter(Path(f).suffix.lstrip(".").lower() for f in files if "." in f)
|
|
74
|
+
age = round((time.time() - int(last)) / 86400) if last else 9999
|
|
75
|
+
low = [f.lower() for f in files]
|
|
76
|
+
return {"files": len(files), "commits": int(commits or 0), "age_days": age,
|
|
77
|
+
"lang": (exts.most_common(1) or [("", 0)])[0][0],
|
|
78
|
+
"family": family_of(exts),
|
|
79
|
+
"hasInfra": any(f.endswith(".tf") or f.rsplit("/", 1)[-1].startswith("dockerfile")
|
|
80
|
+
or ".github/workflows/" in f for f in low),
|
|
81
|
+
"hasFrontend": any(f.endswith((".html", ".css", ".jsx", ".tsx", ".scss", ".vue")) for f in low),
|
|
82
|
+
"hasDocs": any(f.endswith(".md") for f in low)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
CAPITAL = "." # capital city id == the mother repo's own path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mother_nation(root: str) -> dict:
|
|
89
|
+
"""A mother repo as a one-state metropolis: a capital (its own glue) + each subrepo a city."""
|
|
90
|
+
subs = discover_repos(root)
|
|
91
|
+
name = Path(root).resolve().name
|
|
92
|
+
cities = [{"repo": CAPITAL, "name": "⊙ capital", "state": name, **summarize(root, set(subs))}]
|
|
93
|
+
cities += [{"repo": r, "state": name, **summarize(str(Path(root) / r))} for r in subs]
|
|
94
|
+
states = [{"id": name, "name": name, "color": PALETTE[0], "repos": [CAPITAL, *subs]}]
|
|
95
|
+
return {"root": name, "states": states, "cities": cities}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_nation(root: str, manifest_path: str | None) -> dict:
|
|
99
|
+
path = Path(manifest_path) if manifest_path else Path(root) / ".agentopolis-nation.json"
|
|
100
|
+
if not path.exists() and is_mother(root): # an explicit manifest wins; a bare mother is a metropolis
|
|
101
|
+
return mother_nation(root)
|
|
102
|
+
repos = discover_repos(root)
|
|
103
|
+
man = json.loads(path.read_text()) if path.exists() else {}
|
|
104
|
+
|
|
105
|
+
summaries = {r: summarize(str(Path(root) / r)) for r in repos}
|
|
106
|
+
|
|
107
|
+
state_of, states = {}, []
|
|
108
|
+
for i, st in enumerate(man.get("states", [])):
|
|
109
|
+
members = [r for r in st["repos"] if r in repos]
|
|
110
|
+
if not members:
|
|
111
|
+
continue
|
|
112
|
+
for r in members:
|
|
113
|
+
state_of[r] = st["id"]
|
|
114
|
+
states.append({"id": st["id"], "name": st.get("name", st["id"]),
|
|
115
|
+
"color": st.get("color", PALETTE[i % len(PALETTE)]), "repos": members})
|
|
116
|
+
|
|
117
|
+
by_family: dict[str, list[str]] = {}
|
|
118
|
+
for r in repos:
|
|
119
|
+
if r not in state_of:
|
|
120
|
+
by_family.setdefault(summaries[r]["family"], []).append(r)
|
|
121
|
+
for fam, members in sorted(by_family.items()):
|
|
122
|
+
sid = f"auto_{fam}"
|
|
123
|
+
for r in members:
|
|
124
|
+
state_of[r] = sid
|
|
125
|
+
states.append({"id": sid, "name": FAMILY_NAMES[fam],
|
|
126
|
+
"color": FAMILY_COLOR[fam], "repos": members})
|
|
127
|
+
|
|
128
|
+
cities = [{"repo": r, "state": state_of[r], **summaries[r]} for r in repos]
|
|
129
|
+
return {"root": Path(root).resolve().name, "states": states, "cities": cities}
|