stackhelx 1.0.0__py3-none-any.whl

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.
stackhelx/cli.py ADDED
@@ -0,0 +1,1165 @@
1
+ """CLI de StackHelx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import psutil
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from . import (
14
+ __version__,
15
+ config,
16
+ detect,
17
+ docker,
18
+ doctor,
19
+ history,
20
+ mcp,
21
+ ports,
22
+ registry,
23
+ runner,
24
+ scripts,
25
+ tunnel,
26
+ )
27
+
28
+ app = typer.Typer(
29
+ help="Orquestador de entornos de desarrollo locales.",
30
+ no_args_is_help=True,
31
+ add_completion=False,
32
+ )
33
+ console = Console()
34
+ err = Console(stderr=True)
35
+
36
+ PortArg = typer.Argument(..., min=1, max=65535)
37
+
38
+
39
+ def _row(status: ports.PortStatus, cmd_width: int) -> tuple[str, ...]:
40
+ if status.free:
41
+ return (str(status.port), "[green]libre[/]", "-", "-", "-")
42
+ if status.owner_unknown:
43
+ return (str(status.port), "[red]ocupado[/]", "?", "[dim]sin permisos[/]", "-")
44
+ cmd = status.cmdline or "-"
45
+ if len(cmd) > cmd_width:
46
+ cmd = cmd[: cmd_width - 1] + "…"
47
+ return (str(status.port), "[red]ocupado[/]", str(status.pid), status.name or "?", cmd)
48
+
49
+
50
+ @app.command("ports")
51
+ def ports_cmd(
52
+ port: list[int] = typer.Argument(None, min=1, max=65535),
53
+ ) -> None:
54
+ """Revisa puertos. Sin argumentos, usa los del stack (declarados o detectados)."""
55
+ if not port:
56
+ try:
57
+ stack = detect.stack_for(Path.cwd())
58
+ except config.ConfigError as exc:
59
+ err.print(f"{exc}\nPasa los puertos como argumento: stackhelx ports 3000 8080")
60
+ raise typer.Exit(1)
61
+ port = stack.ports()
62
+ if not port:
63
+ err.print(
64
+ f"{stack.path} no declara ningun puerto. Los servicios con "
65
+ "'ready: listen' recien lo tienen cuando arrancan."
66
+ )
67
+ raise typer.Exit(1)
68
+
69
+ table = Table(box=None, pad_edge=False)
70
+ for column in ("PUERTO", "ESTADO", "PID", "PROCESO", "COMANDO"):
71
+ table.add_column(column)
72
+ # Presupuesto para el comando: lo que sobra tras las cuatro columnas fijas.
73
+ cmd_width = max(20, console.width - 34)
74
+ scanned = ports.scan_many(port)
75
+ for value in port:
76
+ table.add_row(*_row(scanned[value], cmd_width))
77
+ console.print(table)
78
+
79
+
80
+ def _release(
81
+ port: int,
82
+ yes: bool,
83
+ force: bool,
84
+ expected_pid: int | None = None,
85
+ expected_create_time: float | None = None,
86
+ ) -> bool:
87
+ """Libera un puerto ocupado. Si se pasan expected_pid y expected_create_time,
88
+ verifica que el proceso no haya cambiado entre la confirmacion y el cierre.
89
+ """
90
+ status = ports.scan(port)
91
+ if status.free:
92
+ console.print(f"Puerto {port} ya estaba libre.")
93
+ return True
94
+
95
+ if status.pid is None:
96
+ err.print(
97
+ f"Puerto {port} ocupado, pero el proceso no es visible con estos "
98
+ "permisos. Proba desde una terminal con privilegios."
99
+ )
100
+ return False
101
+
102
+ if expected_pid is not None and status.pid != expected_pid:
103
+ err.print(
104
+ f"El puerto {port} cambio de proceso (ahora es PID {status.pid} [{status.name}]). "
105
+ "Salteado por seguridad."
106
+ )
107
+ return False
108
+
109
+ target_pid = expected_pid if expected_pid is not None else status.pid
110
+ target_create_time = (
111
+ expected_create_time if expected_create_time is not None else status.create_time
112
+ )
113
+
114
+ if expected_pid is None:
115
+ console.print(f"Puerto {port} ocupado por PID {status.pid} ([bold]{status.name}[/])")
116
+ if status.cmdline:
117
+ console.print(f" [dim]{status.cmdline}[/]")
118
+
119
+ if not yes and not typer.confirm(f"Cerrar el PID {status.pid}?"):
120
+ return False
121
+
122
+ try:
123
+ ports.kill(target_pid, target_create_time, force=force, port=port)
124
+ except ports.KillRefused as exc:
125
+ err.print(f"Rechazado: {exc}")
126
+ return False
127
+ except psutil.NoSuchProcess:
128
+ pass
129
+ except psutil.AccessDenied:
130
+ err.print(
131
+ f"Sin permisos para cerrar el PID {target_pid}. "
132
+ "Proba desde una terminal con privilegios."
133
+ )
134
+ return False
135
+
136
+ console.print(f"PID {target_pid} cerrado. Puerto {port} [green]libre[/].")
137
+ return True
138
+
139
+
140
+ @app.command("free")
141
+ def free_cmd(
142
+ port: int | None = typer.Argument(None, help="Puerto a liberar (ej: 8080)."),
143
+ all_ports: bool = typer.Option(
144
+ False, "--all", "-a", help="Liberar todos los puertos intrusos de los proyectos registrados."
145
+ ),
146
+ yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar antes de matar."),
147
+ force: bool = typer.Option(False, "--force", help="kill() si ignora terminate()."),
148
+ ) -> None:
149
+ """Libera un puerto ocupado o todos los puertos intrusos de proyectos registrados."""
150
+ if not all_ports and port is None:
151
+ err.print("Especifica un puerto (ej: stackhelx free 8080) o el flag --all")
152
+ raise typer.Exit(1)
153
+
154
+ if all_ports:
155
+ return _free_all(yes, force)
156
+
157
+ if ports.is_free(port):
158
+ console.print(f"Puerto {port} [green]libre[/].")
159
+ return
160
+ if not _release(port, yes, force):
161
+ console.print(f"Siguiente puerto libre: [bold]{ports.next_free(port)}[/]")
162
+
163
+
164
+ def _free_all(yes: bool, force: bool) -> None:
165
+ """Cierra lo que ocupa los puertos declarados por los proyectos registrados.
166
+
167
+ A diferencia de la interfaz, el CLI no tiene sesiones: no sabe cuales de
168
+ esos procesos los arranco StackHelx en otra terminal. Por eso lista todo
169
+ antes de tocar nada, y el texto no promete que sean ajenos.
170
+ """
171
+ encontrados = registry.find_orphans()
172
+ if not encontrados:
173
+ console.print("Ningun puerto de tus proyectos registrados esta ocupado.")
174
+ return
175
+
176
+ table = Table(box=None, pad_edge=False, show_header=False)
177
+ for item in encontrados:
178
+ # Los proyectos que reclaman ese puerto, no el dueño del proceso: el
179
+ # proceso es un desconocido y por eso esta en esta lista.
180
+ reclaman = ", ".join(item["projects"])
181
+ table.add_row(
182
+ f" [bold]:{item['port']}[/]",
183
+ f"{item['name']} (pid {item['pid']})",
184
+ f"[dim]lo declara {reclaman}[/]",
185
+ )
186
+ console.print(f"Ocupando puertos de tus proyectos ({len(encontrados)}):")
187
+ console.print(table)
188
+ console.print("[dim]Si alguno lo arrancaste vos desde otra terminal, tambien se cierra.[/]")
189
+ if not yes and not typer.confirm("Cerrarlos?", default=False):
190
+ console.print("Cancelado.")
191
+ return
192
+
193
+ liberados = sum(
194
+ 1
195
+ for item in encontrados
196
+ if _release(
197
+ item["port"],
198
+ yes=True,
199
+ force=force,
200
+ expected_pid=item["pid"],
201
+ expected_create_time=item["create_time"],
202
+ )
203
+ )
204
+ color = "green" if liberados == len(encontrados) else "yellow"
205
+ console.print(f"[{color}]{liberados} de {len(encontrados)} cerrados.[/]")
206
+ if liberados < len(encontrados):
207
+ raise typer.Exit(1)
208
+
209
+
210
+ def _confirm_detected(services: list[config.Service], yes: bool) -> bool:
211
+ """Muestra lo detectado y pide confirmacion: nadie quiere arrancar a ciegas."""
212
+ console.print("[dim]Sin stack.yaml. Detectado:[/]")
213
+ table = Table(box=None, pad_edge=False, show_header=False)
214
+ for service in services:
215
+ port = str(service.port) if service.port else "[dim]al arrancar[/]"
216
+ table.add_row(f" [bold]{service.name}[/]", service.command, port)
217
+ console.print(table)
218
+ console.print("[dim]Para congelarlo en un archivo editable: stackhelx init[/]")
219
+ return yes or typer.confirm("Arrancar?", default=True)
220
+
221
+
222
+ def _free_ports(services: list[config.Service], yes: bool, force: bool) -> None:
223
+ """Libera los puertos declarados que tenga otro proceso, antes de arrancar."""
224
+ for service in services:
225
+ if not service.port:
226
+ continue
227
+ status = ports.scan(service.port)
228
+ if status.free:
229
+ continue
230
+ motor = ports.proxy_owner(status)
231
+ if motor:
232
+ console.print(
233
+ f"[dim]{service.name}: el puerto {service.port} ya lo publica {motor}, "
234
+ f"no hay nada que liberar.[/]"
235
+ )
236
+ continue
237
+ if not _release(service.port, yes, force):
238
+ err.print(f"Puerto {service.port} sigue ocupado ({service.name}). Cancelado.")
239
+ raise typer.Exit(1)
240
+
241
+
242
+ @app.command("up")
243
+ def up_cmd(
244
+ profile: str = typer.Option(None, "--profile", "-p", help="Perfil de stack.yaml."),
245
+ env_file: str = typer.Option(None, "--env-file", "-e", help="Ruta al archivo .env personalizado."),
246
+ yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar nada, arrancar."),
247
+ force: bool = typer.Option(False, "--force", help="kill() si ignora terminate()."),
248
+ free: bool = typer.Option(
249
+ True, "--free/--no-free", help="Liberar los puertos declarados antes de arrancar."
250
+ ),
251
+ timeout: float = typer.Option(60.0, help="Segundos de espera por servicio."),
252
+ ) -> None:
253
+ """Levanta el stack: libera puertos, arranca en orden y sigue los logs."""
254
+ _levantar(Path.cwd(), profile, yes, force, free, timeout, env_file=env_file)
255
+
256
+
257
+ def _levantar(
258
+ root: Path,
259
+ profile: str | None,
260
+ yes: bool,
261
+ force: bool,
262
+ free: bool,
263
+ timeout: float,
264
+ env_file: str | None = None,
265
+ ) -> None:
266
+ """El cuerpo de `up`, por raiz explicita. `switch` levanta otro directorio."""
267
+ extra_env: dict[str, str] = {}
268
+ if env_file:
269
+ custom_env = (root / env_file).resolve()
270
+ if not custom_env.is_file():
271
+ err.print(f"No se encontró el archivo env: {env_file}")
272
+ raise typer.Exit(1)
273
+ extra_env = config.parse_env_file(custom_env)
274
+
275
+ try:
276
+ stack = detect.stack_for(root)
277
+ services = stack.resolve(profile)
278
+ except config.ConfigError as exc:
279
+ err.print(str(exc))
280
+ raise typer.Exit(1)
281
+
282
+ console.print(f"[bold]{stack.name}[/] [dim]{stack.path}[/]")
283
+
284
+ if stack.detected and not _confirm_detected(services, yes):
285
+ raise typer.Exit(1)
286
+
287
+ if free:
288
+ _free_ports(services, yes, force)
289
+
290
+ engine = runner.Runner(stack, console=console, timeout=timeout, extra_env=extra_env)
291
+ try:
292
+ engine.up(profile)
293
+ except runner.StartupError as exc:
294
+ err.print(f"Fallo el arranque: {exc}")
295
+ raise typer.Exit(1)
296
+
297
+ if all(p.service.detached for p in engine.procs):
298
+ console.print(
299
+ "[green]Todo listo.[/] Servicios detached, nada que seguir. "
300
+ "Para bajarlos: [bold]stackhelx down[/]"
301
+ )
302
+ return
303
+
304
+ console.print("[green]Todo listo.[/] Ctrl-C para apagar.")
305
+ try:
306
+ engine.follow()
307
+ except KeyboardInterrupt:
308
+ console.print()
309
+ finally:
310
+ engine.down()
311
+
312
+
313
+ MARCA = {"ok": "[green]ok [/]", "warn": "[yellow]aviso[/]", "fail": "[red]FALLA[/]"}
314
+
315
+
316
+ @app.command("doctor")
317
+ def doctor_cmd() -> None:
318
+ """Revisa que puede impedir el arranque, sin arrancar nada."""
319
+ checks = doctor.run(Path.cwd())
320
+
321
+ ancho = max(len(c.name) for c in checks)
322
+ for check in checks:
323
+ # Sin `highlight`: rich le pone color a los numeros y a las rutas, y
324
+ # esta salida es para copiar y pegar, no para mirar.
325
+ console.print(
326
+ f"{MARCA[check.level]} {check.name.ljust(ancho)} {check.detail}", highlight=False
327
+ )
328
+ if check.fix and check.level != "ok":
329
+ console.print(f"{' ' * (ancho + 8)}[dim]-> {check.fix}[/]", highlight=False)
330
+
331
+ if doctor.blocking(checks):
332
+ raise typer.Exit(1)
333
+
334
+
335
+ def _correr_stops(apagables: list[config.Service]) -> int:
336
+ """Corre los `stop:` en el orden que se le da. Devuelve cuantos fallaron."""
337
+ fallaron = 0
338
+ for service in apagables:
339
+ console.print(f"[dim]{service.name} | $ {service.stop}[/]")
340
+ done = runner.run_stop(service)
341
+ if done is None:
342
+ err.print(f"{service.name}: el apagado no termino en {runner.STOP_TIMEOUT}s")
343
+ fallaron += 1
344
+ continue
345
+ for line in (done.stdout or "").splitlines():
346
+ console.print(f"[dim]{service.name} |[/] {line.rstrip()}")
347
+ if done.returncode != 0:
348
+ err.print(f"{service.name}: el apagado fallo con codigo {done.returncode}")
349
+ fallaron += 1
350
+ return fallaron
351
+
352
+
353
+ @app.command("down")
354
+ def down_cmd(
355
+ profile: str = typer.Option(None, "--profile", "-p", help="Perfil de stack.yaml."),
356
+ ) -> None:
357
+ """Apaga lo que sobrevive a la terminal: contenedores y demas servicios detached."""
358
+ try:
359
+ stack = detect.stack_for(Path.cwd())
360
+ services = stack.resolve(profile)
361
+ except config.ConfigError as exc:
362
+ err.print(str(exc))
363
+ raise typer.Exit(1)
364
+
365
+ console.print(f"[bold]{stack.name}[/] [dim]{stack.path}[/]")
366
+
367
+ # En orden inverso al de arranque, igual que `Runner.down`: lo que depende
368
+ # de algo se baja antes que aquello de lo que depende.
369
+ apagables = [s for s in reversed(services) if s.stop]
370
+ if not apagables:
371
+ console.print(
372
+ "Ningun servicio declara [bold]stop[/]. Los que arranca "
373
+ "[bold]stackhelx up[/] son hijos de esa terminal y se apagan con Ctrl-C."
374
+ )
375
+ return
376
+
377
+ if _correr_stops(apagables):
378
+ raise typer.Exit(1)
379
+ console.print("[green]Apagado.[/]")
380
+
381
+
382
+ @app.command("switch")
383
+ def switch_cmd(
384
+ proyecto: str = typer.Argument(..., help="Nombre o ruta de un proyecto registrado."),
385
+ profile: str = typer.Option(None, "--profile", "-p", help="Perfil de stack.yaml."),
386
+ yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar nada, arrancar."),
387
+ force: bool = typer.Option(False, "--force", help="kill() si ignora terminate()."),
388
+ timeout: float = typer.Option(60.0, help="Segundos de espera por servicio."),
389
+ ) -> None:
390
+ """Baja los proyectos que le pisan los puertos a este, y lo levanta.
391
+
392
+ Rotar entre proyectos con puertos que se pisan son hoy tres comandos en tres
393
+ carpetas. El registro sabe quien declara que puerto, asi que puede hacerlo
394
+ solo.
395
+ """
396
+ root = _resolver_proyecto(proyecto)
397
+ try:
398
+ stack = detect.stack_for(root)
399
+ services = stack.resolve(profile)
400
+ except config.ConfigError as exc:
401
+ err.print(f"{root}: {exc}")
402
+ raise typer.Exit(1)
403
+
404
+ for rival in _rivales(root, services):
405
+ _bajar(rival)
406
+
407
+ _levantar(root, profile, yes, force, True, timeout)
408
+
409
+
410
+ def _resolver_proyecto(nombre: str) -> Path:
411
+ """Un proyecto registrado, por nombre de carpeta o por ruta."""
412
+ conocidos = registry.paths()
413
+ if not conocidos:
414
+ err.print("No hay proyectos registrados. Registra uno con: stackhelx add .")
415
+ raise typer.Exit(1)
416
+
417
+ candidato = Path(nombre).expanduser()
418
+ if candidato.is_dir() and candidato.resolve() in conocidos:
419
+ return candidato.resolve()
420
+
421
+ iguales = [p for p in conocidos if p.name.lower() == nombre.lower()]
422
+ if len(iguales) == 1:
423
+ return iguales[0]
424
+ if iguales:
425
+ err.print(f"'{nombre}' es ambiguo: {', '.join(str(p) for p in iguales)}")
426
+ raise typer.Exit(1)
427
+
428
+ err.print(
429
+ f"'{nombre}' no es un proyecto registrado. "
430
+ f"Conocidos: {', '.join(sorted(p.name for p in conocidos))}"
431
+ )
432
+ raise typer.Exit(1)
433
+
434
+
435
+ def _rivales(root: Path, services: list[config.Service]) -> list[Path]:
436
+ """Proyectos registrados que declaran alguno de los puertos de `services`.
437
+
438
+ Solo los que chocan, y no todo lo registrado: parar una base de datos que
439
+ nadie disputa no ayuda a arrancar, y es lo que mas cuesta volver a levantar.
440
+ Cuando todo choca, esto ya es todo lo demas.
441
+ """
442
+ wanted = {s.port for s in services if s.port}
443
+ if not wanted:
444
+ return []
445
+ mapa = registry.declared_ports()
446
+ return sorted({p for port in wanted for p in mapa.get(port, []) if p != root})
447
+
448
+
449
+ def _bajar(root: Path) -> None:
450
+ """Corre los `stop:` de otro proyecto. Un fallo avisa y no corta el switch.
451
+
452
+ Lo que no tiene `stop:` no se toca aca: son hijos de otra terminal, y si
453
+ igual siguen ocupando el puerto los agarra `_free_ports` al levantar, que ya
454
+ pregunta antes de matar a nadie.
455
+ """
456
+ try:
457
+ stack = detect.stack_for(root)
458
+ apagables = [s for s in reversed(stack.resolve()) if s.stop]
459
+ except config.ConfigError as exc:
460
+ err.print(f"[dim]{root.name}: no se pudo leer para bajarlo ({exc})[/]")
461
+ return
462
+ if not apagables:
463
+ return
464
+ console.print(f"[bold]Bajando {stack.name}[/] [dim]{root}[/]")
465
+ if _correr_stops(apagables):
466
+ err.print(f"{stack.name}: quedo algo sin bajar, el puerto puede seguir ocupado.")
467
+
468
+
469
+ def _abrir(url: str) -> None:
470
+ console.print(f"Abriendo [bold]{url}[/]")
471
+ import webbrowser
472
+
473
+ webbrowser.open(url)
474
+
475
+
476
+ @app.command("open")
477
+ def open_cmd(
478
+ port: int = typer.Argument(None, min=1, max=65535, help="Puerto, si ya lo sabes."),
479
+ ) -> None:
480
+ """Abre en el navegador el primer servicio del stack que conteste HTTP."""
481
+ if port is None:
482
+ try:
483
+ stack = detect.stack_for(Path.cwd())
484
+ except config.ConfigError as exc:
485
+ err.print(f"{exc}\nPasa el puerto como argumento: stackhelx open 3000")
486
+ raise typer.Exit(1)
487
+ # En orden de arranque: los contenedores primero, el frontend al final.
488
+ # Se recorre al reves porque lo que uno quiere abrir suele ser lo ultimo.
489
+ # Cada candidato es (url declarada o None, puerto o None): un puerto
490
+ # suelto no es un servicio y fabricarle uno seria un objeto que miente.
491
+ candidates = [
492
+ (runner.service_url(s), s.port)
493
+ for s in reversed(stack.resolve())
494
+ if s.port or s.url
495
+ ]
496
+ else:
497
+ candidates = [(None, port)]
498
+
499
+ for declarada, puerto in candidates:
500
+ # Sin puerto no hay que sondear: no hay nada que preguntar. Puede abrir
501
+ # una pestaña muerta si el stack no esta arriba, que es exactamente lo
502
+ # que pasa hoy escribiendo la URL a mano, y es preferible a no poder
503
+ # abrirla nunca.
504
+ if puerto is None:
505
+ # Una `url:` con una variable sin valor no resuelve, y sin `port:` no
506
+ # hay a que caer: `_abrir(None)` reventaba con un TypeError crudo en
507
+ # la terminal. Saltearlo deja contestar al candidato siguiente, y si
508
+ # no queda ninguno cae en el error de abajo, que es lo que hay que
509
+ # leer cuando falta la variable.
510
+ if declarada is None:
511
+ continue
512
+ _abrir(declarada)
513
+ return
514
+ if runner.speaks_http(puerto):
515
+ _abrir(declarada or f"http://localhost:{puerto}")
516
+ return
517
+
518
+ err.print(
519
+ "Ningun puerto del stack contesta HTTP. "
520
+ "Arrancalo con 'stackhelx up' o pasa el puerto como argumento."
521
+ )
522
+ raise typer.Exit(1)
523
+
524
+
525
+ @app.command("add")
526
+ def add_cmd(
527
+ path: str = typer.Argument(".", help="Directorio del proyecto."),
528
+ ) -> None:
529
+ """Registra un proyecto para que aparezca en la interfaz."""
530
+ try:
531
+ registered = registry.add(path)
532
+ except registry.RegistryError as exc:
533
+ err.print(str(exc))
534
+ raise typer.Exit(1)
535
+ console.print(f"Registrado: [bold]{registered}[/]")
536
+
537
+
538
+ @app.command("list")
539
+ @app.command("ls")
540
+ def list_cmd() -> None:
541
+ """Lista los proyectos registrados para la interfaz web."""
542
+ items = registry.paths()
543
+ if not items:
544
+ console.print("[dim]No hay proyectos registrados. Registra uno con: stackhelx add <ruta>[/]")
545
+ return
546
+
547
+ declared = registry.declared_ports()
548
+ collisions = registry.find_collisions()
549
+
550
+ table = Table(box=None, pad_edge=False)
551
+ table.add_column("ID")
552
+ table.add_column("NOMBRE")
553
+ table.add_column("PUERTOS")
554
+ table.add_column("RUTA")
555
+ for path in items:
556
+ pid = registry.project_id(path)
557
+ proj_ports = sorted(p for p, projs in declared.items() if path in projs)
558
+ port_labels = []
559
+ for p in proj_ports:
560
+ if p in collisions:
561
+ port_labels.append(f"[yellow]{p}[/]")
562
+ else:
563
+ port_labels.append(str(p))
564
+ ports_str = ", ".join(port_labels) if port_labels else "-"
565
+ table.add_row(pid, registry.name_of(path), ports_str, str(path))
566
+ console.print(table)
567
+ if collisions:
568
+ console.print("[dim yellow]Puertos en amarillo se disputan entre dos o mas proyectos.[/]")
569
+
570
+
571
+
572
+ @app.command("remove")
573
+ @app.command("rm")
574
+ def remove_cmd(
575
+ target: str = typer.Argument(..., help="Ruta o ID del proyecto a des-registrar."),
576
+ ) -> None:
577
+ """Des-registra un proyecto de la interfaz."""
578
+ pids = {registry.project_id(p): p for p in registry.paths()}
579
+ if target in pids:
580
+ pid = target
581
+ path = pids[pid]
582
+ else:
583
+ resolved = Path(target).expanduser().resolve()
584
+ pid = registry.project_id(resolved)
585
+ path = resolved
586
+
587
+ if registry.remove(pid):
588
+ console.print(f"Quitado: [bold]{path}[/]")
589
+ else:
590
+ err.print(f"El proyecto '{target}' no esta registrado.")
591
+ raise typer.Exit(1)
592
+
593
+
594
+ @app.command("init")
595
+ def init_cmd(
596
+ path: str = typer.Argument(".", help="Directorio del proyecto."),
597
+ ) -> None:
598
+ """Escribe un stack.yaml con lo detectado, para editarlo a mano."""
599
+ root = Path(path).expanduser().resolve()
600
+ try:
601
+ target = detect.freeze(root)
602
+ except config.ConfigError as exc:
603
+ err.print(str(exc))
604
+ raise typer.Exit(1)
605
+
606
+ console.print(f"Escrito: [bold]{target}[/]")
607
+ console.print("[dim]Revisalo antes de confiar en el.[/]")
608
+
609
+
610
+ @app.command("export")
611
+ def export_cmd(
612
+ out: Path | None = typer.Argument(None, help="Archivo JSON de destino (opcional)."),
613
+ ) -> None:
614
+ """Exporta las rutas de todos los proyectos registrados en formato JSON."""
615
+ data = registry.export_data()
616
+ text = json.dumps(data, indent=2)
617
+ if out:
618
+ out.write_text(text, encoding="utf-8")
619
+ console.print(f"Exportados {len(data)} proyectos en [bold]{out}[/]")
620
+ else:
621
+ console.print(text)
622
+
623
+
624
+ @app.command("import")
625
+ def import_cmd(
626
+ src: Path = typer.Argument(..., help="Archivo JSON con rutas de proyectos."),
627
+ ) -> None:
628
+ """Importa masivamente proyectos registrados desde un archivo JSON."""
629
+ if not src.is_file():
630
+ err.print(f"El archivo '{src}' no existe.")
631
+ raise typer.Exit(1)
632
+ try:
633
+ data = json.loads(src.read_text(encoding="utf-8"))
634
+ imported = registry.import_data(data)
635
+ console.print(f"Importados [bold]{len(imported)}[/] proyectos desde [bold]{src}[/]")
636
+ except Exception as exc:
637
+ err.print(f"Error al importar desde '{src}': {exc}")
638
+ raise typer.Exit(1)
639
+
640
+
641
+ @app.command("serve")
642
+ def serve_cmd(
643
+ port: int = typer.Option(7666, min=1, max=65535, help="Puerto de la interfaz."),
644
+ no_open: bool = typer.Option(False, "--no-open", help="No abrir el navegador."),
645
+ verbose: bool = typer.Option(
646
+ False, "--verbose", "-v", help="Registrar cada peticion que llega."
647
+ ),
648
+ ) -> None:
649
+ """Levanta la interfaz web local."""
650
+ try:
651
+ import uvicorn
652
+
653
+ from . import server
654
+ except ImportError:
655
+ err.print("Falta fastapi o uvicorn. Reinstala stackhelx: pipx reinstall stackhelx")
656
+ raise typer.Exit(1)
657
+
658
+ # Antes de imprimir la URL: uvicorn atrapa el error de bind y sale por su
659
+ # cuenta, asi que el `except OSError` de abajo nunca corria y el usuario
660
+ # veia un ERROR de winerror en vez del comando que lo arregla. Averiguar
661
+ # quien tiene un puerto es lo que esta herramienta hace.
662
+ if not ports.is_free(port):
663
+ ocupante = ports.scan(port)
664
+ quien = ocupante.name or "un proceso desconocido"
665
+ err.print(f"El puerto {port} ya esta ocupado por {quien} (pid {ocupante.pid}).")
666
+ try:
667
+ suggested = ports.suggest_alternative(port)
668
+ err.print(f"Cerralo con: stackhelx free {port} o arranca con: --port {suggested}")
669
+ except Exception:
670
+ err.print(f"Cerralo con: stackhelx free {port} o arranca con: --port <otro>")
671
+ raise typer.Exit(1)
672
+
673
+ token = registry.token()
674
+ url = f"http://127.0.0.1:{port}/?token={token}"
675
+
676
+ console.print(f"StackHelx en [bold]http://127.0.0.1:{port}[/]")
677
+ console.print("[dim]Solo loopback. El token va en la URL de abajo.[/]")
678
+ console.print(url)
679
+
680
+ if not no_open:
681
+ import webbrowser
682
+
683
+ webbrowser.open(url)
684
+
685
+ try:
686
+ # Por defecto callado: la interfaz sondea cada 2.5s y el log de acceso
687
+ # tapa cualquier otra cosa. Con --verbose se ve que pide el navegador,
688
+ # que es la unica forma de saber si esta hablando con este servidor o
689
+ # mostrando una pagina vieja de su cache.
690
+ uvicorn.run(
691
+ server.create_app(token),
692
+ host="127.0.0.1",
693
+ port=port,
694
+ log_level="info" if verbose else "warning",
695
+ access_log=verbose,
696
+ )
697
+ except OSError as exc:
698
+ err.print(f"No se pudo iniciar el servidor en 127.0.0.1:{port}: {exc}")
699
+ err.print(f"El puerto {port} esta ocupado. Podes usar 'stackhelx free {port}' o '--port <otro>'.")
700
+ raise typer.Exit(1)
701
+
702
+
703
+ def _version(pedido: bool) -> None:
704
+ if pedido:
705
+ console.print(__version__)
706
+ raise typer.Exit()
707
+
708
+
709
+ # El flag ademas del subcomando: `--version` es lo que prueba cualquiera que
710
+ # acaba de instalar la herramienta, y `no_args_is_help` hace que un `stackhelx`
711
+ # pelado muestre la ayuda antes de llegar aca. Eager para que conteste sin pedir
712
+ # un comando.
713
+ @app.callback()
714
+ def _root(
715
+ version: bool = typer.Option(
716
+ False, "--version", callback=_version, is_eager=True,
717
+ help="Muestra la version instalada.",
718
+ ),
719
+ ) -> None:
720
+ pass
721
+
722
+
723
+ @app.command(
724
+ "run",
725
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
726
+ )
727
+ def run_cmd(
728
+ ctx: typer.Context,
729
+ script: str = typer.Argument(None, help="Nombre del script a ejecutar"),
730
+ ) -> None:
731
+ """Ejecuta un script o pipeline de tareas definido en stack.yaml."""
732
+ try:
733
+ stack = detect.stack_for(Path.cwd())
734
+ except config.ConfigError as exc:
735
+ err.print(f"{exc}")
736
+ raise typer.Exit(1)
737
+
738
+ if not script:
739
+ if not stack.scripts:
740
+ console.print("[dim]No hay scripts declarados en stack.yaml.[/]")
741
+ raise typer.Exit(0)
742
+ table = Table(box=None, pad_edge=False)
743
+ table.add_column("SCRIPT", style="bold cyan")
744
+ table.add_column("COMANDOS")
745
+ for name, cmds in stack.scripts.items():
746
+ table.add_row(name, " && ".join(cmds))
747
+ console.print(table)
748
+ raise typer.Exit(0)
749
+
750
+ try:
751
+ code = scripts.run_script(stack, script, extra_args=ctx.args, console=console)
752
+ except config.ConfigError as exc:
753
+ err.print(f"{exc}")
754
+ raise typer.Exit(1)
755
+
756
+ if code != 0:
757
+ raise typer.Exit(code)
758
+
759
+
760
+ @app.command("share")
761
+ def share_cmd(
762
+ target: str = typer.Argument(
763
+ None,
764
+ help="Servicio o puerto a compartir (ej. 3000, web). Sin argumentos, usa el puerto principal.",
765
+ ),
766
+ provider: str = typer.Option(
767
+ None,
768
+ "--provider",
769
+ "-p",
770
+ help="Proveedor de tuneles: cloudflared, ngrok, lt, tailscale.",
771
+ ),
772
+ ) -> None:
773
+ """Expone un servicio local a internet mediante un tunel seguro."""
774
+ import time
775
+
776
+ port: int | None = None
777
+ if target and target.isdigit():
778
+ # `target` es texto porque tambien acepta el nombre de un servicio, asi
779
+ # que se pierde el `min`/`max` que traen los demas comandos. Sin esto,
780
+ # `stackhelx share 0` levantaba el cliente de tuneles contra 127.0.0.1:0.
781
+ try:
782
+ port = ports.check_port(int(target))
783
+ except ValueError as exc:
784
+ err.print(str(exc))
785
+ raise typer.Exit(1)
786
+ else:
787
+ try:
788
+ stack = detect.stack_for(Path.cwd())
789
+ except config.ConfigError as exc:
790
+ err.print(f"{exc}\nEspecifica el puerto a compartir: stackhelx share 3000")
791
+ raise typer.Exit(1)
792
+
793
+ if target and target in stack.services:
794
+ svc = stack.services[target]
795
+ if svc.port:
796
+ port = svc.port
797
+ else:
798
+ err.print(f"El servicio '{target}' no tiene un puerto fijo declarado.")
799
+ raise typer.Exit(1)
800
+ elif target:
801
+ err.print(f"Servicio o puerto '{target}' no encontrado en el stack.")
802
+ raise typer.Exit(1)
803
+ else:
804
+ ports_list = stack.ports()
805
+ if not ports_list:
806
+ err.print(f"{stack.path} no declara ningun puerto.")
807
+ raise typer.Exit(1)
808
+ port = ports_list[-1]
809
+
810
+ console.print(f"[bold cyan]Iniciando tunel hacia 127.0.0.1:{port}...[/]")
811
+ try:
812
+ tun = tunnel.start_tunnel(port, provider=provider)
813
+ except tunnel.TunnelError as exc:
814
+ err.print(f"[bold red]Error:[/] {exc}")
815
+ raise typer.Exit(1)
816
+
817
+ console.print(f"[bold green]Tunel activo![/] Proveedor: [bold]{tun.provider}[/]")
818
+ console.print(f"Local: [cyan]http://127.0.0.1:{port}[/]")
819
+ console.print(f"Publico: [bold underline green]{tun.url}[/]")
820
+ console.print("[dim]Presiona Ctrl-C para cerrar el tunel.[/]")
821
+
822
+ try:
823
+ while tun.proc.poll() is None:
824
+ time.sleep(0.5)
825
+ except KeyboardInterrupt:
826
+ pass
827
+ finally:
828
+ tun.stop()
829
+ console.print("\n[dim]Tunel cerrado.[/]")
830
+
831
+
832
+ @app.command("clean")
833
+ def clean_cmd(
834
+ solo: list[str] = typer.Option(
835
+ None,
836
+ "--solo",
837
+ "-s",
838
+ help=(
839
+ "Limpiar solo estas categorias: containers, images, networks, cache. "
840
+ "Repetible. Sin esto, las cuatro."
841
+ ),
842
+ ),
843
+ volumes: bool = typer.Option(
844
+ False,
845
+ "--volumes",
846
+ "-v",
847
+ help="Elimina tambien volumenes anonimos/huerfanos de Docker.",
848
+ ),
849
+ yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar."),
850
+ ) -> None:
851
+ """Limpia contenedores parados, imagenes sin tag y recursos huerfanos de Docker."""
852
+ # `--solo` acota, `--volumes` suma. Son cosas distintas: una elige de lo que
853
+ # se regenera, la otra agrega lo que tiene datos adentro.
854
+ objetivos = list(solo) if solo else list(docker.DEFAULT_TARGETS)
855
+ desconocidos = [t for t in objetivos if t not in docker.DEFAULT_TARGETS]
856
+ if desconocidos:
857
+ err.print(
858
+ f"Categoria desconocida: {', '.join(desconocidos)}. "
859
+ f"Validas: {', '.join(docker.DEFAULT_TARGETS)}"
860
+ )
861
+ raise typer.Exit(1)
862
+ if volumes:
863
+ objetivos.append("volumes")
864
+ # Preguntando, como `free`. Es el unico comando de la herramienta que borra
865
+ # datos en vez de cerrar procesos, y era el unico que no preguntaba nada: el
866
+ # boton de la interfaz ya pedia dos clicks y este se ejecutaba en silencio.
867
+ if not yes:
868
+ tabla = docker.usage()
869
+ if tabla:
870
+ console.print(tabla)
871
+ console.print("Se borra: " + ", ".join(docker.ETIQUETAS[t] for t in objetivos if t != "volumes"))
872
+ if volumes:
873
+ console.print("[bold]Y los volumenes anonimos huerfanos, que tienen datos adentro.[/]")
874
+ if not typer.confirm("Seguir?", default=False):
875
+ console.print("Cancelado.")
876
+ return
877
+
878
+ console.print("[bold cyan]Ejecutando limpieza de recursos Docker...[/]")
879
+ ok, msg = docker.prune(objetivos)
880
+ if ok:
881
+ console.print(f"[bold green]Listo:[/] {msg}")
882
+ else:
883
+ err.print(f"[bold red]Error al limpiar Docker:[/] {msg}")
884
+ raise typer.Exit(1)
885
+
886
+
887
+ @app.command("mcp")
888
+ def mcp_cmd(
889
+ show_config: bool = typer.Option(
890
+ False,
891
+ "--config",
892
+ "-c",
893
+ help="Muestra el bloque de configuracion JSON para Claude Desktop, Cursor o Antigravity.",
894
+ ),
895
+ ) -> None:
896
+ """Inicia el servidor Model Context Protocol (MCP) sobre stdio para agentes de IA."""
897
+ if show_config:
898
+ cfg = {
899
+ "mcpServers": {
900
+ "stackhelx": {
901
+ "command": "stackhelx",
902
+ "args": ["mcp"],
903
+ }
904
+ }
905
+ }
906
+ console.print(json.dumps(cfg, indent=2))
907
+ return
908
+ mcp.serve_stdio()
909
+
910
+
911
+ @app.command("version")
912
+ def version_cmd() -> None:
913
+ """Muestra la version instalada."""
914
+ console.print(__version__)
915
+
916
+
917
+ @app.command("history")
918
+ def history_cmd(
919
+ target: str = typer.Argument(None),
920
+ limit: int = typer.Option(5, "--limit", "-n", help="Cantidad de arranques a mostrar"),
921
+ ) -> None:
922
+ """Muestra el historial de arranques del proyecto."""
923
+ if limit < 1 or limit > history.MAX_LIMIT:
924
+ err.print(f"[red]Error:[/] --limit debe ser entre 1 y {history.MAX_LIMIT}")
925
+ raise typer.Exit(1)
926
+
927
+ try:
928
+ path = (Path.cwd() / (target or "")).resolve()
929
+ stack = detect.stack_for(path)
930
+ pid = registry.project_id(stack.root)
931
+ except config.ConfigError as exc:
932
+ err.print(f"[red]Error:[/] {exc}")
933
+ raise typer.Exit(1)
934
+
935
+ runs = history.read(pid, limit=limit)
936
+ if not runs:
937
+ console.print(f"No hay historial para el proyecto [bold]{stack.name}[/]")
938
+ return
939
+
940
+ table = Table(title=f"Historial de arranques: {stack.name}")
941
+ table.add_column("Fecha")
942
+ table.add_column("Perfil")
943
+ table.add_column("Duración")
944
+ table.add_column("Resultado")
945
+
946
+ for r in reversed(runs):
947
+ fecha = r.get("timestamp", "").split("T")[0] + " " + r.get("timestamp", "T")[:16].split("T")[-1]
948
+ perfil = r.get("profile") or "-"
949
+ dur = f"{r.get('duration_s', 0)}s"
950
+ res = r.get("result", "unknown")
951
+
952
+ color = "green" if res == "running" else "red" if res == "error" else "yellow"
953
+ res_format = f"[{color}]{res}[/]"
954
+ if res == "error" and "error" in r:
955
+ res_format += f"\n[dim]{r['error']}[/]"
956
+
957
+ table.add_row(fecha, perfil, dur, res_format)
958
+
959
+ console.print(table)
960
+
961
+
962
+ @app.command("test-stack")
963
+ def test_stack_cmd(
964
+ target: str = typer.Argument(None, help="Ruta al proyecto o directorio"),
965
+ ) -> None:
966
+ """Valida la configuración del stack (puertos, dependencias, variables) sin levantar servicios."""
967
+ path = (Path.cwd() / (target or "")).resolve()
968
+ try:
969
+ stack = detect.stack_for(path)
970
+ services = stack.resolve()
971
+ except config.ConfigError as exc:
972
+ err.print(f"[bold red]Configuración inválida:[/] {exc}")
973
+ raise typer.Exit(1)
974
+
975
+ console.print(f"Validando stack [bold]{stack.name}[/] en [dim]{stack.root}[/]...")
976
+ console.print(f"[green]OK:[/] {len(services)} servicio(s) resueltos en orden topológico:")
977
+ for s in services:
978
+ deps = f" (espera a: {', '.join(s.needs)})" if s.needs else ""
979
+ port_info = f" -> puerto {s.port}" if s.port else f" ({s.ready})"
980
+ console.print(f" - [cyan]{s.name}[/]: [dim]{s.command}[/]{port_info}{deps}")
981
+
982
+ # Verificar estado de puertos
983
+ declared_ports = stack.ports()
984
+ if declared_ports:
985
+ occupied = [p for p in declared_ports if not ports.is_free(p)]
986
+ if occupied:
987
+ console.print(f"[yellow]Aviso:[/] Puertos actualmente en uso: {', '.join(map(str, occupied))}")
988
+ else:
989
+ console.print("[green]OK:[/] Todos los puertos declarados están libres")
990
+
991
+ # Sin el `✓` que habia aca: no existe en cp1252, o sea la pagina de
992
+ # codigos con la que sale la consola de Windows, y el comando entero
993
+ # terminaba en UnicodeEncodeError despues de haber validado bien. Los
994
+ # acentos si entran en cp1252, por eso se quedan.
995
+ console.print("\n[bold green]Stack validado con éxito.[/]")
996
+
997
+
998
+ @app.command("logs")
999
+ def logs_cmd(
1000
+ target: str = typer.Argument(None, help="Ruta al proyecto"),
1001
+ service: str = typer.Option(None, "--service", "-s", help="Filtrar por nombre de servicio"),
1002
+ follow: bool = typer.Option(False, "--follow", "-f", help="Seguir logs en tiempo real"),
1003
+ server_port: int = typer.Option(7666, "--port", "-p", help="Puerto del servidor de StackHelx"),
1004
+ ) -> None:
1005
+ """Muestra o sigue los logs del proyecto en ejecución en StackHelx."""
1006
+ import time
1007
+ import urllib.request
1008
+
1009
+ path = (Path.cwd() / (target or "")).resolve()
1010
+ try:
1011
+ stack = detect.stack_for(path)
1012
+ pid = registry.project_id(stack.root)
1013
+ except config.ConfigError as exc:
1014
+ err.print(f"[red]Error:[/] {exc}")
1015
+ raise typer.Exit(1)
1016
+
1017
+ token = registry.token()
1018
+ base_url = f"http://127.0.0.1:{server_port}"
1019
+
1020
+ seq = 0
1021
+ retries = 0
1022
+ while True:
1023
+ req = urllib.request.Request(
1024
+ f"{base_url}/api/projects/{pid}/logs?since={seq}",
1025
+ headers={"Authorization": f"Bearer {token}"},
1026
+ )
1027
+ try:
1028
+ with urllib.request.urlopen(req, timeout=3) as resp:
1029
+ data = json.loads(resp.read().decode("utf-8"))
1030
+ retries = 0
1031
+ except Exception:
1032
+ if follow and retries < 3:
1033
+ retries += 1
1034
+ time.sleep(1.0)
1035
+ continue
1036
+ err.print(
1037
+ f"[yellow]No se pudo conectar con StackHelx en {base_url}. Asegúrate de que `stackhelx serve` está corriendo.[/]"
1038
+ )
1039
+ raise typer.Exit(1)
1040
+
1041
+ lines = data.get("lines", [])
1042
+ for item in lines:
1043
+ text = item.get("text", "")
1044
+ seq = max(seq, item.get("seq", seq))
1045
+ if not service or service in text:
1046
+ console.print(text)
1047
+
1048
+ if not follow:
1049
+ if not lines and seq == 0:
1050
+ console.print(f"[dim]No hay logs disponibles para {stack.name}.[/]")
1051
+ break
1052
+
1053
+ time.sleep(0.5)
1054
+
1055
+
1056
+ @app.command("stats")
1057
+ @app.command("top")
1058
+ def stats_cmd(
1059
+ target: str = typer.Argument(None, help="Ruta al proyecto"),
1060
+ server_port: int = typer.Option(7666, "--port", "-p", help="Puerto del servidor de StackHelx"),
1061
+ ) -> None:
1062
+ """Muestra el uso de CPU y memoria de los servicios en ejecución."""
1063
+ import urllib.request
1064
+
1065
+ path = (Path.cwd() / (target or "")).resolve()
1066
+ try:
1067
+ stack = detect.stack_for(path)
1068
+ pid = registry.project_id(stack.root)
1069
+ except config.ConfigError as exc:
1070
+ err.print(f"[red]Error:[/] {exc}")
1071
+ raise typer.Exit(1)
1072
+
1073
+ token = registry.token()
1074
+ base_url = f"http://127.0.0.1:{server_port}"
1075
+ req = urllib.request.Request(
1076
+ f"{base_url}/api/projects/{pid}/metrics",
1077
+ headers={"Authorization": f"Bearer {token}"},
1078
+ )
1079
+ try:
1080
+ with urllib.request.urlopen(req, timeout=3) as resp:
1081
+ data = json.loads(resp.read().decode("utf-8"))
1082
+ except Exception:
1083
+ err.print(
1084
+ f"[yellow]No se pudo conectar con StackHelx en {base_url}. Asegúrate de que `stackhelx serve` está corriendo.[/]"
1085
+ )
1086
+ raise typer.Exit(1)
1087
+
1088
+ metrics = data.get("metrics", {})
1089
+ if not metrics:
1090
+ console.print(f"[dim]No hay servicios activos en {stack.name}.[/]")
1091
+ return
1092
+
1093
+ table = Table(title=f"Métricas en tiempo real: {stack.name}")
1094
+ table.add_column("Servicio", style="cyan")
1095
+ table.add_column("PID", style="dim")
1096
+ table.add_column("CPU %", justify="right")
1097
+ table.add_column("Memoria (MB)", justify="right")
1098
+
1099
+ for name, s in metrics.items():
1100
+ cpu = f"{s.get('cpu_percent', 0.0)}%"
1101
+ mem = f"{s.get('memory_mb', 0.0)} MB"
1102
+ table.add_row(name, str(s.get("pid", "-")), cpu, mem)
1103
+
1104
+ console.print(table)
1105
+
1106
+
1107
+ @app.command("mcp-status")
1108
+ def mcp_status_cmd(
1109
+ server_port: int = typer.Option(7666, "--port", "-p", help="Puerto del servidor de StackHelx"),
1110
+ ) -> None:
1111
+ """Muestra la telemetría y estado de llamadas MCP para agentes IA."""
1112
+ import urllib.request
1113
+
1114
+ token = registry.token()
1115
+ base_url = f"http://127.0.0.1:{server_port}"
1116
+ req = urllib.request.Request(
1117
+ f"{base_url}/api/mcp/activity",
1118
+ headers={"Authorization": f"Bearer {token}"},
1119
+ )
1120
+ try:
1121
+ with urllib.request.urlopen(req, timeout=2) as resp:
1122
+ data = json.loads(resp.read().decode("utf-8"))
1123
+ except Exception:
1124
+ data = mcp.get_telemetry()
1125
+
1126
+ total = data.get("total_calls", 0)
1127
+ rate = data.get("active_rate_per_min", 0)
1128
+ max_rate = data.get("rate_limit_max", 30)
1129
+ console.print(
1130
+ f"[bold]Servidor MCP StackHelx[/] · Llamadas: [cyan]{total}[/] · Cuota: [green]{rate}/{max_rate}[/] req/min"
1131
+ )
1132
+
1133
+ by_tool = data.get("by_tool", {})
1134
+ if by_tool:
1135
+ tool_table = Table(title="Invocaciones por herramienta MCP")
1136
+ tool_table.add_column("Herramienta", style="cyan")
1137
+ tool_table.add_column("Llamadas", justify="right")
1138
+ for tool, count in sorted(by_tool.items(), key=lambda x: -x[1]):
1139
+ tool_table.add_row(tool, str(count))
1140
+ console.print(tool_table)
1141
+
1142
+ events = data.get("recent_events", [])
1143
+ if events:
1144
+ ev_table = Table(title="Invocaciones recientes (últimas 10)")
1145
+ ev_table.add_column("Timestamp", style="dim")
1146
+ ev_table.add_column("Herramienta", style="cyan")
1147
+ ev_table.add_column("Duración", justify="right")
1148
+ ev_table.add_column("Estado")
1149
+ for ev in events[:10]:
1150
+ st = ev.get("status", "ok")
1151
+ st_style = (
1152
+ "[green]ok[/]"
1153
+ if st == "ok"
1154
+ else (f"[yellow]{st}[/]" if st == "rate_limited" else f"[red]{st}[/]")
1155
+ )
1156
+ dur = f"{ev.get('duration_ms', 0):.1f} ms"
1157
+ ts = ev.get("timestamp", "").replace("T", " ")[:19]
1158
+ ev_table.add_row(ts, ev.get("tool", "-"), dur, st_style)
1159
+ console.print(ev_table)
1160
+ elif not by_tool:
1161
+ console.print("[dim]Sin actividad reciente de agentes MCP.[/]")
1162
+
1163
+
1164
+ if __name__ == "__main__":
1165
+ app()