agents-city 0.3.0-beta.21 → 0.3.0

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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +310 -70
  3. package/README.md +297 -69
  4. package/bin/agents-city.js +3 -0
  5. package/bin/doctor +3 -0
  6. package/bin/hall.html +164 -24
  7. package/bin/navegador.mjs +415 -0
  8. package/bin/serve.py +383 -127
  9. package/bin/shortcut +3 -0
  10. package/bin/test +5 -2
  11. package/bin/test-actualiza.py +130 -0
  12. package/bin/test-atajos.py +301 -0
  13. package/bin/test-busca.py +216 -0
  14. package/bin/test-cage.py +170 -2
  15. package/bin/test-card.py +2 -2
  16. package/bin/test-cities.py +45 -0
  17. package/bin/test-contracts.py +12 -5
  18. package/bin/test-doctor.py +33 -0
  19. package/bin/test-navegador.py +164 -0
  20. package/bin/test-seat.py +245 -25
  21. package/bin/test-serve.py +214 -9
  22. package/bin/test-workspace.py +63 -0
  23. package/bin/testlib.py +23 -0
  24. package/bin/update +3 -0
  25. package/city/web/dist/city.js +47 -47
  26. package/city/web/dist/index.html +1 -1
  27. package/city/web/dist-hall/hall.js +2193 -174
  28. package/city/web/src/bienvenida.ts +686 -0
  29. package/city/web/src/es.ts +180 -0
  30. package/city/web/src/hall.ts +520 -168
  31. package/city/web/src/idioma.ts +86 -0
  32. package/city/web/src/main.ts +27 -0
  33. package/city/web/src/motores.ts +54 -0
  34. package/docs/agents-first.md +8 -1
  35. package/docs/security.md +46 -12
  36. package/docs/testing.md +1 -1
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/channel/bus.js +1 -1
  40. package/plugin/channel/bus.ts +1 -1
  41. package/plugin/channel/runtime/codex.ts +1 -1
  42. package/plugin/channel/runtime-gateway.js +1 -1
  43. package/plugin/scripts/actualiza.py +198 -0
  44. package/plugin/scripts/atajos.py +506 -0
  45. package/plugin/scripts/busca.py +436 -0
  46. package/plugin/scripts/cage.py +266 -26
  47. package/plugin/scripts/capabilities.py +17 -10
  48. package/plugin/scripts/card.py +10 -0
  49. package/plugin/scripts/cities.py +34 -0
  50. package/plugin/scripts/city-session.sh +33 -7
  51. package/plugin/scripts/doctor.py +122 -0
  52. package/plugin/scripts/find-repos.sh +12 -105
  53. package/plugin/scripts/read-card.py +6 -2
  54. package/plugin/scripts/report.py +5 -6
  55. package/plugin/scripts/reset.py +50 -14
  56. package/plugin/scripts/seat.py +445 -103
  57. package/plugin/scripts/workspace.py +197 -0
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env python3
2
+ """Everything on this machine an agent could be given to work on.
3
+
4
+ There used to be a bash script here that indexed git repositories, and three
5
+ things were wrong with it. It could not run on Windows, where there is no bash
6
+ and Python already is a hard dependency. It shelled out to `git` once per
7
+ repository, so a home directory with three hundred of them paid three hundred
8
+ processes. And it only knew about git, while the product's own claim is that a
9
+ house can be a folder of documents with no git anywhere — so the one kind of
10
+ agent that needs the most help finding its material got none.
11
+
12
+ So: one scanner, in the language the rest of the tooling is already written in,
13
+ that returns three kinds of place.
14
+
15
+ repo a clone. Named by its `origin` remote, not by its folder, so it
16
+ is findable by the name you would say out loud.
17
+ worktree a linked worktree — the folder an isolated agent actually works
18
+ in. Named `repo@branch`, a distinct thing to pick.
19
+ docs a folder with documents in it and no git. Named by its folder.
20
+
21
+ The remote and the branch are read out of `.git/config` and `HEAD` directly.
22
+ That is not an optimisation for its own sake: it is what makes a full-disk scan
23
+ finish while somebody is looking at the screen.
24
+
25
+ CITY_SEARCH_IN colon-separated roots to search instead of the defaults
26
+ (semicolon-separated is also accepted, for Windows)
27
+ CITY_SEARCH_DEPTH how deep under each root (default 4)
28
+ AGENTS_CITY_ORG only index repos of this organisation. Unset = all.
29
+ """
30
+
31
+ import configparser
32
+ import json
33
+ import os
34
+ import sys
35
+ import time
36
+
37
+ #: Where the work usually lives, most specific first — the ones later in the
38
+ #: list are broad, and a repo found under two roots keeps its first name.
39
+ CANDIDATAS = (
40
+ "codigo",
41
+ "code",
42
+ "dev",
43
+ "src",
44
+ "projects",
45
+ "proyectos",
46
+ "work",
47
+ "trabajo",
48
+ "repos",
49
+ "git",
50
+ "Documents",
51
+ "Documentos",
52
+ "Desktop",
53
+ "Escritorio",
54
+ "Developer",
55
+ )
56
+
57
+ #: Never walked. Package caches and toolchains hold thousands of directories and
58
+ #: not one of them is somebody's work.
59
+ SALTAR = {
60
+ "node_modules",
61
+ "vendor",
62
+ ".Trash",
63
+ ".cache",
64
+ ".cargo",
65
+ ".asdf",
66
+ ".rbenv",
67
+ ".pyenv",
68
+ ".nvm",
69
+ "miniconda3",
70
+ "anaconda3",
71
+ "Library",
72
+ "AppData",
73
+ "site-packages",
74
+ "venv",
75
+ ".venv",
76
+ "__pycache__",
77
+ "dist",
78
+ "build",
79
+ ".next",
80
+ ".terraform",
81
+ "Applications",
82
+ "third_party",
83
+ "Pods",
84
+ "target",
85
+ }
86
+
87
+ #: What counts as a document. Deliberately short: the question is "is there
88
+ #: writing in here", not "can this folder be indexed".
89
+ DOCUMENTOS = (".md", ".markdown", ".txt", ".rst", ".org", ".pdf", ".docx", ".doc", ".rtf")
90
+
91
+ #: A folder needs this many documents before it is worth offering. Two loose
92
+ #: readmes are not a knowledge base.
93
+ MINIMO_DOCS = 3
94
+
95
+
96
+ def raices():
97
+ """The directories to search, in order, with duplicates and nested ones
98
+ dropped — walking $HOME after ~/codigo would index everything twice."""
99
+ puesto = os.environ.get("CITY_SEARCH_IN", "")
100
+ if puesto:
101
+ # A Windows path holds a colon (C:\...), so accept the separator that
102
+ # platform actually uses as well as the POSIX one.
103
+ crudas = puesto.split(";") if ";" in puesto else puesto.split(os.pathsep)
104
+ else:
105
+ casa = os.path.expanduser("~")
106
+ crudas = [os.path.join(casa, n) for n in CANDIDATAS] + [casa]
107
+ fuera = []
108
+ for r in crudas:
109
+ r = os.path.abspath(os.path.expanduser(r.strip()))
110
+ if not r or not os.path.isdir(r):
111
+ continue
112
+ if any(r == v or r.startswith(v + os.sep) for v in fuera):
113
+ continue
114
+ fuera.append(r)
115
+ return fuera
116
+
117
+
118
+ def profundidad():
119
+ try:
120
+ return max(1, int(os.environ.get("CITY_SEARCH_DEPTH", "4")))
121
+ except ValueError:
122
+ return 4
123
+
124
+
125
+ def _lee_config(ruta):
126
+ """The `origin` URL out of a git config file, without running git."""
127
+ cp = configparser.ConfigParser(strict=False)
128
+ try:
129
+ with open(ruta, "r", encoding="utf-8", errors="replace") as f:
130
+ cp.read_file(f)
131
+ except (OSError, configparser.Error):
132
+ return ""
133
+ for seccion in cp.sections():
134
+ # git writes it as: [remote "origin"]
135
+ if seccion.replace('"', "").replace("'", "").strip() == "remote origin":
136
+ return (cp[seccion].get("url") or "").strip()
137
+ return ""
138
+
139
+
140
+ def _tocado(ruta):
141
+ """When this place last moved, as a unix time, without running anything.
142
+
143
+ A repository's HEAD file is rewritten by every commit, checkout and pull, so
144
+ its mtime is the honest answer to "have I been in here lately" — and asking
145
+ the filesystem costs nothing, while `git log` in three hundred repositories
146
+ costs three hundred processes and the person is waiting.
147
+ """
148
+ for candidato in ("HEAD", "index", ""):
149
+ donde = os.path.join(ruta, candidato) if candidato else ruta
150
+ try:
151
+ # Whole seconds, because that is what the cache file can hold: a
152
+ # scan and a read of its own cache must return the same index, or
153
+ # every caller has two slightly different answers to choose from.
154
+ return float(int(os.path.getmtime(donde)))
155
+ except OSError:
156
+ continue
157
+ return 0.0
158
+
159
+
160
+ def _rama(gitdir):
161
+ try:
162
+ with open(os.path.join(gitdir, "HEAD"), "r", encoding="utf-8", errors="replace") as f:
163
+ linea = f.read().strip()
164
+ except OSError:
165
+ return ""
166
+ return linea.split("refs/heads/", 1)[1].strip() if "refs/heads/" in linea else ""
167
+
168
+
169
+ def _gitdir_de_worktree(fichero):
170
+ """A linked worktree's `.git` is a file that points at the real gitdir."""
171
+ try:
172
+ with open(fichero, "r", encoding="utf-8", errors="replace") as f:
173
+ texto = f.read().strip()
174
+ except OSError:
175
+ return ""
176
+ if not texto.startswith("gitdir:"):
177
+ return ""
178
+ destino = texto.split(":", 1)[1].strip()
179
+ if not os.path.isabs(destino):
180
+ destino = os.path.join(os.path.dirname(fichero), destino)
181
+ return os.path.normpath(destino)
182
+
183
+
184
+ def _comun(gitdir):
185
+ """The main repository's gitdir behind a worktree's private one."""
186
+ ruta = os.path.join(gitdir, "commondir")
187
+ try:
188
+ with open(ruta, "r", encoding="utf-8", errors="replace") as f:
189
+ rel = f.read().strip()
190
+ except OSError:
191
+ return gitdir
192
+ return os.path.normpath(rel if os.path.isabs(rel) else os.path.join(gitdir, rel))
193
+
194
+
195
+ def _nombre_de_url(url):
196
+ nombre = url.rstrip("/").rsplit("/", 1)[-1]
197
+ if ":" in nombre and "/" not in url:
198
+ nombre = nombre.rsplit(":", 1)[-1]
199
+ return nombre[:-4] if nombre.endswith(".git") else nombre
200
+
201
+
202
+ def _de_la_org(url, org):
203
+ if not org:
204
+ return True
205
+ aguja = "/" + org.lower() + "/"
206
+ u = url.lower().replace(":", "/")
207
+ return aguja in u or u.endswith("/" + org.lower())
208
+
209
+
210
+ def _mira_git(carpeta, marca, org):
211
+ """Classify one directory that holds a `.git`. Returns a place, or None."""
212
+ esclon = os.path.isdir(marca)
213
+ gitdir = marca if esclon else _gitdir_de_worktree(marca)
214
+ if not gitdir:
215
+ return None
216
+ config = os.path.join(gitdir if esclon else _comun(gitdir), "config")
217
+ url = _lee_config(config)
218
+ if not url or not _de_la_org(url, org):
219
+ return None
220
+ nombre = _nombre_de_url(url)
221
+ if not nombre:
222
+ return None
223
+ cuando = _tocado(gitdir)
224
+ if esclon:
225
+ return {"clase": "repo", "nombre": nombre, "ruta": carpeta, "cuando": cuando}
226
+ rama = _rama(gitdir) or os.path.basename(carpeta)
227
+ return {"clase": "worktree", "nombre": f"{nombre}@{rama}", "ruta": carpeta, "cuando": cuando}
228
+
229
+
230
+ def _cuenta_documentos(entradas):
231
+ n = 0
232
+ for e in entradas:
233
+ if e.is_file() and e.name.lower().endswith(DOCUMENTOS) and not e.name.startswith("."):
234
+ n += 1
235
+ if n >= MINIMO_DOCS:
236
+ break
237
+ return n
238
+
239
+
240
+ def _clasifica(aqui, entradas, raiz, en_repo, docs_arriba, org):
241
+ """What this one directory is, if it is anything.
242
+
243
+ Returns `(place or None, inside a repository, a documents folder claimed an
244
+ ancestor)`. Kept out of the walk because the walk's job is to visit
245
+ directories and this one's is to recognise them, and a single function doing
246
+ both was the shape nobody could read.
247
+ """
248
+ if ".git" in {e.name for e in entradas}:
249
+ return _mira_git(aqui, os.path.join(aqui, ".git"), org), True, docs_arriba
250
+ # Documents only outside a repository, and only the shallowest folder of a
251
+ # chain: a repo's own `docs/` is already reachable through the repo, and
252
+ # offering a vault plus every folder inside it makes the picker argue with
253
+ # itself.
254
+ if aqui == raiz or en_repo or docs_arriba:
255
+ return None, en_repo, docs_arriba
256
+ if _cuenta_documentos(entradas) < MINIMO_DOCS:
257
+ return None, en_repo, docs_arriba
258
+ sitio = {
259
+ "clase": "docs",
260
+ "nombre": os.path.basename(aqui) or aqui,
261
+ "ruta": aqui,
262
+ "cuando": _tocado(aqui),
263
+ }
264
+ return sitio, en_repo, True
265
+
266
+
267
+ def escanea():
268
+ """Walk the roots once and return every place found, ordered.
269
+
270
+ A single pass answers all three questions, because they are all answers to
271
+ "what is in this directory" and walking a home directory three times to ask
272
+ it three ways would be the slow, obvious mistake.
273
+ """
274
+ hondo = profundidad()
275
+ org = os.environ.get("AGENTS_CITY_ORG", "").strip()
276
+ fuera = []
277
+ vistos = set()
278
+ for raiz in raices():
279
+ base = raiz.rstrip(os.sep).count(os.sep)
280
+ # (path, inside a repository, a documents folder claimed an ancestor).
281
+ # Both flags travel down the walk rather than being asked again per
282
+ # directory.
283
+ pila = [(raiz, False, False)]
284
+ while pila:
285
+ aqui, en_repo, docs_arriba = pila.pop()
286
+ try:
287
+ with os.scandir(aqui) as it:
288
+ entradas = list(it)
289
+ except OSError:
290
+ continue
291
+ sitio, en_repo, docs_arriba = _clasifica(
292
+ aqui, entradas, raiz, en_repo, docs_arriba, org
293
+ )
294
+ if sitio and sitio["ruta"] not in vistos:
295
+ vistos.add(sitio["ruta"])
296
+ fuera.append(sitio)
297
+ if aqui.rstrip(os.sep).count(os.sep) - base >= hondo:
298
+ continue
299
+ for e in entradas:
300
+ if e.name in SALTAR or e.name.startswith("."):
301
+ continue
302
+ try:
303
+ if e.is_dir(follow_symlinks=False):
304
+ pila.append((e.path, en_repo, docs_arriba))
305
+ except OSError:
306
+ continue
307
+ # Most recently touched first, whatever kind it is. The folder somebody was
308
+ # working in an hour ago is the one they came here to pick, and it should not
309
+ # be a hundred alphabetical rows down.
310
+ fuera.sort(key=lambda s: (-s.get("cuando", 0.0), s["nombre"].lower()))
311
+ return fuera
312
+
313
+
314
+ # ── the cache ────────────────────────────────────────────────────────────────
315
+ def fichero_cache():
316
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
317
+ return os.path.join(base, "agents-city", "lugares.tsv")
318
+
319
+
320
+ def _escribe(sitios):
321
+ ruta = fichero_cache()
322
+ os.makedirs(os.path.dirname(ruta), exist_ok=True)
323
+ tmp = f"{ruta}.tmp.{os.getpid()}"
324
+ with open(tmp, "w", encoding="utf-8") as f:
325
+ for s in sitios:
326
+ f.write(f"{s['clase']}\t{s['nombre']}\t{s['ruta']}\t{int(s.get('cuando', 0))}\n")
327
+ os.replace(tmp, ruta)
328
+
329
+
330
+ def _lee():
331
+ try:
332
+ with open(fichero_cache(), "r", encoding="utf-8") as f:
333
+ lineas = f.read().splitlines()
334
+ except OSError:
335
+ return None
336
+ sitios = []
337
+ for linea in lineas:
338
+ partes = linea.split("\t")
339
+ if len(partes) < 3:
340
+ continue
341
+ cuando = 0.0
342
+ if len(partes) > 3:
343
+ try:
344
+ cuando = float(partes[3])
345
+ except ValueError:
346
+ cuando = 0.0
347
+ sitios.append(
348
+ {"clase": partes[0], "nombre": partes[1], "ruta": partes[2], "cuando": cuando}
349
+ )
350
+ return sitios
351
+
352
+
353
+ def caduco(dias=1):
354
+ try:
355
+ return time.time() - os.path.getmtime(fichero_cache()) > dias * 86400
356
+ except OSError:
357
+ return True
358
+
359
+
360
+ def lugares(refrescar=False, dias=1):
361
+ """Every place, from the cache while it is fresh. The first crawl of a full
362
+ home directory is not fast and nobody should pay for it twice."""
363
+ if not refrescar and not caduco(dias):
364
+ guardado = _lee()
365
+ if guardado is not None:
366
+ return guardado
367
+ sitios = escanea()
368
+ try:
369
+ _escribe(sitios)
370
+ except OSError:
371
+ pass # an unwritable cache is slow, not broken
372
+ return sitios
373
+
374
+
375
+ def repos(refrescar=False):
376
+ """Just the git ones, as `(name, path)` — what the launcher asks for."""
377
+ return [(s["nombre"], s["ruta"]) for s in lugares(refrescar) if s["clase"] != "docs"]
378
+
379
+
380
+ def ruta_de(nombre):
381
+ """One place's path by name, rebuilding the index once if it is not there —
382
+ the repo somebody cloned a minute ago is exactly the one they came to use."""
383
+ for intento in (False, True):
384
+ for s in lugares(refrescar=intento):
385
+ if s["nombre"] == nombre:
386
+ return s["ruta"]
387
+ return ""
388
+
389
+
390
+ def _lee_argv(argv):
391
+ """The flags, separated from the work they ask for."""
392
+ refrescar = False
393
+ formato = "tsv"
394
+ resto = []
395
+ for a in argv:
396
+ if a in ("--refresh", "--refrescar"):
397
+ refrescar = True
398
+ elif a == "--json":
399
+ formato = "json"
400
+ elif a == "--repos":
401
+ formato = "repos"
402
+ else:
403
+ resto.append(a)
404
+ return refrescar, formato, resto
405
+
406
+
407
+ def _imprime(sitios, formato):
408
+ if formato == "json":
409
+ json.dump(sitios, sys.stdout)
410
+ return
411
+ for s in sitios:
412
+ if formato == "repos":
413
+ if s["clase"] != "docs":
414
+ print(f"{s['nombre']}\t{s['ruta']}")
415
+ else:
416
+ print(f"{s['clase']}\t{s['nombre']}\t{s['ruta']}")
417
+
418
+
419
+ def main(argv):
420
+ refrescar, formato, resto = _lee_argv(argv)
421
+ if resto:
422
+ ruta = ruta_de(resto[0])
423
+ if ruta:
424
+ print(ruta)
425
+ return 0
426
+ if refrescar:
427
+ print("Looking through your disk…", file=sys.stderr)
428
+ sitios = lugares(refrescar=refrescar)
429
+ _imprime(sitios, formato)
430
+ if refrescar:
431
+ print(f"{len(sitios)} places indexed in {fichero_cache()}", file=sys.stderr)
432
+ return 0
433
+
434
+
435
+ if __name__ == "__main__":
436
+ sys.exit(main(sys.argv[1:]))