agentopolis 0.6.0__py3-none-any.whl

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.
File without changes
agentopolis/cli.py ADDED
@@ -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()
agentopolis/hooks.py ADDED
@@ -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")
agentopolis/nation.py ADDED
@@ -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}
agentopolis/seed.py ADDED
@@ -0,0 +1,263 @@
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 json
8
+ import re
9
+ import subprocess
10
+ import time
11
+ from collections import Counter
12
+ from fnmatch import fnmatch
13
+ from pathlib import Path
14
+
15
+ CLASS = re.compile(r"^\s*(export |abstract |public |final )*(class|interface|struct|trait)\b")
16
+ IMPORT = re.compile(r"^\s*(import|from|require|use|#include)\b|=\s*require\(")
17
+ TODO = re.compile(r"TODO|FIXME|HACK")
18
+ FROM = re.compile(r"^\s*FROM\s+(\S+)", re.I)
19
+ FROM_AS = re.compile(r"\bAS\s+(\S+)", re.I)
20
+
21
+ # A dependency name (substring match) marks the district that declares it as a cloud client.
22
+ DEP_CLOUDS = [
23
+ ("AWS", ("boto3", "botocore", "aioboto3", "aws-sdk", "aws-cdk")),
24
+ ("GCP", ("google-cloud", "google-api-python-client", "firebase")),
25
+ ("Azure", ("azure-", "@azure/")),
26
+ ("Cloudflare", ("cloudflare", "wrangler")),
27
+ ("Supabase", ("supabase",)),
28
+ ("Vercel", ("@vercel",)),
29
+ ("Stripe", ("stripe",)),
30
+ ("OpenAI", ("openai",)),
31
+ ("Anthropic", ("anthropic",)),
32
+ ("Clerk", ("clerk",)),
33
+ ("Twilio", ("twilio",)),
34
+ ("Sentry", ("sentry",)),
35
+ ("Datadog", ("datadog", "ddtrace")),
36
+ ]
37
+ # A file (glob on full path or basename) marks the district that owns it as a cloud user.
38
+ FILE_CLOUDS = [
39
+ ("GitHub Actions", ("*.github/workflows/*",)),
40
+ ("Terraform", ("*.tf", "*.tf.json")),
41
+ ("Vercel", ("vercel.json",)),
42
+ ("Fly.io", ("fly.toml",)),
43
+ ("Render", ("render.yaml", "render.yml")),
44
+ ("Heroku", ("procfile",)),
45
+ ("Netlify", ("netlify.toml",)),
46
+ ("Cloudflare", ("wrangler.toml", "wrangler.json", "wrangler.jsonc")),
47
+ ]
48
+
49
+
50
+ def git(repo: str, *args: str) -> str:
51
+ return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True).stdout
52
+
53
+
54
+ def tracked(repo: str, exclude: set | None = None) -> list[str]:
55
+ files = git(repo, "ls-files").splitlines()
56
+ if exclude: # drop nested-repo dirs when seeding a mother's capital
57
+ files = [f for f in files if f.split("/", 1)[0] not in exclude]
58
+ return files
59
+
60
+
61
+ def component_of(path: str, components: list) -> str | None:
62
+ for c in components:
63
+ if any(fnmatch(path, g) for g in c["globs"]):
64
+ return c["id"]
65
+ return None
66
+
67
+
68
+ def scan(path: Path) -> tuple[int, int, int, int]:
69
+ loc = classes = imports = todos = 0
70
+ try:
71
+ for line in open(path, errors="ignore"):
72
+ loc += 1
73
+ if CLASS.match(line):
74
+ classes += 1
75
+ elif IMPORT.search(line):
76
+ imports += 1
77
+ if TODO.search(line):
78
+ todos += 1
79
+ except OSError:
80
+ pass
81
+ return loc, classes, imports, todos
82
+
83
+
84
+ def parse_deps(repo: str, files: list[str]) -> list[str]:
85
+ import tomllib
86
+ found = set()
87
+ for f in files:
88
+ name = f.rsplit("/", 1)[-1].lower()
89
+ if name not in ("package.json", "pyproject.toml") and \
90
+ not (name.startswith("requirements") and name.endswith(".txt")):
91
+ continue
92
+ try:
93
+ text = Path(repo, f).read_text(errors="ignore")
94
+ if name == "package.json":
95
+ data = json.loads(text)
96
+ found |= set(data.get("dependencies", {})) | set(data.get("devDependencies", {}))
97
+ elif name == "pyproject.toml":
98
+ reqs = tomllib.loads(text).get("project", {}).get("dependencies", [])
99
+ found |= {re.split(r"[<>=~!\[; ]", r, 1)[0] for r in reqs}
100
+ else:
101
+ found |= {re.split(r"[<>=~!\[; ]", li.strip(), 1)[0]
102
+ for li in text.splitlines() if li.strip() and li.lstrip()[0] not in "#-"}
103
+ except (ValueError, OSError): # fixture/vendored manifests may be malformed
104
+ continue
105
+ return sorted(found)
106
+
107
+
108
+ def detect_clouds(repo: str, comp: dict[str, str | None]) -> list[dict]:
109
+ """Fingerprint cloud providers from deps + config files; tether each to its district."""
110
+ real = lambda c: c and c not in ("civic", "commons")
111
+ files = Counter(c for c in comp.values() if c)
112
+ default = next((c for c, _ in files.most_common() if real(c)),
113
+ files.most_common(1)[0][0] if files else None)
114
+ votes: dict[str, Counter] = {}
115
+ for f, c in comp.items():
116
+ name = f.rsplit("/", 1)[-1].lower()
117
+ is_manifest = name in ("package.json", "pyproject.toml") or \
118
+ (name.startswith("requirements") and name.endswith(".txt"))
119
+ for dep in parse_deps(repo, [f]) if is_manifest else []:
120
+ for label, fps in DEP_CLOUDS:
121
+ if any(fp in dep for fp in fps):
122
+ votes.setdefault(label, Counter())[c or default] += 1
123
+ for label, pats in FILE_CLOUDS:
124
+ if any(fnmatch(f, p) or fnmatch(name, p) for p in pats):
125
+ votes.setdefault(label, Counter())[c or default] += 1
126
+ clouds = []
127
+ for label, counter in votes.items():
128
+ ranked = [cid for cid, _ in counter.most_common() if cid]
129
+ tether = next((c for c in ranked if real(c)), (ranked or [default])[0])
130
+ if tether:
131
+ clouds.append({"name": label, "tether": tether})
132
+ return clouds
133
+
134
+
135
+ def compose_services(repo: str, f: str) -> list[str]:
136
+ """Service keys under a compose file's top-level `services:` block (light indent parse, no yaml dep)."""
137
+ try:
138
+ lines = Path(repo, f).read_text(errors="ignore").splitlines()
139
+ except OSError:
140
+ return []
141
+ out: list[str] = []
142
+ in_services = indent = None
143
+ for line in lines:
144
+ body = line.strip()
145
+ if not body or body.startswith("#"):
146
+ continue
147
+ col = len(line) - len(line.lstrip())
148
+ if in_services is None:
149
+ in_services = body == "services:" or None
150
+ elif col == 0: # dedented out of the services block
151
+ break
152
+ else:
153
+ indent = col if indent is None else indent
154
+ if col == indent and body.endswith(":"):
155
+ out.append(body[:-1].strip())
156
+ return out
157
+
158
+
159
+ def dockerfile_images(repo: str, f: str) -> list[str]:
160
+ out: list[str] = []
161
+ stages: set[str] = set() # multi-stage names so `FROM builder` isn't a base image
162
+ try:
163
+ for line in open(Path(repo, f), errors="ignore"):
164
+ m = FROM.match(line)
165
+ if not m:
166
+ continue
167
+ img = m.group(1).rsplit("/", 1)[-1].split("@")[0]
168
+ if img.lower() != "scratch" and img not in stages and img not in out:
169
+ out.append(img)
170
+ stage = FROM_AS.search(line)
171
+ if stage:
172
+ stages.add(stage.group(1))
173
+ except OSError:
174
+ pass
175
+ return out
176
+
177
+
178
+ def docker_manifest(repo: str, files) -> list[dict]:
179
+ """Each compose/Dockerfile as a harbor ship; its items are the services / base images it runs."""
180
+ arts = []
181
+ for f in files:
182
+ name = f.rsplit("/", 1)[-1].lower()
183
+ if "compose.y" in name:
184
+ arts.append({"path": f, "kind": "compose", "items": compose_services(repo, f)})
185
+ elif "dockerfile" in name:
186
+ arts.append({"path": f, "kind": "image", "items": dockerfile_images(repo, f)})
187
+ return arts
188
+
189
+
190
+ def dead_files(repo: str, alive: set[str]) -> list[dict]:
191
+ died: dict[str, int] = {} # path -> deletion commit time (newest deletion wins)
192
+ ts = 0
193
+ for line in git(repo, "log", "-M", "--diff-filter=D", "--name-only", "--pretty=%ct").splitlines():
194
+ if line.isdigit():
195
+ ts = int(line)
196
+ elif line and line not in alive and line not in died:
197
+ died[line] = ts
198
+ if len(died) == 24: # cemetery plot capacity
199
+ break
200
+ born: dict[str, int] = {} # earliest add: newest-first log, last write is oldest
201
+ ts = 0
202
+ for line in git(repo, "log", "--diff-filter=A", "--name-only", "--pretty=%ct").splitlines():
203
+ if line.isdigit():
204
+ ts = int(line)
205
+ elif line in died:
206
+ born[line] = ts
207
+ return [{"path": p, "born": born.get(p), "died": d} for p, d in died.items()]
208
+
209
+
210
+ def seed(repo: str, zone: dict, exclude: set | None = None) -> dict:
211
+ comp = {f: component_of(f, zone["components"]) for f in tracked(repo, exclude)}
212
+ files = [f for f, c in comp.items() if c]
213
+ group = {c["id"]: c.get("group") for c in zone["components"]}
214
+
215
+ commits = dict.fromkeys(files, 0)
216
+ last = dict.fromkeys(files, 0)
217
+ cocomp = {f: set() for f in files}
218
+ timestamp = 0
219
+ touched: list[str] = []
220
+ for line in git(repo, "log", "--name-only", "--pretty=%ct").splitlines() + [""]:
221
+ if line.isdigit() or not line: # commit boundary: flush previous
222
+ comps = {comp[f] for f in touched}
223
+ for f in touched:
224
+ commits[f] += 1
225
+ last[f] = max(last[f], timestamp)
226
+ if len(touched) <= 15: # mega-commits aren't coupling signal
227
+ cocomp[f] |= comps - {comp[f]}
228
+ touched = []
229
+ if line.isdigit():
230
+ timestamp = int(line)
231
+ elif line in commits:
232
+ touched.append(line)
233
+
234
+ buildings: dict[str, dict] = {}
235
+ exts: dict[str, Counter] = {}
236
+ now = time.time()
237
+ for f in sorted(files):
238
+ depth = group[comp[f]]
239
+ key = "/".join(f.split("/")[:depth]) if depth else f
240
+ b = buildings.setdefault(key, {"path": key, "component": comp[f], "loc": 0,
241
+ "commits": 0, "centrality": 0, "age_days": 9999, "files": 0,
242
+ "classes": 0, "imports": 0, "todos": 0})
243
+ loc, classes, imports, todos = scan(Path(repo, f))
244
+ b["loc"] += loc
245
+ b["classes"] += classes
246
+ b["imports"] += imports
247
+ b["todos"] += todos
248
+ b["commits"] += commits[f]
249
+ b["centrality"] = max(b["centrality"], len(cocomp[f]))
250
+ if last[f]:
251
+ b["age_days"] = min(b["age_days"], round((now - last[f]) / 86400))
252
+ b["files"] += 1
253
+ exts.setdefault(key, Counter())[Path(f).suffix.lstrip(".").lower()] += 1
254
+ for key, b in buildings.items():
255
+ b["ext"] = exts[key].most_common(1)[0][0]
256
+
257
+ docker = docker_manifest(repo, comp)
258
+ named = {c["name"].lower() for c in zone.get("clouds", [])} # manual clouds win
259
+ zone["clouds"] = zone.get("clouds", []) + \
260
+ [c for c in detect_clouds(repo, comp) if c["name"].lower() not in named]
261
+ return {"zone": zone, "buildings": list(buildings.values()),
262
+ "deps": parse_deps(repo, list(comp)), "docker": docker,
263
+ "dead": dead_files(repo, set(comp))}