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
@@ -19,6 +19,9 @@ import argparse
19
19
  import json
20
20
  import os
21
21
  import sys
22
+
23
+ GUIONES = os.path.dirname(os.path.abspath(__file__))
24
+ sys.path.insert(0, GUIONES)
22
25
  from dataclasses import dataclass
23
26
  from typing import Callable
24
27
 
@@ -145,7 +148,126 @@ def cura_fichero(ruta, marca, dry_run=False):
145
148
  return reporte
146
149
 
147
150
 
151
+ # ── the machine, not just the config ─────────────────────────────────────────
152
+ #
153
+ # `doctor` used to answer one narrow question — is this config file an old
154
+ # shape? — behind no command at all. What a person means by "doctor" is: tell
155
+ # me whether this thing can work here, and if not, which part. So it answers
156
+ # that too, and every line is derived: what is installed, what the kernel will
157
+ # grant, which city is selected. Nothing here writes.
158
+
159
+
160
+ def _version_de(programa, bandera='--version'):
161
+ import shutil as _sh
162
+ import subprocess as _sp
163
+
164
+ ruta = _sh.which(programa)
165
+ if not ruta:
166
+ return ''
167
+ try:
168
+ r = _sp.run([ruta, bandera], capture_output=True, text=True, timeout=10)
169
+ except (OSError, _sp.SubprocessError):
170
+ return 'installed'
171
+ primera = (r.stdout or r.stderr).strip().splitlines()
172
+ return primera[0].strip() if primera else 'installed'
173
+
174
+
175
+ def revisa_entorno():
176
+ """Every check as `(area, ok, detail)`. `ok` is None for "not applicable".
177
+
178
+ A list rather than printed lines, so the Hall could show the same answers
179
+ the terminal does without either of them re-deciding what healthy means.
180
+ """
181
+ import shutil as _sh
182
+ import sys as _sys
183
+
184
+ import cage as _cage
185
+ import cities as _cities
186
+
187
+ fuera = []
188
+ fuera.append(('python', True, _sys.version.split()[0]))
189
+ # Every one of these spells its version flag differently, and tmux answers
190
+ # `--version` with an error that reads like a working version string.
191
+ for programa, bandera, obligatorio in (('tmux', '-V', True), ('bash', '--version', True),
192
+ ('git', '--version', True),
193
+ ('node', '--version', True),
194
+ ('gh', '--version', False)):
195
+ v = _version_de(programa, bandera)
196
+ fuera.append((programa, bool(v) if obligatorio else (True if v else None),
197
+ v or ('missing' if obligatorio else 'not installed (optional)')))
198
+
199
+ motores = [m for m in ('claude', 'codex', 'opencode', 'kimi') if _sh.which(m)]
200
+ fuera.append(('runtimes', bool(motores),
201
+ ', '.join(motores) if motores else 'none — a city needs at least one CLI'))
202
+
203
+ if _sys.platform == 'darwin':
204
+ detalle = ('seatbelt' if _sh.which('sandbox-exec') else 'sandbox-exec missing')
205
+ elif _sys.platform.startswith('linux'):
206
+ if not _sh.which('bwrap'):
207
+ detalle = 'bubblewrap not installed — agents run uncaged (apt install bubblewrap)'
208
+ elif not _cage.bwrap_sirve():
209
+ detalle = ('bubblewrap present but the kernel refuses unprivileged '
210
+ 'namespaces — agents run uncaged')
211
+ else:
212
+ detalle = 'bubblewrap'
213
+ else:
214
+ detalle = f'no cage on {_sys.platform}'
215
+ fuera.append(('cage', _cage.disponible(), detalle))
216
+
217
+ try:
218
+ usuario = _cities.usuario_actual()
219
+ datos = _cities.actual(usuario, crear=False)
220
+ if datos:
221
+ fuera.append(('city', True, _cities.direccion(usuario, datos)))
222
+ ficha = os.path.join(datos, f'{usuario}.md')
223
+ fuera.append(('card', os.path.isfile(ficha),
224
+ ficha if os.path.isfile(ficha) else f'no card at {ficha}'))
225
+ else:
226
+ fuera.append(('city', None, 'none selected yet — agents-city seat creates one'))
227
+ except (OSError, ValueError) as e:
228
+ fuera.append(('city', False, str(e)))
229
+
230
+ hall = os.path.join(os.path.dirname(os.path.dirname(GUIONES)),
231
+ 'city', 'web', 'dist-hall', 'hall.js')
232
+ fuera.append(('hall bundle', os.path.isfile(hall),
233
+ 'built' if os.path.isfile(hall) else 'not built — ./bin/hall builds it'))
234
+ return fuera
235
+
236
+
237
+ def informe_entorno():
238
+ """Print the environment report. Returns 0 when nothing is broken."""
239
+ import actualiza
240
+
241
+ print('\n Agents City doctor\n')
242
+ roto = 0
243
+ for area, bien, detalle in revisa_entorno():
244
+ marca = '·' if bien is None else ('ok' if bien else 'XX')
245
+ if bien is False:
246
+ roto += 1
247
+ print(f' {marca:>2} {area:<12} {detalle}')
248
+ instalada, ultima, hay = actualiza.comprueba()
249
+ if ultima:
250
+ print(f' {"!!" if hay else "ok":>2} {"version":<12} {instalada}'
251
+ + (f' — {ultima} is out: agents-city update' if hay else ' (current)'))
252
+ else:
253
+ print(f' {"·":>2} {"version":<12} {instalada} (registry not checked)')
254
+ print()
255
+ if roto:
256
+ print(f' {roto} thing(s) need attention.\n')
257
+ return 1 if roto else 0
258
+
259
+
148
260
  def main():
261
+ # `doctor` with no file is the environment report — what a person means by
262
+ # the word. The config migration keeps its own path, unchanged.
263
+ if len(sys.argv) == 1 or sys.argv[1] in ('-h', '--help', 'help'):
264
+ if len(sys.argv) > 1:
265
+ print(' usage: agents-city doctor [config.json [--fix]]\n\n'
266
+ ' With no arguments: check this machine — tools, runtimes, the\n'
267
+ ' cage, the selected city, and whether a newer version is out.\n'
268
+ ' With a config file: detect, explain and migrate an old shape.')
269
+ return 0
270
+ return informe_entorno()
149
271
  p = argparse.ArgumentParser(description='Detect, explain and migrate an old config shape.')
150
272
  p.add_argument('fichero', help='the config JSON file to check')
151
273
  p.add_argument('--fix', action='store_true', help='rewrite (default is dry-run report)')
@@ -1,111 +1,18 @@
1
1
  #!/usr/bin/env bash
2
- # Index the git repos already cloned on this machine, wherever they are, and
3
- # cache it. One line each: <name-on-the-remote><TAB><local-path>
2
+ # The git half of the disk index, for the shell callers.
4
3
  #
5
- # find-repos.sh uses the cache while it is less than a day old
6
- # find-repos.sh --refresh rebuilds it
7
- # find-repos.sh <repo> prints that repo's path, or nothing
4
+ # The scanner itself is `busca.py`: one implementation, in the language the rest
5
+ # of the tooling already needs, so it also runs where there is no bash. This is
6
+ # the shim that keeps `city-session.sh` and anything else in a pipeline talking
7
+ # to it the way they always did — one line each, <name><TAB><local-path>.
8
8
  #
9
- # The name comes from the `origin` remote and not from the folder name, so it
10
- # finds the repo even when the folder is called something else — which is the
11
- # normal case for anybody with more than ten of them.
12
- #
13
- # AGENTS_CITY_ORG only index repos of this organisation. Unset = all of
14
- # them; the filter is for machines that also hold work
15
- # from somewhere else.
16
- # CITY_SEARCH_IN colon-separated roots to search instead of the defaults
17
- # CITY_SEARCH_DEPTH how deep to go under each root (default 4)
18
-
9
+ # find-repos.sh the cache, rebuilt if it is more than a day old
10
+ # find-repos.sh --refresh rebuild it now
11
+ # find-repos.sh <repo> that repo's path, or nothing
19
12
  set -uo pipefail
20
-
21
- ORG="${AGENTS_CITY_ORG:-}"
22
- CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/agents-city"
23
- CACHE="$CACHE_DIR/repos.tsv"
24
- RAICES_DEF="$HOME/codigo:$HOME/code:$HOME/dev:$HOME/src:$HOME/projects:$HOME/proyectos:$HOME/work:$HOME/trabajo:$HOME/repos:$HOME/git:$HOME/Documents:$HOME/Desktop:$HOME/Developer:$HOME"
25
- RAICES="${CITY_SEARCH_IN:-$RAICES_DEF}"
26
- PROFUNDIDAD="${CITY_SEARCH_DEPTH:-4}"
27
-
28
- construir() {
29
- mkdir -p "$CACHE_DIR"
30
- local tmp="$CACHE.tmp.$$"
31
- : > "$tmp"
32
- local vistos=""
33
- local IFS=':'
34
- for raiz in $RAICES; do
35
- unset IFS
36
- [ -d "$raiz" ] || continue
37
- # Do not walk a root another one already covered.
38
- case ":$vistos:" in *":$raiz:"*) continue ;; esac
39
- vistos="$vistos:$raiz"
40
-
41
- # `.git` is a directory in a normal clone and a FILE in a linked worktree —
42
- # and a worktree is exactly the folder an isolated agent works in, so missing
43
- # them missed the people using agents the most. Both are indexed; a worktree
44
- # is named `repo@branch` so the two are distinct things to pick.
45
- find "$raiz" -maxdepth "$PROFUNDIDAD" \( -type d -o -type f \) -name .git \
46
- -not -path "*/node_modules/*" \
47
- -not -path "*/Library/*" \
48
- -not -path "*/.Trash/*" \
49
- -not -path "*/vendor/*" \
50
- -not -path "*/.cache/*" \
51
- -not -path "*/plugins/cache/*" \
52
- -not -path "*/.cargo/*" -not -path "*/.asdf/*" -not -path "*/miniconda3/*" \
53
- -not -path "*/.rbenv/*" -not -path "*/.pyenv/*" -not -path "*/.nvm/*" \
54
- 2>/dev/null \
55
- | while read -r g; do
56
- repo="$(dirname "$g")"
57
- url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || continue
58
- [ -z "$url" ] && continue
59
- # git@host:org/name.git | https://host/org/name(.git)
60
- # The version excluded by an organisation whose default was the literal
61
- # "<your-org>", so the index came back empty for everybody who never set
62
- # the variable. Unset now means: index what is there.
63
- if [ -n "$ORG" ]; then
64
- case "$url" in
65
- *[:/]"$ORG"/*) ;;
66
- *) continue ;;
67
- esac
68
- fi
69
- nombre="$(basename "$url" .git)"
70
- if [ -f "$g" ]; then
71
- rama="$(git -C "$repo" branch --show-current 2>/dev/null)"
72
- [ -z "$rama" ] && rama="$(basename "$repo")"
73
- nombre="$nombre@$rama"
74
- fi
75
- [ -n "$nombre" ] && printf '%s\t%s\n' "$nombre" "$repo"
76
- done >> "$tmp"
77
- IFS=':'
78
- done
79
- unset IFS
80
- # First one wins on a duplicate: the roots run from most specific to least.
81
- sort -u -t$'\t' -k1,1 "$tmp" > "$CACHE"
82
- rm -f "$tmp"
83
- }
84
-
85
- caduca() {
86
- [ ! -f "$CACHE" ] && return 0
87
- [ -n "$(find "$CACHE" -mtime +1 2>/dev/null)" ] && return 0 # older than a day
88
- return 1
89
- }
90
-
13
+ GUION="$(dirname "$0")/busca.py"
91
14
  case "${1:-}" in
92
- --refresh|--refrescar)
93
- echo "Looking for cloned repos…" >&2
94
- construir
95
- echo "$(wc -l < "$CACHE" | tr -d ' ') repos indexed in $CACHE" >&2
96
- ;;
97
- "")
98
- caduca && construir
99
- cat "$CACHE"
100
- ;;
101
- *)
102
- caduca && construir
103
- ruta="$(awk -F'\t' -v n="$1" '$1==n {print $2; exit}' "$CACHE")"
104
- # Not there? It may have been cloned a minute ago: rebuild once and retry.
105
- if [ -z "$ruta" ]; then
106
- construir
107
- ruta="$(awk -F'\t' -v n="$1" '$1==n {print $2; exit}' "$CACHE")"
108
- fi
109
- [ -n "$ruta" ] && printf '%s\n' "$ruta"
110
- ;;
15
+ --refresh|--refrescar) exec python3 "$GUION" --refresh --repos ;;
16
+ "") exec python3 "$GUION" --repos ;;
17
+ *) exec python3 "$GUION" "$1" ;;
111
18
  esac
@@ -43,8 +43,12 @@ def main():
43
43
  except OSError as e:
44
44
  sys.exit(f'cannot read {ruta}: {e}')
45
45
  valor = card.campo(texto, campo)
46
- # `repos` comes back as a list everywhere else; the shell wants one line.
47
- print(','.join(card.lista(valor)) if campo == 'repos' else valor)
46
+ # A bracketed value is a list everywhere else in this product `repos:`,
47
+ # `agents:`, `mounts.<agent>:` and the shell wants one line. Deciding by
48
+ # SHAPE rather than by a list of blessed field names is what stopped this
49
+ # from having to be edited every time the card grew another list.
50
+ esLista = valor.strip().startswith('[') and valor.strip().endswith(']')
51
+ print(','.join(card.lista(valor)) if esLista else valor)
48
52
 
49
53
 
50
54
  if __name__ == '__main__':
@@ -28,6 +28,7 @@ import sys
28
28
  import urllib.request
29
29
 
30
30
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ import busca # the one disk scanner, shared with the seat and the Hall
31
32
  import city_env
32
33
  import parcels # the one reader of parcels.yml, shared with the seeder
33
34
 
@@ -49,14 +50,12 @@ def donde_esta(repo):
49
50
 
50
51
  `parcels.yml` deliberately holds no local paths: the same file is shared by a
51
52
  whole team and the same repo sits somewhere different on every machine. That is
52
- what find-repos.sh is for, and it caches its index for a day.
53
+ what `busca` is for, and it caches its index for a day.
53
54
  """
54
55
  if repo not in _DONDE:
55
- guion = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'find-repos.sh')
56
56
  try:
57
- r = subprocess.run([guion, repo], capture_output=True, text=True, timeout=300)
58
- _DONDE[repo] = r.stdout.strip()
59
- except (OSError, subprocess.TimeoutExpired):
57
+ _DONDE[repo] = busca.ruta_de(repo)
58
+ except OSError:
60
59
  _DONDE[repo] = ''
61
60
  return _DONDE[repo]
62
61
 
@@ -142,7 +141,7 @@ def explica_vacio(di, datos, todas, por_defecto):
142
141
  di(f'{len(perdidas)} of {len(todas)} parcels are not cloned on this '
143
142
  f'machine, so there is no folder to run anything in:')
144
143
  di(' ' + ', '.join(sorted(set(perdidas))[:10]))
145
- di('Clone them, or run ./plugin/scripts/find-repos.sh --refresh if they '
144
+ di('Clone them, or run ./plugin/scripts/busca.py --refresh if they '
146
145
  'are here under another folder name.')
147
146
  elif not por_defecto:
148
147
  di('The folders are here, but nothing says how growth is counted. Put '
@@ -151,28 +151,64 @@ def reinicia(datos, usuario="", dry_run=False):
151
151
  return ctx["backup"]
152
152
 
153
153
 
154
+ def _resuelve_todas(pedidas, usuario):
155
+ """Every requested city as a path, or (None, complaint).
156
+
157
+ `all` means every managed city this owner has. Names resolve one by one and
158
+ a single unknown name aborts the WHOLE run: resetting three of four cities
159
+ and then stopping on a typo is the worst possible outcome of a destructive
160
+ command, so nothing starts until every name is known.
161
+ """
162
+ if len(pedidas) == 1 and pedidas[0].lower() == "all":
163
+ todas = [c["ruta"] for c in cities.lista(usuario)]
164
+ if not todas:
165
+ return None, "there are no cities here to reset"
166
+ return todas, ""
167
+ fuera, desconocidas = [], []
168
+ for nombre in pedidas:
169
+ datos = cities.resuelve(nombre, usuario)
170
+ if datos:
171
+ if datos not in fuera:
172
+ fuera.append(datos)
173
+ else:
174
+ desconocidas.append(nombre)
175
+ if desconocidas:
176
+ conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
177
+ return None, (f"no city called {', '.join(repr(d) for d in desconocidas)}. "
178
+ f"Known here: {conocidas}. Nothing was reset.")
179
+ return fuera, ""
180
+
181
+
154
182
  def main():
155
183
  ap = argparse.ArgumentParser(
156
- description="Reset one city to onboarding; repos stay untouched and data is backed up"
184
+ description="Reset cities to onboarding; repos stay untouched and data is backed up"
157
185
  )
158
- ap.add_argument("city", help="city name or path (required)")
186
+ ap.add_argument("city", nargs="+",
187
+ help="one or more city names or paths, space separated — or `all`")
159
188
  ap.add_argument("--dry-run", action="store_true", help="show every effect, change nothing")
160
189
  a = ap.parse_args()
161
190
  usuario = cities.usuario_actual()
162
- datos = cities.resuelve(a.city, usuario)
163
- if not datos:
164
- conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
165
- print(f" No city called {a.city!r}. Known here: {conocidas}.", file=sys.stderr)
166
- return 1
167
- try:
168
- backup = reinicia(datos, usuario, a.dry_run)
169
- except ValueError as e:
170
- print(f" {e}", file=sys.stderr)
191
+ objetivo, queja = _resuelve_todas(a.city, usuario)
192
+ if not objetivo:
193
+ print(f" {queja}", file=sys.stderr)
171
194
  return 1
195
+ # More than one city at once is a bigger gesture than the command's name
196
+ # suggests, so it says out loud what it is about to do before doing it.
197
+ if len(objetivo) > 1 and not a.dry_run:
198
+ print(f" Resetting {len(objetivo)} cities: "
199
+ f"{', '.join(cities.slug_ciudad(d) for d in objetivo)}")
200
+ for datos in objetivo:
201
+ try:
202
+ backup = reinicia(datos, usuario, a.dry_run)
203
+ except ValueError as e:
204
+ print(f" {cities.slug_ciudad(datos)}: {e}", file=sys.stderr)
205
+ return 1
206
+ if not a.dry_run:
207
+ corto = backup.replace(os.path.expanduser("~"), "~")
208
+ print(f" City `{cities.slug_ciudad(datos)}` reset. Recovery copy: {corto}")
172
209
  if not a.dry_run:
173
- corto = backup.replace(os.path.expanduser("~"), "~")
174
- print(f" City `{cities.slug_ciudad(datos)}` reset. Recovery copy: {corto}")
175
- print(f" Run `agents-city seat --city {cities.slug_ciudad(datos)}` for onboarding.")
210
+ primera = cities.slug_ciudad(objetivo[0])
211
+ print(f" Run `agents-city seat --city {primera}` for onboarding.")
176
212
  return 0
177
213
 
178
214