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
package/bin/shortcut ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Put a city on your desktop: its icon, its name, double-click to enter.
3
+ exec python3 "$(cd "$(dirname "$0")/../plugin/scripts" && pwd)/atajos.py" "$@"
package/bin/test CHANGED
@@ -7,7 +7,9 @@
7
7
  # Twelve suites and two compatibility walks. Nothing here needs a network, an
8
8
  # account, a terminal or a deployed anything: the widgets run against a fake curses
9
9
  # window, the reporter against a temporary git repo, and the wizard against a file
10
- # of answers. A suite that needs a browser open is a suite nobody runs.
10
+ # of answers. The one browser suite drives headless Chrome when the machine has
11
+ # one and says so when it does not — a page whose buttons are wired to nothing
12
+ # passes every other check in here, and one shipped.
11
13
  set -uo pipefail
12
14
  cd "$(dirname "$0")/.." || exit 1
13
15
 
@@ -20,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
20
22
  && printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
21
23
  fi
22
24
 
23
- SUITES=(widgets card parcels domains serve seat cities channel committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento launch)
25
+ SUITES=(widgets card parcels domains busca serve seat cities channel committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza launch)
24
26
  [ $# -gt 0 ] && SUITES=("$@")
25
27
 
26
28
  fallos=0
@@ -94,6 +96,7 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
94
96
  "plugin/scripts/launch.py", "plugin/scripts/hook_activity.py",
95
97
  "plugin/scripts/city-session.sh", "plugin/scripts/city-runtime.sh",
96
98
  "plugin/scripts/find-repos.sh",
99
+ "plugin/scripts/busca.py",
97
100
  "plugin/channel/bus.js", "plugin/channel/local-hub.js",
98
101
  "plugin/channel/client.js", "plugin/channel/adapter.js",
99
102
  "plugin/channel/runtime-gateway.js",
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Knowing there is a new version, and getting it.
3
+
4
+ ./bin/test-actualiza.py
5
+
6
+ No network: the registry call is replaced, because a suite that depends on
7
+ npm being up tests npm. What is tested here is everything around it — the
8
+ version comparison (where a prerelease must sort BELOW its release, the bug
9
+ every hand-rolled semver has), the day-long cache, the opt-out, and the refusal
10
+ to `npm install -g` over somebody's git checkout.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ import time
19
+
20
+ AQUI = os.path.dirname(os.path.abspath(__file__))
21
+ RAIZ = os.path.dirname(AQUI)
22
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
23
+ sys.path.insert(0, AQUI)
24
+
25
+ import actualiza # noqa: E402
26
+ from testlib import afirma, comprueba, resumen # noqa: E402
27
+
28
+
29
+ def comparacion():
30
+ print(" which version is newer")
31
+ for nueva, vieja in (
32
+ ("0.2.0", "0.1.0"),
33
+ ("1.0.0", "0.9.9"),
34
+ ("0.1.10", "0.1.9"),
35
+ # The one every hand-rolled comparison gets wrong: a release outranks
36
+ # its own prereleases, so 1.0.0 must beat 1.0.0-beta.22.
37
+ ("1.0.0", "1.0.0-beta.22"),
38
+ ("0.3.0-beta.22", "0.3.0-beta.21"),
39
+ ):
40
+ afirma(f"· {nueva} is newer than {vieja}", actualiza.es_mas_nueva(nueva, vieja),
41
+ f"{actualiza._partes(nueva)} vs {actualiza._partes(vieja)}")
42
+ for igual_o_vieja, actual in (("0.1.0", "0.1.0"), ("0.1.0", "0.2.0"),
43
+ ("1.0.0-beta.1", "1.0.0")):
44
+ afirma(f"· {igual_o_vieja} is NOT newer than {actual}",
45
+ not actualiza.es_mas_nueva(igual_o_vieja, actual))
46
+ afirma("· and nothing is newer than nothing", not actualiza.es_mas_nueva("", "0.1.0")
47
+ and not actualiza.es_mas_nueva("0.2.0", ""))
48
+
49
+
50
+ def cache_y_opt_out():
51
+ print(" the check: cached, quiet, and refusable")
52
+ casa = tempfile.mkdtemp()
53
+ previo = {k: os.environ.get(k) for k in ("AGENTS_CITY_HOME", "CITY_UPDATE_CHECK")}
54
+ os.environ["AGENTS_CITY_HOME"] = casa
55
+ os.environ.pop("CITY_UPDATE_CHECK", None)
56
+ llamadas = []
57
+
58
+ def registro_falso():
59
+ llamadas.append(1)
60
+ return "9.9.9"
61
+
62
+ real = actualiza.consulta_registro
63
+ actualiza.consulta_registro = lambda *a, **k: registro_falso()
64
+ try:
65
+ instalada, ultima, hay = actualiza.comprueba()
66
+ afirma("· a first check asks the registry and reports the newer version",
67
+ ultima == "9.9.9" and hay and len(llamadas) == 1, f"{instalada} {ultima} {hay}")
68
+ actualiza.comprueba()
69
+ actualiza.comprueba()
70
+ comprueba("· and the next ones read the day's answer instead of asking again",
71
+ len(llamadas), 1)
72
+ _, _, hay2 = actualiza.comprueba(forzar=True)
73
+ afirma("· --check forces a fresh ask", len(llamadas) == 2 and hay2)
74
+
75
+ # The cache is what makes a plane or a firewall silent rather than noisy.
76
+ actualiza.consulta_registro = lambda *a, **k: ""
77
+ _, ultima3, _ = actualiza.comprueba(forzar=True)
78
+ comprueba("· an unreachable registry falls back to what it knew", ultima3, "9.9.9")
79
+ guardado = json.load(open(os.path.join(casa, ".runtime", "version.json")))
80
+ afirma("· which is remembered under the runtime dir, with its timestamp",
81
+ guardado["latest"] == "9.9.9" and time.time() - guardado["when"] < 60,
82
+ str(guardado))
83
+
84
+ os.environ["CITY_UPDATE_CHECK"] = "0"
85
+ instalada, ultima4, hay4 = actualiza.comprueba(forzar=True)
86
+ afirma("· and CITY_UPDATE_CHECK=0 stops it entirely, saying nothing",
87
+ ultima4 == "" and hay4 is False and instalada, f"{instalada} {ultima4}")
88
+ comprueba("· so the passive notice stays empty too", actualiza.aviso(), "")
89
+ finally:
90
+ actualiza.consulta_registro = real
91
+ for k, v in previo.items():
92
+ if v is None:
93
+ os.environ.pop(k, None)
94
+ else:
95
+ os.environ[k] = v
96
+ shutil.rmtree(casa, ignore_errors=True)
97
+
98
+
99
+ def no_toca_un_checkout():
100
+ print(" what update refuses to do")
101
+ # This repo IS a git checkout, so the refusal is testable against itself:
102
+ # `npm install -g` over somebody's working copy is not an update.
103
+ comprueba("· a git checkout is recognised as one", actualiza.como_se_instalo(), "clone")
104
+ import io
105
+ from contextlib import redirect_stdout
106
+
107
+ salida = io.StringIO()
108
+ with redirect_stdout(salida):
109
+ codigo = actualiza.actualiza()
110
+ afirma("· and update refuses it, telling you the command that fits",
111
+ codigo == 1 and "git pull" in salida.getvalue(), salida.getvalue())
112
+
113
+
114
+ def la_puerta():
115
+ print(" the command in the front door")
116
+ front = open(os.path.join(RAIZ, "bin", "agents-city.js"), encoding="utf-8").read()
117
+ for orden in ("update", "doctor"):
118
+ afirma(f"· `agents-city {orden}` is listed and points at one implementation",
119
+ f"{orden}: {{ que: ['bin/{orden}']" in front, "")
120
+ for guion in ("update", "doctor"):
121
+ texto = open(os.path.join(RAIZ, "bin", guion), encoding="utf-8").read()
122
+ afirma(f"· bin/{guion} holds no logic of its own",
123
+ ".py" in texto and "def " not in texto, texto)
124
+
125
+
126
+ comparacion()
127
+ cache_y_opt_out()
128
+ no_toca_un_checkout()
129
+ la_puerta()
130
+ sys.exit(resumen("actualiza"))
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env python3
2
+ """The city's door on the desktop: a real icon, a real bundle, a real command.
3
+
4
+ ./bin/test-atajos.py
5
+
6
+ Everything lands in a throwaway "desktop" pointed at by CITY_DESKTOP, so this
7
+ never writes to the machine's real one. The load-bearing cases: the icon is a
8
+ valid PNG drawn from the city's own identity (no image library involved), the
9
+ shortcut runs the same front-door command a person would type, and removing it
10
+ leaves nothing behind.
11
+ """
12
+
13
+ import contextlib
14
+ import os
15
+ import shutil
16
+ import struct
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+
21
+ AQUI = os.path.dirname(os.path.abspath(__file__))
22
+ RAIZ = os.path.dirname(AQUI)
23
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
24
+ sys.path.insert(0, AQUI)
25
+
26
+ import atajos # noqa: E402
27
+ from testlib import afirma, comprueba, resumen # noqa: E402
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def entorno(**claves):
32
+ """Set env vars for a block and put the caller's own back afterwards.
33
+
34
+ Written once because these suites run inside somebody's real shell: a test
35
+ that leaves CITY_DESKTOP pointing at a deleted temp dir breaks the NEXT one.
36
+ """
37
+ previos = {k: os.environ.get(k) for k in claves}
38
+ os.environ.update({k: v for k, v in claves.items() if v is not None})
39
+ try:
40
+ yield
41
+ finally:
42
+ for k, v in previos.items():
43
+ if v is None:
44
+ os.environ.pop(k, None)
45
+ else:
46
+ os.environ[k] = v
47
+
48
+
49
+ def _ciudad(base, nombre="Aurora Games", ident="city_test_v1"):
50
+ datos = os.path.join(base, "city")
51
+ os.makedirs(datos, exist_ok=True)
52
+ with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
53
+ f.write(f"id: {ident}\nname: {nombre}\nslug: aurora\nowner: ana\n")
54
+ return datos
55
+
56
+
57
+ def el_icono():
58
+ print(" the icon, drawn from the city's own identity")
59
+ datos_png = atajos.icono_png("city_test_v1", 64)
60
+ afirma("· it is a real PNG, header and all", datos_png.startswith(b"\x89PNG\r\n\x1a\n"))
61
+ ancho, alto = struct.unpack(">II", datos_png[16:24])
62
+ comprueba("· at the size asked for", (ancho, alto), (64, 64))
63
+ afirma("· and it ends with the chunk that closes a PNG", datos_png.endswith(b"IEND\xaeB`\x82"))
64
+ # Same city, same colour, forever: derived from the identity, never stored
65
+ # and never random, so an icon does not change under somebody's cursor.
66
+ comprueba(
67
+ "· one city always gets the same colour",
68
+ atajos.color_de("city_test_v1"),
69
+ atajos.color_de("city_test_v1"),
70
+ )
71
+ distintos = {atajos.color_de(f"city_{i}") for i in range(40)}
72
+ afirma("· and different cities do not all look alike", len(distintos) > 1, str(distintos))
73
+
74
+
75
+ def el_atajo():
76
+ print(" the shortcut itself")
77
+ base = tempfile.mkdtemp(prefix="agents-city-atajo-")
78
+ mesa = os.path.join(base, "Desktop")
79
+ os.makedirs(mesa)
80
+ try:
81
+ with entorno(CITY_DESKTOP=mesa):
82
+ datos = _ciudad(base)
83
+ ruta, mal = atajos.crea(datos)
84
+ afirma("· it lands on the desktop, named after the city", not mal and os.path.exists(ruta),
85
+ f"{ruta} {mal}")
86
+ # Where a person reads the name differs by desktop: macOS shows the
87
+ # bundle's filename, GNOME and KDE show the entry's `Name=` field and
88
+ # never the filename. Assert the one the person actually sees.
89
+ # The command inside must be the front door, not a private re-launcher.
90
+ if sys.platform == "darwin":
91
+ afirma("· carrying the city's name where a person reads it",
92
+ "Aurora Games" in os.path.basename(ruta), os.path.basename(ruta))
93
+ guion = open(os.path.join(ruta, "Contents", "MacOS", "abrir-ciudad")).read()
94
+ info = os.path.join(ruta, "Contents", "Info.plist")
95
+ afirma("· the bundle has the Info.plist macOS needs to launch it",
96
+ os.path.isfile(info))
97
+ import plistlib
98
+
99
+ leido = plistlib.load(open(info, "rb"))
100
+ comprueba("· whose display name is the city's", leido["CFBundleDisplayName"],
101
+ "Aurora Games")
102
+ afirma("· and an icon file, when this machine can build one",
103
+ ("CFBundleIconFile" in leido)
104
+ == os.path.isfile(os.path.join(ruta, "Contents", "Resources", "city.icns")),
105
+ str(sorted(os.listdir(os.path.join(ruta, "Contents", "Resources")))))
106
+ afirma("· double-clicking it opens a terminal, where a tmux city can live",
107
+ "osascript" in guion and "Terminal" in guion, guion)
108
+ else:
109
+ guion = open(ruta).read()
110
+ afirma("· carrying the city's name where a person reads it",
111
+ "Name=Aurora Games" in guion, guion)
112
+ afirma("· it is a desktop entry with a name and an icon",
113
+ "Name=Aurora Games" in guion and "Icon=" in guion, guion)
114
+ afirma("· that opens in a terminal, where a tmux city can live",
115
+ "Terminal=true" in guion, guion)
116
+ afirma("· running exactly the command a person would type",
117
+ "agents-city seat --city 'Aurora Games'" in guion, guion)
118
+
119
+ # The hall variant, for somebody who wants the map and not the tmux.
120
+ atajos.quita(datos)
121
+ ruta_hall, _ = atajos.crea(datos, hall=True)
122
+ guion_hall = (
123
+ open(os.path.join(ruta_hall, "Contents", "MacOS", "abrir-ciudad")).read()
124
+ if sys.platform == "darwin"
125
+ else open(ruta_hall).read()
126
+ )
127
+ afirma("· --hall makes the same door open the browser instead",
128
+ "agents-city hall --city" in guion_hall, guion_hall)
129
+
130
+ # Removing it leaves nothing: a shortcut you cannot take back is litter.
131
+ comprueba("· removing it says so", atajos.quita(datos), True)
132
+ comprueba("· and leaves an empty desktop behind", os.listdir(mesa), [])
133
+ comprueba("· removing one that is not there is not an error",
134
+ atajos.quita(datos), False)
135
+ finally:
136
+ shutil.rmtree(base, ignore_errors=True)
137
+
138
+
139
+ def caminos_infelices():
140
+ print(" what it refuses")
141
+ base = tempfile.mkdtemp(prefix="agents-city-atajo-")
142
+ try:
143
+ # No desktop is a real answer on a server, and it must not be a crash.
144
+ with entorno(CITY_DESKTOP=os.path.join(base, "nowhere")):
145
+ datos = _ciudad(base)
146
+ ruta, mal = atajos.crea(datos)
147
+ afirma("· a machine with no Desktop gets an explanation, not a traceback",
148
+ not ruta and "Desktop" in mal, f"{ruta} {mal}")
149
+ # …and can still put it somewhere it chooses.
150
+ elegido = os.path.join(base, "elsewhere")
151
+ os.makedirs(elegido)
152
+ ruta, mal = atajos.crea(datos, carpeta=elegido)
153
+ afirma("· --to writes it wherever it was asked for",
154
+ not mal and ruta.startswith(elegido), f"{ruta} {mal}")
155
+ ruta, mal = atajos.crea(os.path.join(base, "no-such-city"), carpeta=elegido)
156
+ afirma("· a city that does not exist is refused", not ruta and "no city" in mal, mal)
157
+
158
+ # A name with a quote in it must not break out of the door's quoting.
159
+ raro = _ciudad(base, nombre="Ana's City", ident="city_quote_v1")
160
+ comprueba(
161
+ "· a quote in a city name is quoted for the shell, never passed raw",
162
+ atajos.orden_de_ciudad(raro),
163
+ "agents-city seat --city 'Ana'\\''s City'",
164
+ )
165
+ ruta, mal = atajos.crea(raro, carpeta=elegido)
166
+ guion = (
167
+ open(os.path.join(ruta, "Contents", "MacOS", "abrir-ciudad")).read()
168
+ if sys.platform == "darwin"
169
+ else open(ruta).read()
170
+ )
171
+ afirma(
172
+ "· and the written door never carries the unquoted form",
173
+ "--city 'Ana's City'" not in guion and "Ana" in guion,
174
+ guion,
175
+ )
176
+ if sys.platform == "darwin":
177
+ # The command travels inside an AppleScript string, so its
178
+ # backslashes are escaped once more; AppleScript unescapes them and
179
+ # Terminal receives exactly the shell-quoted line asserted above.
180
+ afirma(
181
+ "· macOS escapes it once more for AppleScript, and no further",
182
+ "'Ana'\\\\''s City'" in guion,
183
+ guion,
184
+ )
185
+ finally:
186
+ shutil.rmtree(base, ignore_errors=True)
187
+
188
+
189
+ def cada_escritorio():
190
+ """Every desktop's writer, exercised on whatever machine runs this.
191
+
192
+ `crea` picks one by platform, so a suite that only ever calls `crea` tests
193
+ one third of this file and lets the other two rot until CI meets them. Each
194
+ writer is therefore called directly here — this is the tombstone of a Linux
195
+ break that a Mac could not see.
196
+ """
197
+ print(" all three desktops, from whichever one we are on")
198
+ base = tempfile.mkdtemp(prefix="agents-city-todos-")
199
+ try:
200
+ datos = _ciudad(base, nombre="Aurora Games", ident="city_all_v1")
201
+ mesa = os.path.join(base, "Desktop")
202
+ os.makedirs(mesa)
203
+ orden = atajos.orden_de_ciudad(datos)
204
+ with entorno(XDG_DATA_HOME=os.path.join(base, "share")):
205
+ entrada = atajos._crea_linux(datos, mesa, "Aurora Games", orden, "city_all_v1")
206
+ texto = open(entrada, encoding="utf-8").read()
207
+ afirma("· the Linux entry names the city where GNOME and KDE read it",
208
+ "Name=Aurora Games" in texto, texto)
209
+ icono = [ln[5:] for ln in texto.splitlines() if ln.startswith("Icon=")]
210
+ afirma("· and points at an icon file that is really there",
211
+ icono and os.path.isfile(icono[0]), str(icono))
212
+ afirma("· running the same front-door command as every other desktop",
213
+ orden in texto, texto)
214
+
215
+ paquete = atajos._crea_mac(datos, mesa, "Aurora Games", orden, "city_all_v1")
216
+ arranque = os.path.join(paquete, "Contents", "MacOS", "abrir-ciudad")
217
+ afirma("· the macOS bundle is a folder with an executable inside",
218
+ os.path.isfile(arranque) and os.access(arranque, os.X_OK), paquete)
219
+ afirma("· running that same command",
220
+ orden in open(arranque, encoding="utf-8").read())
221
+ finally:
222
+ shutil.rmtree(base, ignore_errors=True)
223
+
224
+
225
+ def windows_bajo_wsl():
226
+ """On Windows the product runs inside WSL, and that changes everything here.
227
+
228
+ A `~/Desktop` inside WSL is the Linux home's desktop, which nobody looks at:
229
+ the desktop with icons on it belongs to Windows. So the shortcut has to be a
230
+ Windows one, crossing back into WSL to start the city.
231
+ """
232
+ print(" Windows, which is WSL wearing a Linux coat")
233
+ base = tempfile.mkdtemp(prefix="agents-city-wsl-")
234
+ try:
235
+ datos = _ciudad(base, nombre="Aurora Games", ident="city_wsl_v1")
236
+ mesa = os.path.join(base, "Desktop")
237
+ os.makedirs(mesa)
238
+
239
+ # Detection: WSL announces itself in the environment and in /proc.
240
+ with entorno(WSL_DISTRO_NAME="Ubuntu"):
241
+ afirma("· a WSL environment is recognised as Windows", atajos.en_wsl())
242
+
243
+ # The icon Windows needs: an ICO, which since Vista may simply wrap a
244
+ # PNG — so the same hand-written image serves both desktops.
245
+ crudo = atajos.ico("city_wsl_v1", 256)
246
+ reserva, tipo, cuantos = struct.unpack("<HHH", crudo[:6])
247
+ afirma("· the Windows icon is a valid single-image ICO",
248
+ (reserva, tipo, cuantos) == (0, 1, 1), str((reserva, tipo, cuantos)))
249
+ tam, desplazamiento = struct.unpack("<II", crudo[14:22])
250
+ afirma("· whose directory points at the PNG it carries",
251
+ crudo[desplazamiento:desplazamiento + 8] == b"\x89PNG\r\n\x1a\n"
252
+ and desplazamiento + tam == len(crudo),
253
+ f"offset {desplazamiento} size {tam} total {len(crudo)}")
254
+
255
+ # With no interop (a WSL with no powershell.exe reachable), it still
256
+ # writes a door: a .cmd, which double-clicks fine and only lacks an icon.
257
+ ruta = atajos._crea_wsl(
258
+ datos, mesa, "Aurora Games", atajos.orden_de_ciudad(datos), "city_wsl_v1"
259
+ )
260
+ guion = open(ruta, encoding="utf-8").read()
261
+ afirma("· without interop it still writes a double-clickable .cmd",
262
+ ruta.endswith(".cmd") and os.path.isfile(ruta), ruta)
263
+ afirma("· that crosses back into WSL to open the city",
264
+ "wsl.exe" in guion and "agents-city seat --city 'Aurora Games'" in guion, guion)
265
+ afirma("· in a login shell, so the PATH holds the command it runs",
266
+ "bash -lic" in guion, guion)
267
+ afirma("· with CRLF line endings, which is what cmd.exe reads",
268
+ "\r\n" in open(ruta, newline="", encoding="utf-8").read(), repr(guion))
269
+ # And removing it finds the Windows shapes too, not only the Unix ones.
270
+ with entorno(CITY_DESKTOP=mesa):
271
+ comprueba("· removal knows about .cmd and .lnk as well",
272
+ atajos.quita(datos), True)
273
+ comprueba("· and leaves the desktop clean", os.listdir(mesa), [])
274
+ finally:
275
+ shutil.rmtree(base, ignore_errors=True)
276
+
277
+
278
+ def la_puerta():
279
+ print(" the command in the front door")
280
+ ayuda = subprocess.run(
281
+ [sys.executable, os.path.join(RAIZ, "plugin", "scripts", "atajos.py"), "--help"],
282
+ capture_output=True,
283
+ text=True,
284
+ timeout=60,
285
+ )
286
+ afirma("· `agents-city shortcut --help` explains itself",
287
+ "shortcut" in ayuda.stdout and "--remove" in ayuda.stdout, ayuda.stdout)
288
+ front = open(os.path.join(RAIZ, "bin", "agents-city.js"), encoding="utf-8").read()
289
+ afirma("· and the npm front door lists it", "shortcut:" in front)
290
+ afirma("· pointing at the one implementation", "bin/shortcut" in front)
291
+ guion = open(os.path.join(RAIZ, "bin", "shortcut"), encoding="utf-8").read()
292
+ afirma("· which holds no logic of its own", "atajos.py" in guion and "def " not in guion)
293
+
294
+
295
+ el_icono()
296
+ el_atajo()
297
+ caminos_infelices()
298
+ cada_escritorio()
299
+ windows_bajo_wsl()
300
+ la_puerta()
301
+ sys.exit(resumen("atajos"))