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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +310 -70
- package/README.md +297 -69
- package/bin/agents-city.js +3 -0
- package/bin/doctor +3 -0
- package/bin/hall.html +164 -24
- package/bin/navegador.mjs +415 -0
- package/bin/serve.py +383 -127
- package/bin/shortcut +3 -0
- package/bin/test +5 -2
- package/bin/test-actualiza.py +130 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-busca.py +216 -0
- package/bin/test-cage.py +170 -2
- package/bin/test-card.py +2 -2
- package/bin/test-cities.py +45 -0
- package/bin/test-contracts.py +12 -5
- package/bin/test-doctor.py +33 -0
- package/bin/test-navegador.py +164 -0
- package/bin/test-seat.py +245 -25
- package/bin/test-serve.py +214 -9
- package/bin/test-workspace.py +63 -0
- package/bin/testlib.py +23 -0
- package/bin/update +3 -0
- package/city/web/dist/city.js +47 -47
- package/city/web/dist/index.html +1 -1
- package/city/web/dist-hall/hall.js +2193 -174
- package/city/web/src/bienvenida.ts +686 -0
- package/city/web/src/es.ts +180 -0
- package/city/web/src/hall.ts +520 -168
- package/city/web/src/idioma.ts +86 -0
- package/city/web/src/main.ts +27 -0
- package/city/web/src/motores.ts +54 -0
- package/docs/agents-first.md +8 -1
- package/docs/security.md +46 -12
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/busca.py +436 -0
- package/plugin/scripts/cage.py +266 -26
- package/plugin/scripts/capabilities.py +17 -10
- package/plugin/scripts/card.py +10 -0
- package/plugin/scripts/cities.py +34 -0
- package/plugin/scripts/city-session.sh +33 -7
- package/plugin/scripts/doctor.py +122 -0
- package/plugin/scripts/find-repos.sh +12 -105
- package/plugin/scripts/read-card.py +6 -2
- package/plugin/scripts/report.py +5 -6
- package/plugin/scripts/reset.py +50 -14
- package/plugin/scripts/seat.py +445 -103
- 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
|
|