aioli-cli 0.1.28__tar.gz → 0.1.30__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.28 → aioli_cli-0.1.30}/PKG-INFO +1 -1
  2. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/pyproject.toml +1 -1
  3. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_cli/main.py +61 -1
  4. aioli_cli-0.1.30/src/aioli_cli/readiness.py +347 -0
  5. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_cli/setup.py +107 -4
  6. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_cli/status.py +59 -7
  7. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/__init__.py +10 -1
  8. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/readiness.py +28 -2
  9. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/data/models.toml +7 -5
  10. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/doctor.py +11 -4
  11. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/launcher.py +5 -3
  12. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/models_data.py +3 -3
  13. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/cli_commands/generate_cmd.py +36 -7
  14. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/core/generate.py +215 -32
  15. aioli_cli-0.1.30/src/aioli_music/doctor.py +121 -0
  16. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/models_data.py +3 -3
  17. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/transport/client.py +123 -31
  18. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/cli.py +4 -1
  19. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/doctor.py +17 -2
  20. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/cli/doctor.py +19 -6
  21. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/piper.py +3 -1
  22. aioli_cli-0.1.28/src/aioli_music/doctor.py +0 -65
  23. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/.gitignore +0 -0
  24. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/LICENSE +0 -0
  25. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/README.md +0 -0
  26. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_cli/_plugins.py +0 -0
  27. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_cli/fetch_ui.py +0 -0
  28. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_cli/registry.py +0 -0
  29. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/advise.py +0 -0
  30. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/config.py +0 -0
  31. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/doctor.py +0 -0
  32. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/fetch.py +0 -0
  33. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_contract/probe.py +0 -0
  34. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/__init__.py +0 -0
  35. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/cli.py +0 -0
  36. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/companions.py +0 -0
  37. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/config.py +0 -0
  38. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/gpu.py +0 -0
  39. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/models.py +0 -0
  40. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/plugin.py +0 -0
  41. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/presets.py +0 -0
  42. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/stub.py +0 -0
  43. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_image/ui.py +0 -0
  44. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_master/cli.py +0 -0
  45. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_master/doctor.py +0 -0
  46. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_master/ops.py +0 -0
  47. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_master/plugin.py +0 -0
  48. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/__init__.py +0 -0
  49. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/__init__.py +0 -0
  50. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/acestep.py +0 -0
  51. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/base.py +0 -0
  52. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/llm_caption.py +0 -0
  53. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/minimax_comfy.py +0 -0
  54. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/process.py +0 -0
  55. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/stub.py +0 -0
  56. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/backends/yue2.py +0 -0
  57. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/cli_commands/__init__.py +0 -0
  58. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/cli_commands/backend_cmd.py +0 -0
  59. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
  60. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/cli_commands/models_cmd.py +0 -0
  61. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/client.py +0 -0
  62. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/config.py +0 -0
  63. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/config_data/default.toml +0 -0
  64. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/core/__init__.py +0 -0
  65. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/core/formats.py +0 -0
  66. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/core/jobs.py +0 -0
  67. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/core/layout.py +0 -0
  68. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/generator.py +0 -0
  69. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/history.py +0 -0
  70. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/jsonstore.py +0 -0
  71. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/models.py +0 -0
  72. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/paths.py +0 -0
  73. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/plugin.py +0 -0
  74. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/presets.py +0 -0
  75. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/prompts.py +0 -0
  76. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/server.py +0 -0
  77. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/transport/__init__.py +0 -0
  78. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_music/tui.py +0 -0
  79. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_podcast/cast.py +0 -0
  80. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_podcast/cli.py +0 -0
  81. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_podcast/doctor.py +0 -0
  82. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_podcast/episode.py +0 -0
  83. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_podcast/plugin.py +0 -0
  84. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_podcast/script.py +0 -0
  85. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/models_data.py +0 -0
  86. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/paths.py +0 -0
  87. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/plugin.py +0 -0
  88. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/stub.py +0 -0
  89. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/wgp.py +0 -0
  90. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_video/worker.py +0 -0
  91. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/__init__.py +0 -0
  92. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/cli/engines_cmd.py +0 -0
  93. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/cli/generate.py +0 -0
  94. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/cli/models_cmd.py +0 -0
  95. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/cli/profiles.py +0 -0
  96. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/config/__init__.py +0 -0
  97. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/config/settings.py +0 -0
  98. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/core/__init__.py +0 -0
  99. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/core/generation.py +0 -0
  100. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/core/model_manager.py +0 -0
  101. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/core/profiles.py +0 -0
  102. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/core/registry.py +0 -0
  103. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/core/types.py +0 -0
  104. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/devices.py +0 -0
  105. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/doctor.py +0 -0
  106. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/__init__.py +0 -0
  107. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/chatterbox.py +0 -0
  108. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/kokoro.py +0 -0
  109. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/moss.py +0 -0
  110. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/qwen3.py +0 -0
  111. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/stub.py +0 -0
  112. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/voxcpm.py +0 -0
  113. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/engines/xtts.py +0 -0
  114. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/src/aioli_voice/models_data.py +0 -0
  115. {aioli_cli-0.1.28 → aioli_cli-0.1.30}/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.28
3
+ Version: 0.1.30
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.28"
3
+ version = "0.1.30"
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"
@@ -50,8 +50,14 @@ def _root(
50
50
  def doctor() -> None:
51
51
  """Preflight: python, ffmpeg, CUDA. Plugins append their own checks.
52
52
 
53
+ V3: además emite la lectura unificada por backend (misma que
54
+ `status` y `setup --check`): `plugin/backend → capacidad/probado`
55
+ es OK; el resto es fallo con el nivel como detalle.
53
56
  Exit 1 si algún check falla (usable en scripts/CI).
54
57
  """
58
+ from aioli_cli import _plugins as _plugmod # noqa: PLC0415
59
+
60
+ _plugmod.load_all()
55
61
  cfg = AioliConfig.load()
56
62
  table = Table(title="aioli doctor")
57
63
  table.add_column("check")
@@ -71,6 +77,57 @@ def doctor() -> None:
71
77
  check.hint,
72
78
  )
73
79
  ok = ok and check.ok
80
+ try:
81
+ from aioli_cli.readiness import doctor_checks_for, spec_for # noqa: PLC0415
82
+ from aioli_contract import ModelFile, default_bin_dir, default_models_dir # noqa: PLC0415
83
+ from aioli_contract.advise import read_machine # noqa: PLC0415
84
+
85
+ _md, _bd = default_models_dir(), default_bin_dir()
86
+ _machine = read_machine(_md)
87
+ for plugin in registry.all():
88
+ if plugin.models is None:
89
+ continue
90
+ grouped: dict[str, list] = {}
91
+ for raw in plugin.models():
92
+ entry = ModelFile(**raw)
93
+ if entry.optional:
94
+ continue
95
+ if entry.platform:
96
+ import sys as _sys
97
+
98
+ if entry.platform != _sys.platform:
99
+ continue
100
+ if entry.kind == "runtime":
101
+ continue
102
+ grouped.setdefault(entry.backend or "-", []).append(entry)
103
+ runtimes = []
104
+ for raw in plugin.models():
105
+ entry = ModelFile(**raw)
106
+ if entry.kind != "runtime" or entry.optional:
107
+ continue
108
+ if entry.platform:
109
+ import sys as _sys2
110
+
111
+ if entry.platform != _sys2.platform:
112
+ continue
113
+ runtimes.append(entry)
114
+ for backend, entries in sorted(grouped.items()):
115
+ if backend == "-":
116
+ continue
117
+ try:
118
+ spec = spec_for(plugin.name, backend)
119
+ except KeyError:
120
+ continue
121
+ files = [e for e in runtimes if e.backend in ("", backend)] + entries
122
+ for check in doctor_checks_for(spec, files, _md, _bd, _machine):
123
+ table.add_row(
124
+ f"readiness/{check.name}",
125
+ "✅" if check.ok else "❌",
126
+ check.hint,
127
+ )
128
+ ok = ok and check.ok
129
+ except (ImportError, OSError, RuntimeError):
130
+ pass
74
131
  table.add_row("models_dir", "📁", str(cfg.models_dir))
75
132
  table.add_row("plugins", "🔌", ", ".join(registry.names()) or "(none yet)")
76
133
  console.print(table)
@@ -85,11 +142,14 @@ def setup(
85
142
  None, "--plugins", "-p", help="Solo estos plugins (coma): image,video,music,voice."
86
143
  ),
87
144
  ask: bool = typer.Option(False, "--ask", help="Pregunta qué instalar (por defecto: auto)."),
145
+ check: bool = typer.Option(
146
+ False, "--check", help="Solo muestra la lectura (misma que status) sin descargar."
147
+ ),
88
148
  ) -> None:
89
149
  """Asistente de primera instalación: equipo → consejo → descarga."""
90
150
  from aioli_cli.setup import run_setup
91
151
 
92
- run_setup(yes=yes, plugins=plugins, ask=ask)
152
+ run_setup(yes=yes, plugins=plugins, ask=ask, check=check)
93
153
 
94
154
 
95
155
  @app.command()
@@ -0,0 +1,347 @@
1
+ """Evaluación readiness unificada V3 (D23): una sola lectura para status/doctor/setup.
2
+
3
+ Consume ``BackendSpec``/``ExecutionPlan``/``Readiness`` de
4
+ ``aioli_contract.readiness`` sin duplicar detectores: las tres superficies
5
+ llaman aquí con el MISMO input y obtienen la MISMA lectura.
6
+
7
+ Mapa de detectores duplicados eliminados (V3.1):
8
+ - ``status._piper_binary`` → :func:`motor_probe` (voice/piper).
9
+ - ``setup._piper_ready`` → :func:`motor_probe` (voice/piper).
10
+ - ``image.launcher.resolve_sd_cli`` + ``image.doctor`` (exists sin
11
+ bit-X) → :func:`motor_probe` (image/*, exige is_file + bit X).
12
+ - ``music.paths.ace_server_binary`` + ``music.doctor`` (exists sin
13
+ probe unificado) → :func:`motor_probe` (music/*).
14
+ - ``video.wgp.resolve_*`` + ``video.doctor`` (exists/which bool) →
15
+ :func:`motor_probe` (video/*, directorio nunca vale).
16
+ - ``voice.cli.doctor._check_extras`` (which/find_spec bool) →
17
+ :func:`motor_probe` (voice/* SDK/binario, SDK faltante es desconocido).
18
+ - ``contract.doctor`` baseline (python/ffmpeg/cuda) se mantiene como
19
+ preflight genérico; la lectura por backend vive aquí.
20
+
21
+ Sin cambios de red/descarga: este módulo solo LEE (verify/quick/full,
22
+ stat del bit X, resolve sin ejecutar salvo ``run_probe=True`` explícito).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+
29
+ from aioli_contract.doctor import Check
30
+ from aioli_contract.readiness import (
31
+ BackendSpec,
32
+ ExecutionPlan,
33
+ ProbeOutcome,
34
+ Readiness,
35
+ check_exec_bit,
36
+ check_sdk,
37
+ probe_runtime,
38
+ rank,
39
+ resolve_backend,
40
+ )
41
+
42
+ __all__ = [
43
+ "BACKEND_SPECS",
44
+ "build_execution_plan",
45
+ "doctor_checks_for",
46
+ "evaluate_plugin",
47
+ "is_ready",
48
+ "motor_probe",
49
+ "spec_for",
50
+ ]
51
+
52
+ BACKEND_SPECS: dict[tuple[str, str], BackendSpec] = {
53
+ ("image", "flux-klein"): BackendSpec(
54
+ plugin="image",
55
+ backend="flux-klein",
56
+ kind="binary",
57
+ binary_env="IMAGE_SD_CLI_BIN",
58
+ binary_names=("sd-cli",),
59
+ ),
60
+ ("image", "flux-klein-4b"): BackendSpec(
61
+ plugin="image",
62
+ backend="flux-klein-4b",
63
+ kind="binary",
64
+ binary_env="IMAGE_SD_CLI_BIN",
65
+ binary_names=("sd-cli",),
66
+ ),
67
+ ("music", "acestep"): BackendSpec(
68
+ plugin="music",
69
+ backend="acestep",
70
+ kind="binary",
71
+ binary_names=("ace-server",),
72
+ ),
73
+ ("music", "yue2"): BackendSpec(
74
+ plugin="music",
75
+ backend="yue2",
76
+ kind="binary",
77
+ binary_names=("audiocpp_cli",),
78
+ ),
79
+ ("video", "ltx25"): BackendSpec(
80
+ plugin="video",
81
+ backend="ltx25",
82
+ kind="binary",
83
+ binary_env="VIDEO_PYTHON",
84
+ ),
85
+ ("voice", "piper"): BackendSpec(
86
+ plugin="voice",
87
+ backend="piper",
88
+ kind="binary",
89
+ binary_env="PIPER_BIN",
90
+ binary_names=("piper",),
91
+ ),
92
+ ("voice", "kokoro"): BackendSpec(
93
+ plugin="voice",
94
+ backend="kokoro",
95
+ kind="sdk",
96
+ sdk_import="kokoro_onnx",
97
+ extra_name="voice-kokoro",
98
+ ),
99
+ }
100
+
101
+
102
+ def spec_for(plugin: str, backend: str) -> BackendSpec:
103
+ """Spec unificado por backend (estable V3: tabla cerrada, KeyError si falta)."""
104
+ try:
105
+ return BACKEND_SPECS[(plugin, backend)]
106
+ except KeyError:
107
+ raise KeyError(f"backend sin spec unificado: {plugin}/{backend}") from None
108
+
109
+
110
+ def _managed_candidates(spec: BackendSpec, bin_dir: Path | None) -> list[Path]:
111
+ """Runtimes gestionados por `setup` en bin_dir (sin ejecutar nada)."""
112
+ if bin_dir is None:
113
+ return []
114
+ table: dict[tuple[str, str], list[str]] = {
115
+ ("image", "flux-klein"): ["sd-cli/sd-cli", "sd-cli"],
116
+ ("image", "flux-klein-4b"): ["sd-cli/sd-cli", "sd-cli"],
117
+ ("music", "acestep"): ["ace-server/ace-server", "ace-server"],
118
+ ("music", "yue2"): ["audiocpp/audiocpp_cli", "audiocpp_cli"],
119
+ }
120
+ return [bin_dir / rel for rel in table.get((spec.plugin, spec.backend), [])]
121
+
122
+
123
+ def _legacy_candidates(spec: BackendSpec) -> list[Path]:
124
+ """Resolutores legacy de plugins (solo lectura, envueltos con bit-X).
125
+
126
+ Reutiliza los resolvers existentes sin duplicar su lógica de rutas;
127
+ la ESTRICTEZ (is_file + bit X, directorio nunca vale) la pone el
128
+ contrato vía :func:`check_exec_bit`.
129
+ """
130
+ out: list[Path] = []
131
+ try:
132
+ if spec.plugin == "image":
133
+ from aioli_image.launcher import resolve_sd_cli # noqa: PLC0415
134
+
135
+ try:
136
+ out.append(resolve_sd_cli())
137
+ except (FileNotFoundError, OSError, RuntimeError, SystemExit):
138
+ pass
139
+ elif spec.plugin == "music" and spec.backend == "acestep":
140
+ from aioli_music.paths import ace_server_binary # noqa: PLC0415
141
+
142
+ try:
143
+ out.append(ace_server_binary())
144
+ except (OSError, RuntimeError):
145
+ pass
146
+ elif spec.plugin == "music" and spec.backend == "yue2":
147
+ try:
148
+ from aioli_music.backends.yue2 import _cli as _yue2_cli # noqa: PLC0415
149
+ from aioli_music.config import load_config # noqa: PLC0415
150
+
151
+ try:
152
+ out.append(_yue2_cli(load_config()))
153
+ except (OSError, RuntimeError, ValueError):
154
+ pass
155
+ except (ImportError, OSError):
156
+ pass
157
+ elif spec.plugin == "video":
158
+ from aioli_video.wgp import resolve_python # noqa: PLC0415
159
+
160
+ try:
161
+ out.append(resolve_python())
162
+ except (OSError, RuntimeError):
163
+ pass
164
+ except ImportError:
165
+ pass
166
+ return out
167
+
168
+
169
+ def motor_probe(
170
+ spec: BackendSpec, bin_dir: Path | None, *, run_probe: bool = False, timeout: int = 10
171
+ ) -> ProbeOutcome:
172
+ """Motor presente+ejecutable con la MISMA vara en las tres superficies.
173
+
174
+ Orden: contrato (env+PATH) → gestionados (bin_dir) → legacy (plugins).
175
+ Cada candidato pasa por ``is_file`` + bit X (directorio, inventado y
176
+ sin-permiso son ``desconocido``, nunca éxito). SDK faltante es
177
+ ``desconocido``. Con ``run_probe=True`` se ejecuta ``--help`` con
178
+ timeout: colgado → ``desconocido`` (nunca éxito).
179
+ """
180
+ if spec.kind == "sdk":
181
+ return check_sdk(spec)
182
+ if spec.kind == "hybrid":
183
+ try:
184
+ resolved = resolve_backend(spec)
185
+ ok = check_exec_bit(resolved)
186
+ if ok.readiness == Readiness.MOTOR:
187
+ return _maybe_probe(resolved, ok, run_probe=run_probe, timeout=timeout)
188
+ except (FileNotFoundError, OSError, RuntimeError):
189
+ pass
190
+ return check_sdk(spec)
191
+ candidates: list[Path] = []
192
+ try:
193
+ candidates.append(resolve_backend(spec))
194
+ except (FileNotFoundError, OSError, RuntimeError):
195
+ pass
196
+ candidates.extend(_managed_candidates(spec, bin_dir))
197
+ candidates.extend(_legacy_candidates(spec))
198
+ for cand in candidates:
199
+ outcome = check_exec_bit(cand)
200
+ if outcome.readiness == Readiness.MOTOR:
201
+ return _maybe_probe(cand, outcome, run_probe=run_probe, timeout=timeout)
202
+ if spec.kind == "hybrid" or spec.sdk_import:
203
+ sdk = check_sdk(spec)
204
+ if sdk.readiness == Readiness.MOTOR:
205
+ return sdk
206
+ first = candidates[0] if candidates else None
207
+ if first is not None:
208
+ return ProbeOutcome(Readiness.DESCONOCIDO, f"{first} no ejecutable")
209
+ return ProbeOutcome(Readiness.DESCONOCIDO, f"motor {spec.plugin}/{spec.backend} no encontrado")
210
+
211
+
212
+ def _maybe_probe(path: Path, ok: ProbeOutcome, *, run_probe: bool, timeout: int) -> ProbeOutcome:
213
+ if not run_probe:
214
+ return ok
215
+ probed = probe_runtime(path, timeout=timeout)
216
+ if probed.readiness == Readiness.MOTOR:
217
+ return ProbeOutcome(Readiness.MOTOR, f"{path} responde --help")
218
+ return ProbeOutcome(Readiness.DESCONOCIDO, probed.note)
219
+
220
+
221
+ def evaluate_plugin(
222
+ spec: BackendSpec,
223
+ entries: list,
224
+ models_dir: Path,
225
+ bin_dir: Path | None,
226
+ *,
227
+ full: bool = False,
228
+ capacity_ok: bool | None = None,
229
+ tested: bool = False,
230
+ machine=None,
231
+ run_probe: bool = False,
232
+ ) -> Readiness:
233
+ """Escalera quick/full/probado con motor unificado (misma que el contrato).
234
+
235
+ Idéntica escalera que ``evaluate_readiness`` pero con :func:`motor_probe`
236
+ (contrato + gestionados + legacy con bit-X) en vez del solo-contrato:
237
+ mismo input → misma lectura en status, doctor y setup dry.
238
+ """
239
+ if not entries:
240
+ return Readiness.DESCONOCIDO
241
+ from aioli_contract.fetch import verify # noqa: PLC0415
242
+
243
+ if machine is not None and capacity_ok is None:
244
+ try:
245
+ from aioli_contract.advise import judge # noqa: PLC0415
246
+
247
+ capacity_ok = bool(judge(spec.plugin, spec.backend, machine).fits)
248
+ except Exception: # noqa: BLE001
249
+ capacity_ok = None
250
+ for entry in entries:
251
+ if verify(entry, models_dir, bin_dir, quick=True) != "ok":
252
+ return Readiness.DESCONOCIDO
253
+ if not full:
254
+ level = Readiness.DESCARGADO
255
+ if motor_probe(spec, bin_dir, run_probe=run_probe).readiness == Readiness.MOTOR:
256
+ level = Readiness.MOTOR
257
+ if capacity_ok:
258
+ level = Readiness.CAPACIDAD
259
+ return level
260
+ for entry in entries:
261
+ if verify(entry, models_dir, bin_dir, quick=False) != "ok":
262
+ return Readiness.DESCARGADO
263
+ level = Readiness.INTEGRIDAD
264
+ if motor_probe(spec, bin_dir, run_probe=run_probe).readiness != Readiness.MOTOR:
265
+ return level
266
+ level = Readiness.MOTOR
267
+ if not capacity_ok:
268
+ return level
269
+ level = Readiness.CAPACIDAD
270
+ if tested:
271
+ return Readiness.PROBADO
272
+ return level
273
+
274
+
275
+ def build_execution_plan(
276
+ spec: BackendSpec,
277
+ entries: list,
278
+ models_dir: Path,
279
+ bin_dir: Path | None,
280
+ *,
281
+ full: bool = True,
282
+ capacity_ok: bool | None = None,
283
+ tested: bool = False,
284
+ machine=None,
285
+ ) -> ExecutionPlan:
286
+ """Plan setup dry/check: ficheros, pasos, evidencia y faltantes (sin descargar)."""
287
+ from aioli_contract.fetch import verify # noqa: PLC0415
288
+
289
+ evidence = evaluate_plugin(
290
+ spec,
291
+ entries,
292
+ models_dir,
293
+ bin_dir,
294
+ full=full,
295
+ capacity_ok=capacity_ok,
296
+ tested=tested,
297
+ machine=machine,
298
+ )
299
+ missing = tuple(e for e in entries if verify(e, models_dir, bin_dir) != "ok")
300
+ steps: list[str] = []
301
+ if missing:
302
+ steps.append(f"descargar {len(missing)} fichero(s) de {spec.plugin}/{spec.backend}")
303
+ if rank(evidence) < rank(Readiness.MOTOR):
304
+ steps.append(f"resolver motor {spec.plugin}/{spec.backend}")
305
+ if not steps:
306
+ steps.append("nada pendiente")
307
+ return ExecutionPlan(
308
+ spec=spec, files=tuple(entries), steps=tuple(steps), evidence=evidence, missing=missing
309
+ )
310
+
311
+
312
+ def is_ready(level: Readiness) -> bool:
313
+ """Listo para generar: capacidad verificada (o probado)."""
314
+ return level in (Readiness.CAPACIDAD, Readiness.PROBADO)
315
+
316
+
317
+ def doctor_checks_for(
318
+ spec: BackendSpec,
319
+ entries: list,
320
+ models_dir: Path,
321
+ bin_dir: Path | None,
322
+ machine=None,
323
+ ) -> list[Check]:
324
+ """Checks doctor por backend con la misma lectura que status/setup."""
325
+ from aioli_contract.advise import judge # noqa: PLC0415
326
+
327
+ # V3 (F-4): None = capacidad sin medir (nunca se midió); no se
328
+ # confunde con el veredicto NEGATIVO medido del asesor, que es
329
+ # "no cabe". El hint no miente en ninguno de los tres casos.
330
+ fits: bool | None
331
+ if machine is None:
332
+ fits = None
333
+ else:
334
+ try:
335
+ fits = bool(judge(spec.plugin, spec.backend, machine).fits)
336
+ except Exception: # noqa: BLE001
337
+ fits = False
338
+ level = evaluate_plugin(
339
+ spec, entries, models_dir, bin_dir, full=False, capacity_ok=bool(fits), machine=None
340
+ )
341
+ ok = is_ready(level)
342
+ detail = level.value
343
+ if fits is False:
344
+ detail += " (no cabe en este equipo)"
345
+ elif fits is None:
346
+ detail += " (capacidad sin medir)"
347
+ return [Check(name=f"{spec.plugin}/{spec.backend}", ok=ok, hint=detail)]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
+ from typing import TYPE_CHECKING
6
7
 
7
8
  import typer
8
9
  from rich.console import Console
@@ -12,6 +13,9 @@ from rich.table import Table
12
13
 
13
14
  console = Console()
14
15
 
16
+ if TYPE_CHECKING:
17
+ from aioli_contract.readiness import Readiness # noqa: F401 — anotación de _plan_readiness
18
+
15
19
  LEAGUE_LINES = {
16
20
  "patata": "🥔 Sin NVIDIA a la vista: tiraremos de CPU (voice) y poco más.",
17
21
  "modesta": "🛵 GPU modesta: motores ligeros y sin prisas.",
@@ -119,7 +123,9 @@ def select_plan(registry, machine) -> dict[str, list]:
119
123
  return plan
120
124
 
121
125
 
122
- def run_setup(yes: bool = False, plugins: str | None = None, ask: bool = False) -> None:
126
+ def run_setup(
127
+ yes: bool = False, plugins: str | None = None, ask: bool = False, check: bool = False
128
+ ) -> None:
123
129
  from aioli_cli.fetch_ui import download_many, print_summary
124
130
  from aioli_contract import (
125
131
  check_space,
@@ -145,6 +151,22 @@ def run_setup(yes: bool = False, plugins: str | None = None, ask: bool = False)
145
151
  # quepa. Runtimes y ficheros de plataforma correcta incluidos.
146
152
  plan = select_plan(registry, machine)
147
153
 
154
+ if check:
155
+ # V3 --check: misma lectura que status --full, sin descargar ni preguntar.
156
+ from aioli_contract import default_bin_dir as _bdir # noqa: PLC0415
157
+ from aioli_contract import default_models_dir as _mdir # noqa: PLC0415
158
+
159
+ _md, _bd = _mdir(), _bdir()
160
+ for pn in sorted(plan):
161
+ missing, readiness = _plan_readiness(pn, plan[pn], _md, _bd, machine=machine)
162
+ state = (
163
+ "listo"
164
+ if not missing and readiness in ("capacidad", "probado")
165
+ else (readiness.value if readiness else "desconocido")
166
+ )
167
+ console.print(f"[dim]check {pn}: {state} ({len(missing)} pendiente(s))[/]")
168
+ return
169
+
148
170
  if plugins:
149
171
  requested = [p.strip() for p in plugins.split(",") if p.strip()]
150
172
  known = {p.name for p in registry.all() if p.models is not None}
@@ -383,15 +405,96 @@ def _persist_engine_defaults(chosen: dict[str, str]) -> None:
383
405
 
384
406
 
385
407
  def _piper_ready() -> bool:
408
+ """V3: mismo criterio que status/doctor (evaluación unificada)."""
409
+ try:
410
+ from aioli_cli.readiness import motor_probe, spec_for # noqa: PLC0415
411
+ from aioli_contract import default_bin_dir # noqa: PLC0415
412
+ from aioli_contract.readiness import Readiness # noqa: PLC0415
413
+
414
+ spec = spec_for("voice", "piper")
415
+ return motor_probe(spec, default_bin_dir()).readiness == Readiness.MOTOR
416
+ except (KeyError, OSError, RuntimeError, ImportError):
417
+ pass
418
+ # V3 (F-6): el fallback tampoco gradua sin bit X - un binario
419
+ # presente pero no ejecutable es "pendiente", no exito.
386
420
  try:
421
+ from aioli_contract.readiness import ( # noqa: PLC0415
422
+ Readiness as _Readiness,
423
+ )
424
+ from aioli_contract.readiness import (
425
+ check_exec_bit,
426
+ )
387
427
  from aioli_voice.engines.piper import resolve_piper_bin # noqa: PLC0415
388
428
 
389
- resolve_piper_bin()
390
- return True
391
- except (OSError, RuntimeError):
429
+ return check_exec_bit(resolve_piper_bin()).readiness == _Readiness.MOTOR
430
+ except (OSError, RuntimeError, ImportError):
392
431
  return False
393
432
 
394
433
 
434
+ def _plan_readiness(
435
+ pn: str, files: list, models_dir, bin_dir, *, machine=None
436
+ ) -> tuple[list, Readiness | None]:
437
+ """--check/dry_plan: pendientes + PEOR readiness de TODOS los backends (V3, F-2).
438
+
439
+ Antes solo se evaluaba el PRIMER backend del plan (un plan
440
+ multi-backend, p.ej. music acestep+yue2, quedaba subevaluado). Ahora
441
+ se agrupa por backend (como `aioli doctor`) y se agrega con la peor
442
+ lectura: un motor desconocido no es "listo" porque el otro gradue.
443
+ Backend sin spec unificada -> None (desconocido honesto, nunca exito).
444
+ """
445
+ from aioli_contract import verify as _verify # noqa: PLC0415
446
+ from aioli_contract.readiness import rank as _rank
447
+
448
+ missing = [e for e in files if _verify(e, models_dir, bin_dir) != "ok"]
449
+ by_backend: dict[str, list] = {}
450
+ runtimes: list = []
451
+ for e in files:
452
+ if e.kind == "runtime":
453
+ runtimes.append(e)
454
+ elif e.backend:
455
+ by_backend.setdefault(e.backend, []).append(e)
456
+ if not by_backend:
457
+ return missing, None
458
+ levels: list[Readiness] = []
459
+ try:
460
+ from aioli_cli.readiness import build_execution_plan, spec_for # noqa: PLC0415
461
+
462
+ for backend in sorted(by_backend):
463
+ spec = spec_for(pn, backend)
464
+ backend_files = [r for r in runtimes if r.backend in ("", backend)]
465
+ levels.append(
466
+ build_execution_plan(
467
+ spec,
468
+ backend_files + by_backend[backend],
469
+ models_dir,
470
+ bin_dir,
471
+ machine=machine,
472
+ ).evidence
473
+ )
474
+ except (KeyError, ImportError, OSError, RuntimeError):
475
+ return missing, None
476
+ return missing, min(levels, key=_rank)
477
+
478
+
479
+ def dry_plan(registry, machine) -> dict[str, object]:
480
+ """Plan setup sin descargar (V3.2 coherencia): misma lectura que status.
481
+
482
+ Devuelve por plugin ``{"files": [...], "missing": [...], "readiness": Readiness}``
483
+ con verificación full (como `setup` sin descargas pendientes). No escribe
484
+ nada ni pregunta: los tests comparan este dict con status/doctor.
485
+ """
486
+ from aioli_contract import default_bin_dir, default_models_dir # noqa: PLC0415
487
+
488
+ models_dir = default_models_dir()
489
+ bin_dir = default_bin_dir()
490
+ plan = select_plan(registry, machine)
491
+ out: dict[str, object] = {}
492
+ for pn, files in plan.items():
493
+ missing, readiness = _plan_readiness(pn, files, models_dir, bin_dir, machine=machine)
494
+ out[pn] = {"files": files, "missing": missing, "readiness": readiness}
495
+ return out
496
+
497
+
395
498
  def _ensure_binaries(chosen: dict[str, str], yes: bool = False) -> list[str]:
396
499
  """Garantiza el motor ejecutable o devuelve la lista de pendientes.
397
500