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
@@ -257,6 +257,203 @@ def sincroniza(agente, data):
257
257
  return [t for _, t in mounts_en_disco(data, agente.slug)]
258
258
 
259
259
 
260
+ def como_ficha(agente, motor=None):
261
+ """One agent in the shape the card writers speak: a plain dict.
262
+
263
+ `Agente` is what readers get; this is what writers take. Keeping the two
264
+ shapes converted in one place is what stops the wizard and the Hall from
265
+ each inventing their own idea of what an agent looks like on a card.
266
+ """
267
+ return {
268
+ 'nombre': agente.nombre,
269
+ 'slug': agente.slug,
270
+ 'clase': agente.clase,
271
+ 'rol': agente.rol,
272
+ 'mounts': list(agente.mounts),
273
+ 'motor': dict(motor or {}),
274
+ 'skills': [],
275
+ }
276
+
277
+
278
+ def claves_de_roster(roster):
279
+ """Every card key a roster implies: identity, kind, role, mounts, engine.
280
+
281
+ The single owner of "what does a city's roster look like on a card". The
282
+ wizard writes it after asking seven questions and the Hall writes it after
283
+ one POST; both land here, so neither can drift into writing `kinds.` or
284
+ forgetting `mounts.` — the class of bug that only appears at the seam.
285
+ An empty mount list REMOVES the key rather than writing `[]`, because an
286
+ empty value is how `card.pon_campo` says "back to nothing".
287
+ """
288
+ claves = {'agents': '[' + ', '.join(a['nombre'] for a in roster) + ']'}
289
+ for a in roster:
290
+ claves[f'kind.{a["slug"]}'] = a['clase']
291
+ claves[f'role.{a["slug"]}'] = a['rol']
292
+ claves[f'mounts.{a["slug"]}'] = (
293
+ '[' + ', '.join(a['mounts']) + ']' if a['mounts'] else ''
294
+ )
295
+ for clave, valor in (a.get('motor') or {}).items():
296
+ claves[clave] = valor
297
+ return claves
298
+
299
+
300
+ # ── skills, the one deliberate write ─────────────────────────────────────────
301
+ #
302
+ # Recognition of skills is read-only everywhere else in this codebase. This is
303
+ # the single exception: the owner explicitly hands over a skill and asks for it
304
+ # to live in one agent's home. Both doors — the wizard's question and the Hall's
305
+ # upload — come through here, so the containment rules are written once. A skill
306
+ # is the Claude runtime's format; other engines ignore the folder entirely.
307
+
308
+ #: One skill's extraction budget. Whatever bounds the upload (a byte cap, a file
309
+ #: on disk), only these bound what a hostile deflate ratio inflates it to.
310
+ MAX_ENTRADAS_ZIP = 2048
311
+ MAX_EXTRAIDO = 64 * 1024 * 1024
312
+
313
+
314
+ def raiz_de_skills(hogar):
315
+ """The home's own `.claude/skills`, verified to still be inside the home
316
+ after resolution, or ''.
317
+
318
+ A repo can commit `.claude/skills` (or `.claude` itself) as a symlink to
319
+ anywhere the user can write — the global skills folder included — and both
320
+ an install and a removal would follow it straight out of the agent's home.
321
+ """
322
+ real = os.path.realpath(os.path.join(hogar, '.claude', 'skills'))
323
+ if real != os.path.join(os.path.realpath(hogar), '.claude', 'skills'):
324
+ return ''
325
+ return real
326
+
327
+
328
+ def zip_inseguro(entradas):
329
+ """The shapes a hostile zip uses, refused by name before a byte lands."""
330
+ if len(entradas) > MAX_ENTRADAS_ZIP:
331
+ return 'the zip holds too many files for one skill'
332
+ if sum(info.file_size for info in entradas) > MAX_EXTRAIDO:
333
+ return 'the zip inflates too large for one skill'
334
+ for info in entradas:
335
+ nombre = info.filename
336
+ if nombre.startswith(('/', '\\')) or '..' in nombre.split('/'):
337
+ return 'the zip tries to escape its folder'
338
+ if (info.external_attr >> 16) & 0o170000 == 0o120000:
339
+ return 'symlinks in a skill are refused'
340
+ return ''
341
+
342
+
343
+ def extrae_zip(archivo, entradas, base, destino):
344
+ """Write a zip's files under `destino`, containment resolved twice and the
345
+ decompressed bytes metered as they land — the headers already passed
346
+ `zip_inseguro`, but a header's declared size is the sender's word."""
347
+ presupuesto = MAX_EXTRAIDO
348
+ for info in entradas:
349
+ relativa = info.filename[len(base):] if info.filename.startswith(base) else ''
350
+ if not relativa or relativa.endswith('/'):
351
+ continue
352
+ ruta = os.path.realpath(os.path.join(destino, relativa))
353
+ if not ruta.startswith(destino + os.sep) and ruta != destino:
354
+ return 'the zip tries to escape its folder'
355
+ os.makedirs(os.path.dirname(ruta), exist_ok=True)
356
+ with archivo.open(info) as origen, open(ruta, 'wb') as salida:
357
+ while True:
358
+ trozo = origen.read(1024 * 64)
359
+ if not trozo:
360
+ break
361
+ presupuesto -= len(trozo)
362
+ if presupuesto < 0:
363
+ return 'the zip inflates too large for one skill'
364
+ salida.write(trozo)
365
+ return ''
366
+
367
+
368
+ def _nombre_de_skill_en_zip(entradas, pedido):
369
+ """The skill's name and its base inside the zip: the single top folder when
370
+ there is one, else the zip root plus a name the caller supplies."""
371
+ raices = {e.filename.split('/')[0] for e in entradas if e.filename.strip('/')}
372
+ con_carpeta = len(raices) == 1 and any(
373
+ '/' in e.filename or e.filename.endswith('/') for e in entradas
374
+ )
375
+ base = (next(iter(raices)) + '/') if con_carpeta else ''
376
+ crudo = next(iter(raices)) if con_carpeta else str(pedido or '')
377
+ rutas_dentro = {e.filename[len(base):] for e in entradas if e.filename.startswith(base)}
378
+ return card.rol_seguro(crudo, defecto=''), base, rutas_dentro
379
+
380
+
381
+ def _sitio_para_skill(raiz, etiqueta):
382
+ """(destination, '') for a free, plainly-named slot under the skills root."""
383
+ if not etiqueta:
384
+ return '', 'the skill needs a plain folder name'
385
+ destino = os.path.join(raiz, etiqueta)
386
+ if os.path.lexists(destino):
387
+ return '', f'{etiqueta} already exists there'
388
+ return destino, ''
389
+
390
+
391
+ def _copia_skill(ruta, raiz, nombre):
392
+ import shutil as _sh
393
+
394
+ if not os.path.isfile(os.path.join(ruta, 'SKILL.md')):
395
+ return '', 'a skill is a folder with a SKILL.md in it'
396
+ etiqueta = card.rol_seguro(nombre or os.path.basename(ruta.rstrip(os.sep)), defecto='')
397
+ destino, mal = _sitio_para_skill(raiz, etiqueta)
398
+ if mal:
399
+ return '', mal
400
+ os.makedirs(raiz, exist_ok=True)
401
+ try:
402
+ # symlinks=False: a link inside the source would otherwise land in the
403
+ # agent's home still pointing wherever the copier could reach.
404
+ _sh.copytree(ruta, destino, symlinks=False, ignore_dangling_symlinks=True)
405
+ except OSError as e:
406
+ _sh.rmtree(destino, ignore_errors=True)
407
+ return '', f'could not install the skill: {e}'
408
+ return etiqueta, ''
409
+
410
+
411
+ def _descomprime_skill(ruta, raiz, nombre):
412
+ import shutil as _sh
413
+ import zipfile
414
+
415
+ try:
416
+ archivo = zipfile.ZipFile(ruta)
417
+ entradas = archivo.infolist()
418
+ except (OSError, zipfile.BadZipFile):
419
+ return '', 'that is not a readable folder or zip'
420
+ mal = zip_inseguro(entradas)
421
+ if mal:
422
+ return '', mal
423
+ etiqueta, base, dentro = _nombre_de_skill_en_zip(entradas, nombre)
424
+ if 'SKILL.md' not in dentro:
425
+ return '', 'a skill is a folder with a SKILL.md in it'
426
+ destino, mal = _sitio_para_skill(raiz, etiqueta)
427
+ if mal:
428
+ return '', mal
429
+ try:
430
+ os.makedirs(destino)
431
+ mal = extrae_zip(archivo, entradas, base, destino)
432
+ except OSError as e:
433
+ mal = f'could not install the skill: {e}'
434
+ if mal:
435
+ _sh.rmtree(destino, ignore_errors=True)
436
+ return '', mal
437
+ return etiqueta, ''
438
+
439
+
440
+ def instala_skill(data, slug, origen, nombre=None):
441
+ """Install one skill from a folder or a `.zip` into an agent's own home.
442
+
443
+ Returns (skill name, ''), or ('', why). Never raises for a bad input: both
444
+ callers answer a person, and a traceback is not an answer.
445
+ """
446
+ ruta = rutas.canonicaliza(origen)
447
+ if not os.path.exists(ruta):
448
+ return '', f'nothing at {ruta}'
449
+ raiz = raiz_de_skills(crea_workspace(data, slug))
450
+ if not raiz:
451
+ return '', "this agent's .claude/skills is a link out of its home"
452
+ if os.path.isdir(ruta):
453
+ return _copia_skill(ruta, raiz, nombre)
454
+ return _descomprime_skill(ruta, raiz, nombre)
455
+
456
+
260
457
  # ── CLI ──────────────────────────────────────────────────────────────────────
261
458
 
262
459