aioli-cli 0.1.26__tar.gz → 0.1.28__tar.gz

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 (115) hide show
  1. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/PKG-INFO +1 -1
  2. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/pyproject.toml +9 -9
  3. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/fetch_ui.py +38 -4
  4. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/main.py +7 -2
  5. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/status.py +83 -12
  6. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/__init__.py +24 -0
  7. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/advise.py +149 -29
  8. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/fetch.py +741 -98
  9. aioli_cli-0.1.28/src/aioli_contract/readiness.py +252 -0
  10. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/companions.py +112 -50
  11. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/data/models.toml +1 -1
  12. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/models.py +82 -22
  13. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/models_data.py +1 -0
  14. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/presets.py +3 -2
  15. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/ops.py +59 -16
  16. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/minimax_comfy.py +8 -19
  17. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/process.py +1 -2
  18. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/stub.py +12 -5
  19. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/yue2.py +103 -17
  20. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/config_data/default.toml +4 -3
  21. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/formats.py +21 -10
  22. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/jobs.py +4 -8
  23. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/layout.py +3 -1
  24. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/doctor.py +13 -6
  25. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/history.py +1 -3
  26. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/models.py +10 -12
  27. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/paths.py +1 -4
  28. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/presets.py +1 -5
  29. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/transport/client.py +19 -28
  30. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/episode.py +2 -1
  31. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/script.py +3 -2
  32. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/cli.py +47 -2
  33. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/paths.py +3 -2
  34. aioli_cli-0.1.28/src/aioli_video/stub.py +51 -0
  35. aioli_cli-0.1.28/src/aioli_video/wgp.py +323 -0
  36. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/doctor.py +14 -4
  37. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/generate.py +2 -1
  38. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/devices.py +1 -3
  39. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/__init__.py +89 -9
  40. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/moss.py +4 -2
  41. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/qwen3.py +2 -1
  42. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/stub.py +1 -3
  43. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/voxcpm.py +2 -1
  44. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/xtts.py +2 -1
  45. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/models_data.py +12 -6
  46. aioli_cli-0.1.26/src/aioli_video/stub.py +0 -30
  47. aioli_cli-0.1.26/src/aioli_video/wgp.py +0 -136
  48. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/.gitignore +0 -0
  49. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/LICENSE +0 -0
  50. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/README.md +0 -0
  51. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/_plugins.py +0 -0
  52. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/registry.py +0 -0
  53. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/setup.py +0 -0
  54. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/config.py +0 -0
  55. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/doctor.py +0 -0
  56. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/probe.py +0 -0
  57. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/__init__.py +0 -0
  58. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/cli.py +0 -0
  59. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/config.py +0 -0
  60. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/doctor.py +0 -0
  61. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/gpu.py +0 -0
  62. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/launcher.py +0 -0
  63. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/plugin.py +0 -0
  64. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/stub.py +0 -0
  65. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/ui.py +0 -0
  66. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/cli.py +0 -0
  67. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/doctor.py +0 -0
  68. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/plugin.py +0 -0
  69. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/__init__.py +0 -0
  70. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/__init__.py +0 -0
  71. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/acestep.py +0 -0
  72. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/base.py +0 -0
  73. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/llm_caption.py +0 -0
  74. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/__init__.py +0 -0
  75. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/backend_cmd.py +0 -0
  76. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/generate_cmd.py +0 -0
  77. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
  78. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/models_cmd.py +0 -0
  79. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/client.py +0 -0
  80. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/config.py +0 -0
  81. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/__init__.py +0 -0
  82. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/generate.py +0 -0
  83. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/generator.py +0 -0
  84. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/jsonstore.py +0 -0
  85. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/models_data.py +0 -0
  86. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/plugin.py +0 -0
  87. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/prompts.py +0 -0
  88. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/server.py +0 -0
  89. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/transport/__init__.py +0 -0
  90. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/tui.py +0 -0
  91. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/cast.py +0 -0
  92. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/cli.py +0 -0
  93. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/doctor.py +0 -0
  94. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/plugin.py +0 -0
  95. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/doctor.py +0 -0
  96. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/models_data.py +0 -0
  97. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/plugin.py +0 -0
  98. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/worker.py +0 -0
  99. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/__init__.py +0 -0
  100. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/engines_cmd.py +0 -0
  101. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/models_cmd.py +0 -0
  102. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/profiles.py +0 -0
  103. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/config/__init__.py +0 -0
  104. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/config/settings.py +0 -0
  105. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/__init__.py +0 -0
  106. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/generation.py +0 -0
  107. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/model_manager.py +0 -0
  108. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/profiles.py +0 -0
  109. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/registry.py +0 -0
  110. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/types.py +0 -0
  111. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/doctor.py +0 -0
  112. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/chatterbox.py +0 -0
  113. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/kokoro.py +0 -0
  114. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/piper.py +0 -0
  115. {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/plugin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aioli-cli
3
- Version: 0.1.26
3
+ Version: 0.1.28
4
4
  Summary: aioli — local-first AI media suite CLI (images, video, music, voice, podcast)
5
5
  Author: aioli contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioli-cli"
3
- version = "0.1.26"
3
+ version = "0.1.28"
4
4
  description = "aioli — local-first AI media suite CLI (images, video, music, voice, podcast)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -117,6 +117,12 @@ addopts = "-q"
117
117
  dev = [
118
118
  "pytest>=8.0",
119
119
  "pillow>=10.0", # solo tests (stubs de imagen); nunca en dependencies
120
+ # H0.4: tooling de CI pineado aqui (no en `uvx` run-steps) para que
121
+ # dependabot lo renueve: no ve herramientas invocadas con `uvx` en los
122
+ # workflows. release.yml ejecuta estas tools con `uv run`.
123
+ "ruff==0.16.8",
124
+ "mypy==2.3.1",
125
+ "pip-audit==2.10.1",
120
126
  ]
121
127
 
122
128
  [tool.mypy]
@@ -175,18 +181,12 @@ exclude = [
175
181
  [tool.ruff.lint]
176
182
  select = ["E", "F", "W", "I", "UP", "B"]
177
183
  ignore = [
178
- # E501 lo posee el formatter; activarlo junto al formatter es ruido.
179
- "E501",
180
184
  # B008 es falso positivo sistemático: typer.Option/Argument en defaults
181
185
  # es la API documentada de Typer, no llamada mutable en default.
182
186
  "B008",
183
- # UP042 (str,Enum)->StrEnum cambia semántica de str()/format; migración
184
- # aparte, fuera del alcance de esta wave de lint.
185
- "UP042",
186
187
  ]
187
188
 
188
189
  [tool.ruff.format]
189
190
  # line-length se hereda de [tool.ruff] line-length = 100.
190
- # NOTA: `ruff format --check .` aún reporta ~73 ficheros sin formatear.
191
- # El reformateo masivo (~4900 líneas de diff) es tarea aparte: esta wave
192
- # solo configura el formatter y limpia el lint sin reescribir el código.
191
+ # H0 (2026-09-18): reformateo masivo aplicado (32 ficheros);
192
+ # `ruff format --check .` verde en CI.
@@ -11,11 +11,37 @@ from pathlib import Path
11
11
  from threading import Lock
12
12
 
13
13
  from rich.console import Console
14
- from rich.progress import Progress, TaskID
14
+ from rich.progress import (
15
+ BarColumn,
16
+ DownloadColumn,
17
+ Progress,
18
+ TaskID,
19
+ TaskProgressColumn,
20
+ TextColumn,
21
+ TimeElapsedColumn,
22
+ TransferSpeedColumn,
23
+ )
15
24
 
16
25
  console = Console()
17
26
 
18
27
 
28
+ def live_progress() -> Progress:
29
+ """Live bar for downloads: bytes + speed + elapsed, never a fake ETA.
30
+
31
+ H3.1: `TimeRemainingColumn` is deliberately absent. With an unknown
32
+ total rich would print a bogus countdown, so remaining time is never
33
+ shown — elapsed time and current speed are honest without a total.
34
+ """
35
+ return Progress(
36
+ TextColumn("{task.description}"),
37
+ BarColumn(),
38
+ TaskProgressColumn(),
39
+ DownloadColumn(),
40
+ TransferSpeedColumn(),
41
+ TimeElapsedColumn(),
42
+ )
43
+
44
+
19
45
  class _Bar:
20
46
  """Thread-safe fan-out: one rich task per file."""
21
47
 
@@ -25,13 +51,21 @@ class _Bar:
25
51
  self.lock = Lock()
26
52
 
27
53
  def update(self, path: Path, done_bytes: int, total_bytes: int) -> None:
54
+ # H3.1: total desconocido (0) = tarea indeterminada (total=None).
55
+ # El total=1 anterior fingía porcentajes absurdos (p.ej. 1638400%)
56
+ # con ETA. Cuando el total llega tarde se fija entonces.
28
57
  key = str(path)
29
58
  with self.lock:
30
59
  task = self.tasks.get(key)
31
60
  if task is None:
32
- task = self.progress.add_task(path.name, total=total_bytes or 1)
61
+ task = self.progress.add_task(
62
+ path.name, total=total_bytes if total_bytes > 0 else None
63
+ )
33
64
  self.tasks[key] = task
34
- self.progress.update(task, completed=done_bytes, total=total_bytes or 1)
65
+ if total_bytes > 0:
66
+ self.progress.update(task, completed=done_bytes, total=total_bytes)
67
+ else:
68
+ self.progress.update(task, completed=done_bytes)
35
69
 
36
70
 
37
71
  def download_many(
@@ -52,7 +86,7 @@ def download_many(
52
86
  failures: list[tuple[str, str]] = []
53
87
  flog: Lock = Lock()
54
88
 
55
- with Progress() as progress:
89
+ with live_progress() as progress:
56
90
  bar = _Bar(progress)
57
91
 
58
92
  def _one(entry: ModelFile) -> None:
@@ -93,11 +93,16 @@ def setup(
93
93
 
94
94
 
95
95
  @app.command()
96
- def status() -> None:
96
+ def status(
97
+ quick: bool = typer.Option(False, "--quick", help="Nivel quick: presencia+tamaño."),
98
+ full: bool = typer.Option(False, "--full", help="Nivel full: sha256/recibo re-hasheado."),
99
+ ) -> None:
97
100
  """Límites del equipo + qué motores sirven, faltan y cómo arreglarlos."""
98
101
  from aioli_cli.status import run_status
99
102
 
100
- run_status()
103
+ if quick and full:
104
+ raise typer.BadParameter("--quick y --full son excluyentes")
105
+ run_status(level="full" if full else "quick")
101
106
 
102
107
 
103
108
  @app.command()
@@ -8,7 +8,19 @@ from rich.table import Table
8
8
  console = Console()
9
9
 
10
10
 
11
- def run_status() -> None:
11
+ def run_status(level: str = "quick") -> None:
12
+ """Niveles H1 (D08): quick / full / probado por separado.
13
+
14
+ - `quick` (defecto): presencia + tamano, sin re-hashear GBs. Nunca
15
+ vende "listo/certificado": lo presente se marca como presente.
16
+ - `full`: sha256/recibo re-hasheado; coincide con `setup` sin
17
+ descargas pendientes y con `models verify`.
18
+ - `probado`: solo inferencia real; status nunca lo afirma, solo
19
+ muestra la evidencia de `probe.json` cuando existe.
20
+ """
21
+ if level not in ("quick", "full"):
22
+ raise ValueError(f"nivel desconocido: {level!r} (válidos: quick, full)")
23
+ quick = level == "quick"
12
24
  from aioli_cli import _plugins # noqa: PLC0415
13
25
  from aioli_cli.registry import registry # noqa: PLC0415
14
26
  from aioli_cli.setup import machine_panel # noqa: PLC0415
@@ -34,6 +46,19 @@ def run_status() -> None:
34
46
  console.print()
35
47
  console.print(machine_panel(machine))
36
48
  console.print()
49
+ if quick:
50
+ console.print(
51
+ "[dim]nivel quick: presencia+tamaño (sin re-hashear). "
52
+ "No certifica integridad ni inferencia; "
53
+ "`aioli status --full` re-hashea (sha256/recibo).[/]"
54
+ )
55
+ else:
56
+ console.print(
57
+ "[dim]nivel full: sha256/recibo re-hasheado "
58
+ "(coincide con `setup` y `models verify`). "
59
+ "Probado exige inferencia real (sección probado).[/]"
60
+ )
61
+ console.print()
37
62
 
38
63
  table = Table(title="¿Qué puedo usar?")
39
64
  table.add_column("plugin")
@@ -56,7 +81,7 @@ def run_status() -> None:
56
81
  runtimes_plat: list[ModelFile] = [] # plataforma actual: ¿hay motor usable?
57
82
 
58
83
  def _ok_any(entry) -> bool:
59
- return any(verify(entry, root, bin_dir, quick=True) == "ok" for root in _roots)
84
+ return any(verify(entry, root, bin_dir, quick=quick) == "ok" for root in _roots)
60
85
 
61
86
  for raw in p.models():
62
87
  e = ModelFile(**raw)
@@ -111,15 +136,26 @@ def run_status() -> None:
111
136
  )
112
137
  nofit.append(f"{tag} — {v.headline}")
113
138
  elif is_ready:
114
- table.add_row(
115
- p.name,
116
- engine,
117
- f"{v.icon} cabe",
118
- f"{ok_n}/{len(core)}",
119
- "—",
120
- f"aioli {p.name} … (listo)",
121
- )
122
- ready.append(tag)
139
+ if quick:
140
+ table.add_row(
141
+ p.name,
142
+ engine,
143
+ f"{v.icon} cabe",
144
+ f"{ok_n}/{len(core)}",
145
+ "—",
146
+ f"aioli {p.name} … (presente quick, confirma con --full)",
147
+ )
148
+ ready.append(f"{tag} (presente quick, sin verificar)")
149
+ else:
150
+ table.add_row(
151
+ p.name,
152
+ engine,
153
+ f"{v.icon} cabe",
154
+ f"{ok_n}/{len(core)}",
155
+ "—",
156
+ f"aioli {p.name} … (listo, verificado full)",
157
+ )
158
+ ready.append(tag)
123
159
  elif gaps:
124
160
  if "motor piper (binario)" in gaps:
125
161
  fix = "uv tool install piper-tts (o exporta $PIPER_BIN)"
@@ -140,8 +176,18 @@ def run_status() -> None:
140
176
  (licensed if _needs_license(entries) else easy).append(f"{tag} → {fix}")
141
177
  console.print(table)
142
178
  console.print()
179
+ probado = _probado_plugins()
180
+ if probado:
181
+ console.print("[bold]probado (inferencia real en este equipo):[/]")
182
+ for line in sorted(probado):
183
+ console.print(f"🧪 probado: [bold]{line}[/]")
184
+ else:
185
+ console.print("[dim]probado: sin evidencia de inferencia en este equipo;[/]")
186
+ console.print("[dim]solo una inferencia real marca probado (quick/full no bastan).[/]")
187
+ console.print()
188
+ ready_icon = "✅ presente (quick)" if quick else "✅ listo (full)"
143
189
  for line in ready:
144
- console.print(f"✅ listo: [bold]{line}[/]")
190
+ console.print(f"{ready_icon}: [bold]{line}[/]")
145
191
  for line in easy:
146
192
  console.print(f"⬇️ instalable fácil: [bold]{line}[/]")
147
193
  for line in licensed:
@@ -151,6 +197,31 @@ def run_status() -> None:
151
197
  console.print()
152
198
 
153
199
 
200
+ def _probado_plugins() -> set[str]:
201
+ """Plugins con al menos una inferencia que cupo en ESTE equipo.
202
+
203
+ Lee `probe.json` (solo lectura); vacío o ausente = sin evidencia.
204
+ """
205
+ try:
206
+ from aioli_contract.probe import hardware_fingerprint, load_store # noqa: PLC0415
207
+
208
+ store = load_store()
209
+ hw = hardware_fingerprint()
210
+ out: set[str] = set()
211
+ plugins = store.get(hw, {})
212
+ for plugin, models in plugins.items():
213
+ if not isinstance(models, dict):
214
+ continue
215
+ for modes in models.values():
216
+ if not isinstance(modes, dict):
217
+ continue
218
+ if any(isinstance(m, dict) and m.get("fits") for m in modes.values()):
219
+ out.add(plugin)
220
+ return out
221
+ except Exception: # noqa: BLE001 — sin evidencia: no probado, sin romper status
222
+ return set()
223
+
224
+
154
225
  def _piper_binary() -> bool:
155
226
  """El MISMO criterio que el motor en ejecución (A05).
156
227
 
@@ -16,6 +16,19 @@ from aioli_contract.fetch import (
16
16
  smoke_runtime,
17
17
  verify,
18
18
  )
19
+ from aioli_contract.readiness import (
20
+ BackendSpec,
21
+ ExecutionPlan,
22
+ ProbeOutcome,
23
+ Readiness,
24
+ check_abi_ldd,
25
+ check_exec_bit,
26
+ check_sdk,
27
+ evaluate_readiness,
28
+ probe_runtime,
29
+ rank,
30
+ resolve_backend,
31
+ )
19
32
 
20
33
  __all__ = [
21
34
  "AioliConfig",
@@ -34,6 +47,17 @@ __all__ = [
34
47
  "human_gb",
35
48
  "judge",
36
49
  "read_machine",
50
+ "BackendSpec",
51
+ "ExecutionPlan",
52
+ "ProbeOutcome",
53
+ "Readiness",
54
+ "check_abi_ldd",
55
+ "check_exec_bit",
56
+ "check_sdk",
57
+ "evaluate_readiness",
58
+ "probe_runtime",
59
+ "rank",
60
+ "resolve_backend",
37
61
  "run_checks",
38
62
  "sha256_of",
39
63
  "smoke_runtime",
@@ -22,13 +22,33 @@ class GPU:
22
22
  name: str
23
23
  total_gb: float
24
24
  free_gb: float
25
+ uuid: str = ""
26
+ index: int = 0
25
27
 
26
28
 
27
29
  @dataclass
28
30
  class Machine:
31
+ """Snapshot de hardware de UNA invocacion (H4.1, D15).
32
+
33
+ Un snapshot por comando: `read_machine()` escanea una vez por proceso
34
+ (cache explicita, ver `invalidate_machine_cache`) y el MISMO objeto se
35
+ comparte entre todos los checks del comando en lugar de re-escanear
36
+ por plugin.
37
+
38
+ Convencion desconocido != 0:
39
+ - `gpus == []` = sin NVIDIA medible (desconocido), NO "0 GPUs de 0 GB".
40
+ - `free_gb == 0.0` en una GPU listada ES un dato valido (GPU ocupada).
41
+ - `ram_total_gb/ram_available_gb is None` = no medible; `ram_gb == 0.0`
42
+ es solo el alias legacy de ese desconocido.
43
+ - `cuda_visible_devices is None` = variable ausente (todo visible).
44
+ """
45
+
29
46
  gpus: list[GPU] = field(default_factory=list)
30
47
  ram_gb: float = 0.0
31
48
  disk_free_gb: float = 0.0
49
+ ram_total_gb: float | None = None
50
+ ram_available_gb: float | None = None
51
+ cuda_visible_devices: str | None = None
32
52
 
33
53
  @property
34
54
  def best_vram_gb(self) -> float:
@@ -117,29 +137,100 @@ REQUIREMENTS: dict[tuple[str, str], Requirement] = {
117
137
  }
118
138
 
119
139
 
120
- def read_machine(models_dir: Path | None = None) -> Machine:
121
- gpus: list[GPU] = []
140
+ _MACHINE_CACHE: dict[str, Machine] = {}
141
+
142
+
143
+ def invalidate_machine_cache() -> None:
144
+ """Invalidacion explicita del snapshot (H4.1).
145
+
146
+ La cache vive solo en el proceso actual: un comando CLI escanea una
147
+ vez y comparte el objeto; los tests o un cambio de GPU fuerzan con
148
+ `force_refresh=True` o llamando aqui.
149
+ """
150
+ _MACHINE_CACHE.clear()
151
+
152
+
153
+ def _cache_key(models_dir: Path | None) -> str:
122
154
  try:
123
- out = subprocess.run(
124
- ["nvidia-smi", "--query-gpu=name,memory.total,memory.free",
125
- "--format=csv,noheader,nounits"],
126
- capture_output=True, text=True, timeout=10,
127
- )
128
- if out.returncode == 0:
129
- for line in out.stdout.strip().splitlines():
130
- name, total, free = [p.strip() for p in line.split(",")]
131
- gpus.append(GPU(name, int(total) / 1024, int(free) / 1024))
132
- except Exception: # noqa: BLE001 — no NVIDIA, CPU life
133
- pass
134
- ram_gb = 0.0
155
+ target = models_dir or Path.home()
156
+ return str(target)
157
+ except Exception: # noqa: BLE001 — clave best-effort, nunca rompe el scan
158
+ return ""
159
+
160
+
161
+ def _query_gpus() -> list[GPU]:
162
+ """Un unico `nvidia-smi` con UUID; fallback al query legacy sin UUID."""
163
+ queries = [
164
+ [
165
+ "nvidia-smi",
166
+ "--query-gpu=name,uuid,memory.total,memory.free",
167
+ "--format=csv,noheader,nounits",
168
+ ],
169
+ [
170
+ "nvidia-smi",
171
+ "--query-gpu=name,memory.total,memory.free",
172
+ "--format=csv,noheader,nounits",
173
+ ],
174
+ ]
175
+ for argv in queries:
176
+ try:
177
+ out = subprocess.run(argv, capture_output=True, text=True, timeout=10)
178
+ except Exception: # noqa: BLE001 — no NVIDIA, CPU life
179
+ return []
180
+ if out.returncode != 0 or not out.stdout.strip():
181
+ continue
182
+ gpus: list[GPU] = []
183
+ try:
184
+ for idx, line in enumerate(out.stdout.strip().splitlines()):
185
+ parts = [s.strip() for s in line.split(",")]
186
+ if len(parts) == 4:
187
+ name, uuid, total, free = parts
188
+ elif len(parts) == 3:
189
+ name, total, free = parts
190
+ uuid = ""
191
+ else:
192
+ continue
193
+ gpus.append(GPU(name, int(total) / 1024, int(free) / 1024, uuid, idx))
194
+ except (ValueError, TypeError):
195
+ continue
196
+ return gpus
197
+ return []
198
+
199
+
200
+ def _read_ram() -> tuple[float | None, float | None]:
201
+ """(total_GiB, disponible_GiB); (None, None) si no medible."""
202
+ total: float | None = None
203
+ avail: float | None = None
135
204
  try:
136
205
  with Path("/proc/meminfo").open() as fh:
137
206
  for line in fh:
138
207
  if line.startswith("MemTotal:"):
139
- ram_gb = int(line.split()[1]) / 1024 / 1024
208
+ total = int(line.split()[1]) / 1024 / 1024
209
+ elif line.startswith("MemAvailable:"):
210
+ avail = int(line.split()[1]) / 1024 / 1024
211
+ if total is not None and avail is not None:
140
212
  break
141
213
  except OSError:
142
- pass
214
+ return None, None
215
+ return total, avail
216
+
217
+
218
+ def read_machine(models_dir: Path | None = None, *, force_refresh: bool = False) -> Machine:
219
+ """Snapshot unico de hardware por comando (H4.1).
220
+
221
+ Escanea una vez por proceso (un `nvidia-smi` + `/proc/meminfo` +
222
+ `shutil.disk_usage`) y devuelve SIEMPRE el mismo objeto mientras la
223
+ cache viva: todos los `judge()` del comando comparten este snapshot.
224
+ `force_refresh=True` o `invalidate_machine_cache()` rescanean.
225
+ """
226
+ import os as _os
227
+
228
+ key = _cache_key(models_dir)
229
+ if not force_refresh and key in _MACHINE_CACHE:
230
+ return _MACHINE_CACHE[key]
231
+ gpus = _query_gpus()
232
+ ram_total, ram_avail = _read_ram()
233
+ cuda_env = _os.environ.get("CUDA_VISIBLE_DEVICES", "").strip() or None
143
234
  disk = 0.0
144
235
  try:
145
236
  target = models_dir or Path.home()
@@ -148,7 +239,20 @@ def read_machine(models_dir: Path | None = None) -> Machine:
148
239
  disk = shutil.disk_usage(target).free / 1024**3
149
240
  except OSError:
150
241
  pass
151
- return Machine(gpus=gpus, ram_gb=ram_gb, disk_free_gb=disk)
242
+ machine = Machine(
243
+ gpus=gpus,
244
+ ram_gb=ram_total if ram_total is not None else 0.0,
245
+ disk_free_gb=disk,
246
+ ram_total_gb=ram_total,
247
+ ram_available_gb=ram_avail,
248
+ cuda_visible_devices=cuda_env,
249
+ )
250
+ _MACHINE_CACHE[key] = machine
251
+ return machine
252
+
253
+
254
+ #: Alias de contrato H4.1: el snapshot ES la Machine compartida.
255
+ MachineSnapshot = Machine
152
256
 
153
257
 
154
258
  @dataclass
@@ -165,7 +269,8 @@ def judge(plugin: str, backend: str, machine: Machine) -> Verdict:
165
269
  # R09: motor sin datos NO es "cabe" — es desconocido. Setup no debe
166
270
  # descargar GB a ciegas y status no debe prometer lo no probado.
167
271
  return Verdict(
168
- False, "❔",
272
+ False,
273
+ "❔",
169
274
  f"{plugin}/{backend}: sin datos de requisitos, no se instala a ciegas.",
170
275
  "Pruébalo a mano y comparte resultados para añadirlo a la tabla.",
171
276
  )
@@ -174,7 +279,8 @@ def judge(plugin: str, backend: str, machine: Machine) -> Verdict:
174
279
  gpu = machine.effective_gpu
175
280
  if gpu is None:
176
281
  return Verdict(
177
- False, "❌",
282
+ False,
283
+ "❌",
178
284
  f"{plugin}/{backend} necesita GPU con {req.min_vram_gb:.0f} GB y aquí no hay NVIDIA.",
179
285
  f"Prueba {req.fallback}… en otra máquina, o quédate con voice/piper.",
180
286
  )
@@ -182,24 +288,38 @@ def judge(plugin: str, backend: str, machine: Machine) -> Verdict:
182
288
  free = gpu.free_gb
183
289
  # 0 GB libres es un dato válido (GPU ocupada), no "desconocido": con
184
290
  # memoria totalmente ocupada no hay veredicto verde.
185
- busy = (f"Ahora mismo solo hay {free:.0f} GB libres en GPU ({gpu.name}); "
186
- "cierra lo que la esté usando."
187
- if free + 2 < req.min_vram_gb else "")
188
- disk_warn = (f"Y ojo al disco: {machine.disk_free_gb:.0f} GB libres para "
189
- f"{req.disk_gb:.0f} GB de pesos."
190
- if machine.disk_free_gb and machine.disk_free_gb < req.disk_gb + 10 else "")
191
- ram_warn = (f"RAM justa: {machine.ram_gb:.0f} GB (el offload tira de RAM)."
192
- if machine.ram_gb and machine.ram_gb < 8 else "")
291
+ busy = (
292
+ f"Ahora mismo solo hay {free:.0f} GB libres en GPU ({gpu.name}); "
293
+ "cierra lo que la esté usando."
294
+ if free + 2 < req.min_vram_gb
295
+ else ""
296
+ )
297
+ disk_warn = (
298
+ f"Y ojo al disco: {machine.disk_free_gb:.0f} GB libres para {req.disk_gb:.0f} GB de pesos."
299
+ if machine.disk_free_gb and machine.disk_free_gb < req.disk_gb + 10
300
+ else ""
301
+ )
302
+ ram_warn = (
303
+ f"RAM justa: {machine.ram_gb:.0f} GB (el offload tira de RAM)."
304
+ if machine.ram_gb and machine.ram_gb < 8
305
+ else ""
306
+ )
193
307
  if vram >= req.min_vram_gb + 4:
194
308
  if busy or disk_warn or ram_warn:
195
309
  detail = " ".join(x for x in (busy, disk_warn, ram_warn) if x)
196
- return Verdict(True, "⚠️", f"{plugin}/{backend}: cabe con {vram:.0f} GB… si liberas. {req.blurb}", detail)
310
+ return Verdict(
311
+ True,
312
+ "⚠️",
313
+ f"{plugin}/{backend}: cabe con {vram:.0f} GB… si liberas. {req.blurb}",
314
+ detail,
315
+ )
197
316
  return Verdict(True, "✅", f"{plugin}/{backend}: sobrado con {vram:.0f} GB. {req.blurb}")
198
317
  if vram >= req.min_vram_gb:
199
318
  detail = " ".join(x for x in (req.risk, busy, disk_warn, ram_warn) if x)
200
319
  return Verdict(True, "⚠️", f"{plugin}/{backend}: cabe justo con {vram:.0f} GB.", detail)
201
320
  return Verdict(
202
- False, "❌",
321
+ False,
322
+ "❌",
203
323
  f"{plugin}/{backend} pide {req.min_vram_gb:.0f} GB y tienes {vram:.0f} GB.",
204
324
  f"Vete a {req.fallback}." if req.fallback else "Ese motor no es para este PC.",
205
325
  )