aioli-cli 0.1.30__tar.gz → 0.1.32__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.30 → aioli_cli-0.1.32}/PKG-INFO +6 -4
  2. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/README.md +5 -3
  3. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/pyproject.toml +13 -1
  4. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/main.py +133 -31
  5. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/setup.py +252 -48
  6. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/status.py +98 -12
  7. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_contract/advise.py +149 -0
  8. aioli_cli-0.1.32/src/aioli_contract/config.py +99 -0
  9. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_contract/fetch.py +208 -0
  10. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/cli.py +45 -2
  11. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/cli_commands/generate_cmd.py +55 -3
  12. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/cli.py +64 -1
  13. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/wgp.py +43 -0
  14. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/cli/engines_cmd.py +3 -1
  15. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/cli/generate.py +92 -10
  16. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/chatterbox.py +103 -7
  17. aioli_cli-0.1.30/src/aioli_contract/config.py +0 -43
  18. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/.gitignore +0 -0
  19. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/LICENSE +0 -0
  20. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/_plugins.py +0 -0
  21. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/fetch_ui.py +0 -0
  22. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/readiness.py +0 -0
  23. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_cli/registry.py +0 -0
  24. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_contract/__init__.py +0 -0
  25. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_contract/doctor.py +0 -0
  26. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_contract/probe.py +0 -0
  27. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_contract/readiness.py +0 -0
  28. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/__init__.py +0 -0
  29. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/companions.py +0 -0
  30. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/config.py +0 -0
  31. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/data/models.toml +0 -0
  32. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/doctor.py +0 -0
  33. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/gpu.py +0 -0
  34. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/launcher.py +0 -0
  35. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/models.py +0 -0
  36. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/models_data.py +0 -0
  37. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/plugin.py +0 -0
  38. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/presets.py +0 -0
  39. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/stub.py +0 -0
  40. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_image/ui.py +0 -0
  41. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_master/cli.py +0 -0
  42. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_master/doctor.py +0 -0
  43. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_master/ops.py +0 -0
  44. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_master/plugin.py +0 -0
  45. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/__init__.py +0 -0
  46. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/__init__.py +0 -0
  47. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/acestep.py +0 -0
  48. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/base.py +0 -0
  49. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/llm_caption.py +0 -0
  50. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/minimax_comfy.py +0 -0
  51. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/process.py +0 -0
  52. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/stub.py +0 -0
  53. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/backends/yue2.py +0 -0
  54. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/cli_commands/__init__.py +0 -0
  55. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/cli_commands/backend_cmd.py +0 -0
  56. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
  57. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/cli_commands/models_cmd.py +0 -0
  58. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/client.py +0 -0
  59. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/config.py +0 -0
  60. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/config_data/default.toml +0 -0
  61. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/core/__init__.py +0 -0
  62. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/core/formats.py +0 -0
  63. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/core/generate.py +0 -0
  64. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/core/jobs.py +0 -0
  65. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/core/layout.py +0 -0
  66. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/doctor.py +0 -0
  67. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/generator.py +0 -0
  68. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/history.py +0 -0
  69. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/jsonstore.py +0 -0
  70. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/models.py +0 -0
  71. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/models_data.py +0 -0
  72. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/paths.py +0 -0
  73. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/plugin.py +0 -0
  74. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/presets.py +0 -0
  75. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/prompts.py +0 -0
  76. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/server.py +0 -0
  77. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/transport/__init__.py +0 -0
  78. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/transport/client.py +0 -0
  79. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_music/tui.py +0 -0
  80. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_podcast/cast.py +0 -0
  81. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_podcast/cli.py +0 -0
  82. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_podcast/doctor.py +0 -0
  83. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_podcast/episode.py +0 -0
  84. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_podcast/plugin.py +0 -0
  85. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_podcast/script.py +0 -0
  86. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/doctor.py +0 -0
  87. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/models_data.py +0 -0
  88. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/paths.py +0 -0
  89. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/plugin.py +0 -0
  90. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/stub.py +0 -0
  91. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_video/worker.py +0 -0
  92. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/__init__.py +0 -0
  93. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/cli/doctor.py +0 -0
  94. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/cli/models_cmd.py +0 -0
  95. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/cli/profiles.py +0 -0
  96. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/config/__init__.py +0 -0
  97. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/config/settings.py +0 -0
  98. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/core/__init__.py +0 -0
  99. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/core/generation.py +0 -0
  100. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/core/model_manager.py +0 -0
  101. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/core/profiles.py +0 -0
  102. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/core/registry.py +0 -0
  103. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/core/types.py +0 -0
  104. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/devices.py +0 -0
  105. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/doctor.py +0 -0
  106. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/__init__.py +0 -0
  107. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/kokoro.py +0 -0
  108. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/moss.py +0 -0
  109. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/piper.py +0 -0
  110. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/qwen3.py +0 -0
  111. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/stub.py +0 -0
  112. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/voxcpm.py +0 -0
  113. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/engines/xtts.py +0 -0
  114. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/src/aioli_voice/models_data.py +0 -0
  115. {aioli_cli-0.1.30 → aioli_cli-0.1.32}/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.30
3
+ Version: 0.1.32
4
4
  Summary: aioli — local-first AI media suite CLI (images, video, music, voice, podcast)
5
5
  Author: aioli contributors
6
6
  License: MIT
@@ -79,8 +79,8 @@ with these commands. No accounts, no subscription.
79
79
  ## Install
80
80
 
81
81
  ```bash
82
- curl -fsSL https://aioli.es/install.sh | sh # hardware → advice → models, fully guided
83
- # …or by hand (needs `uv`, Python ≥3.12 and `ffmpeg`):
82
+ curl -fsSL https://aioli.es/install.sh | sh # installs uv+ffmpeg if missing → doctor → setup → status
83
+ # …or truly by hand (needs `uv`, Python ≥3.12 and `ffmpeg`):
84
84
  uv tool install aioli-cli # extras: aioli-cli[voice-piper,music-storage]
85
85
  aioli setup # tells you what fits your GPU, downloads weights
86
86
  aioli doctor # preflight: CUDA, ffmpeg, models, VRAM
@@ -91,7 +91,9 @@ huggingface.co and export `HF_TOKEN` (read-only token) before `setup`.
91
91
 
92
92
  > 🔒 Paranoid (good): the installer lives in the repo's `install.sh`.
93
93
  > It only uses the network for `astral.sh` (uv), GitHub and HuggingFace.
94
- > No `sudo`.
94
+ > The only elevated step is installing `ffmpeg` if missing — it asks first
95
+ > (or skips with `--no-ffmpeg`). Re-running is safe: it upgrades in place
96
+ > and preserves your extras and `--with` engines.
95
97
 
96
98
  ## Requirements
97
99
 
@@ -47,8 +47,8 @@ with these commands. No accounts, no subscription.
47
47
  ## Install
48
48
 
49
49
  ```bash
50
- curl -fsSL https://aioli.es/install.sh | sh # hardware → advice → models, fully guided
51
- # …or by hand (needs `uv`, Python ≥3.12 and `ffmpeg`):
50
+ curl -fsSL https://aioli.es/install.sh | sh # installs uv+ffmpeg if missing → doctor → setup → status
51
+ # …or truly by hand (needs `uv`, Python ≥3.12 and `ffmpeg`):
52
52
  uv tool install aioli-cli # extras: aioli-cli[voice-piper,music-storage]
53
53
  aioli setup # tells you what fits your GPU, downloads weights
54
54
  aioli doctor # preflight: CUDA, ffmpeg, models, VRAM
@@ -59,7 +59,9 @@ huggingface.co and export `HF_TOKEN` (read-only token) before `setup`.
59
59
 
60
60
  > 🔒 Paranoid (good): the installer lives in the repo's `install.sh`.
61
61
  > It only uses the network for `astral.sh` (uv), GitHub and HuggingFace.
62
- > No `sudo`.
62
+ > The only elevated step is installing `ffmpeg` if missing — it asks first
63
+ > (or skips with `--no-ffmpeg`). Re-running is safe: it upgrades in place
64
+ > and preserves your extras and `--with` engines.
63
65
 
64
66
  ## Requirements
65
67
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioli-cli"
3
- version = "0.1.30"
3
+ version = "0.1.32"
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"
@@ -144,6 +144,7 @@ module = [
144
144
  "voxcpm",
145
145
  "qwen_tts", "qwen_tts.*",
146
146
  "chatterbox", "chatterbox.*",
147
+ "perth",
147
148
  "moss_tts_nano",
148
149
  "transformers", "transformers.*",
149
150
  "kokoro_onnx", "kokoro_onnx.*",
@@ -155,6 +156,17 @@ ignore_missing_imports = true
155
156
  module = ["soundfile", "soundfile.*", "tqdm"]
156
157
  ignore_missing_imports = true
157
158
 
159
+ # Out-of-venv checkers (session lens) run mypy WITHOUT the project venv:
160
+ # pytest (dev dep) and aioli_contract (workspace pkg in libs/contract, editable
161
+ # in .venv) don't resolve there and mypy reports import-not-found — false
162
+ # positive. In the real env they resolve (`.venv/bin/python -c "import pytest,
163
+ # aioli_contract"` OK; ruff + pytest --collect-only green on libs/contract/tests).
164
+ # ignore_missing_imports only fires when resolution fails, so the repo's own
165
+ # venv runs keep full checking.
166
+ [[tool.mypy.overrides]]
167
+ module = ["pytest", "pytest.*", "aioli_contract", "aioli_contract.*"]
168
+ ignore_missing_imports = true
169
+
158
170
  # DEBT: aioli_music.core.jobs imports aioli_music.core.storage, which does not
159
171
  # exist (unimplemented object-storage feature); nothing imports jobs. Decide:
160
172
  # implement storage or delete the module. Until then its errors stay hidden
@@ -1,5 +1,8 @@
1
1
  """aioli CLI entry point."""
2
2
 
3
+ from pathlib import Path
4
+ from typing import Any
5
+
3
6
  import typer
4
7
  from rich.console import Console
5
8
  from rich.table import Table
@@ -92,11 +95,10 @@ def doctor() -> None:
92
95
  entry = ModelFile(**raw)
93
96
  if entry.optional:
94
97
  continue
95
- if entry.platform:
96
- import sys as _sys
98
+ from aioli_contract.fetch import current_platform, platform_matches
97
99
 
98
- if entry.platform != _sys.platform:
99
- continue
100
+ if not platform_matches(entry.platform, current_platform()):
101
+ continue
100
102
  if entry.kind == "runtime":
101
103
  continue
102
104
  grouped.setdefault(entry.backend or "-", []).append(entry)
@@ -105,11 +107,10 @@ def doctor() -> None:
105
107
  entry = ModelFile(**raw)
106
108
  if entry.kind != "runtime" or entry.optional:
107
109
  continue
108
- if entry.platform:
109
- import sys as _sys2
110
+ from aioli_contract.fetch import current_platform, platform_matches
110
111
 
111
- if entry.platform != _sys2.platform:
112
- continue
112
+ if not platform_matches(entry.platform, current_platform()):
113
+ continue
113
114
  runtimes.append(entry)
114
115
  for backend, entries in sorted(grouped.items()):
115
116
  if backend == "-":
@@ -174,6 +175,28 @@ def version() -> None:
174
175
  models_app = typer.Typer(help="Download + verify weight files.")
175
176
  app.add_typer(models_app, name="models")
176
177
 
178
+ backend_app = typer.Typer(help="Ver + cambiar el motor por defecto (music/voice).")
179
+ app.add_typer(backend_app, name="backend")
180
+
181
+
182
+ @backend_app.command("set")
183
+ def backend_set(
184
+ plugin: str = typer.Argument(..., help="Plugin con default persistido: music | voice."),
185
+ backend: str = typer.Argument(..., help="Motor a usar por defecto en generate."),
186
+ ) -> None:
187
+ """Cambia el default persistido sin descargar (orden explicita, AC6).
188
+
189
+ `generate` usa este default salvo flag explicito (`--backend`/`--engine`,
190
+ que manda). Image/video son informativos y no tienen default persistido.
191
+ """
192
+ from aioli_cli.setup import set_backend # noqa: PLC0415
193
+
194
+ set_backend(plugin, backend)
195
+ console.print(
196
+ f"✅ default de {plugin}: [bold]{backend}[/] "
197
+ "(el flag --backend/--engine sigue mandando en generate)"
198
+ )
199
+
177
200
 
178
201
  def _wanted(plugin: str | None, backend: str | None = None) -> list[tuple[str, dict]]:
179
202
  from aioli_cli import _plugins # noqa: PLC0415 — ensure loaded
@@ -202,6 +225,15 @@ def _wanted(plugin: str | None, backend: str | None = None) -> list[tuple[str, d
202
225
  return out
203
226
 
204
227
 
228
+ def _plugin_roots(
229
+ wanted: list[tuple[str, Any]], cfg: AioliConfig, cli_flag: str | None
230
+ ) -> dict[str, Path]:
231
+ roots: dict[str, Path] = {}
232
+ for pname, _ in wanted:
233
+ roots.setdefault(pname, cfg.resolve_models_root(pname, cli_flag=cli_flag))
234
+ return roots
235
+
236
+
205
237
  def _backends() -> dict[str, list[str]]:
206
238
  from aioli_cli import _plugins # noqa: PLC0415 — ensure loaded
207
239
 
@@ -222,24 +254,33 @@ def models_list(
222
254
  None,
223
255
  "--backend",
224
256
  "-b",
225
- help="Engine filter (default: plugin default; 'all' = every engine).",
257
+ help="Engine filter (default: plugin default; 'all' = every engine). "
258
+ "Disponible para tu equipo: ver `aioli status`.",
259
+ ),
260
+ models_root: str | None = typer.Option(
261
+ None,
262
+ "--models-root",
263
+ help="Raíz de modelos (máxima precedencia, todos los plugins).",
226
264
  ),
227
265
  ) -> None:
228
266
  """Show manifest entries + local status."""
229
- from aioli_contract import ModelFile, default_bin_dir, default_models_dir, human_gb, verify
267
+ from aioli_contract import ModelFile, default_bin_dir, human_gb, verify
230
268
 
231
- models_dir = default_models_dir()
269
+ cfg = AioliConfig.load()
232
270
  bin_dir = default_bin_dir()
233
- table = Table(title=f"aioli models → {models_dir}")
271
+ wanted = _wanted(plugin, backend)
272
+ roots = _plugin_roots(wanted, cfg, models_root)
273
+ distinct = ", ".join(sorted({str(r) for r in roots.values()}))
274
+ table = Table(title=f"aioli models → {distinct}")
234
275
  table.add_column("plugin")
235
276
  table.add_column("engine")
236
277
  table.add_column("kind")
237
278
  table.add_column("file")
238
279
  table.add_column("size")
239
280
  table.add_column("status")
240
- for pname, raw in _wanted(plugin, backend):
281
+ for pname, raw in wanted:
241
282
  entry = ModelFile(**raw)
242
- status = verify(entry, models_dir, bin_dir, quick=True)
283
+ status = verify(entry, roots[pname], bin_dir, quick=True)
243
284
  icon = {"ok": "✅", "missing": "⬇️", "corrupt": "⚠️", "unverified": "❔"}[status]
244
285
  table.add_row(
245
286
  pname,
@@ -260,11 +301,20 @@ def models_download(
260
301
  plugin: str | None = typer.Argument(None),
261
302
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
262
303
  backend: str | None = typer.Option(
263
- None, "--backend", "-b", help="Engine only (default: plugin default; 'all' = every engine)."
304
+ None,
305
+ "--backend",
306
+ "-b",
307
+ help="Engine only (default: plugin default; 'all' = every engine). "
308
+ "Disponible para tu equipo: ver `aioli status`.",
264
309
  ),
265
310
  with_optional: bool = typer.Option(
266
311
  False, "--with-optional", help="Include optional files (ASR, alt quants)."
267
312
  ),
313
+ models_root: str | None = typer.Option(
314
+ None,
315
+ "--models-root",
316
+ help="Raíz de modelos (máxima precedencia, todos los plugins).",
317
+ ),
268
318
  force: bool = typer.Option(
269
319
  False, "--force", help="Re-download even if verified (repairs partial runtime bundles)."
270
320
  ),
@@ -276,14 +326,14 @@ def models_download(
276
326
  ModelFile,
277
327
  check_space,
278
328
  default_bin_dir,
279
- default_models_dir,
280
329
  human_gb,
281
330
  verify,
282
331
  )
283
332
 
284
- models_dir = default_models_dir()
333
+ cfg = AioliConfig.load()
285
334
  bin_dir = default_bin_dir()
286
335
  wanted = [(pn, ModelFile(**raw)) for pn, raw in _wanted(plugin, backend)]
336
+ roots = _plugin_roots(wanted, cfg, models_root)
287
337
  if not with_optional:
288
338
  skipped = [e.rel for _, e in wanted if e.optional]
289
339
  wanted = [(pn, e) for pn, e in wanted if not e.optional]
@@ -296,19 +346,29 @@ def models_download(
296
346
  if force:
297
347
  missing = wanted # re-descargar todo lo seleccionado
298
348
  else:
299
- missing = [(pn, e) for pn, e in wanted if verify(e, models_dir, bin_dir) != "ok"]
349
+ missing = [(pn, e) for pn, e in wanted if verify(e, roots[pn], bin_dir) != "ok"]
300
350
  if not missing:
301
351
  console.print("✅ all models present + verified")
302
352
  return
353
+ by_root: dict[Path, list] = {}
354
+ for pn, e in missing:
355
+ by_root.setdefault(roots[pn], []).append(e)
303
356
  total = sum(e.size for _, e in missing)
304
- fits, _needed, free = check_space([e for _, e in missing], models_dir, bin_dir, force=force)
305
- console.print(f"⬇️ {len(missing)} file(s), {human_gb(total)} → {models_dir}")
306
- if free is None:
357
+ fits_all = True
358
+ free_min: int | None = None
359
+ for root, es in by_root.items():
360
+ fits, _needed, free = check_space(es, root, bin_dir, force=force)
361
+ fits_all = fits_all and fits
362
+ if free is not None:
363
+ free_min = free if free_min is None else min(free_min, free)
364
+ distinct = ", ".join(sorted(str(r) for r in by_root))
365
+ console.print(f"⬇️ {len(missing)} file(s), {human_gb(total)} → {distinct}")
366
+ if free_min is None:
307
367
  console.print(" [yellow]⚠️ disco no medible: no puedo confirmar que quepa.[/]")
308
- elif free:
309
- console.print(f" disk free: {human_gb(free)}")
310
- if not fits:
311
- have = human_gb(free) if free is not None else "desconocido"
368
+ elif free_min:
369
+ console.print(f" disk free: {human_gb(free_min)}")
370
+ if not fits_all:
371
+ have = human_gb(free_min) if free_min is not None else "desconocido"
312
372
  raise typer.Abort(f"❌ not enough disk space for {human_gb(total)} (free: {have})")
313
373
  notes = {registry._plugins[pn].gated_note for pn, _ in missing}
314
374
  for note in sorted(n for n in notes if n):
@@ -323,28 +383,39 @@ def models_download(
323
383
 
324
384
  from aioli_cli.fetch_ui import download_many, print_summary
325
385
 
326
- failures = download_many([e for _, e in missing], models_dir, bin_dir, jobs=jobs, force=force)
386
+ failures: list[tuple[str, str]] = []
387
+ for root, es in sorted(by_root.items(), key=lambda kv: str(kv[0])):
388
+ failures.extend(download_many(es, root, bin_dir, jobs=jobs, force=force))
327
389
  ok_count = len(missing) - len(failures)
328
390
  if not print_summary(failures, ok_count):
329
391
  raise typer.Exit(code=1)
330
392
 
331
393
 
332
394
  @models_app.command("verify")
333
- def models_verify(plugin: str | None = typer.Argument(None)) -> None:
395
+ def models_verify(
396
+ plugin: str | None = typer.Argument(None),
397
+ models_root: str | None = typer.Option(
398
+ None,
399
+ "--models-root",
400
+ help="Raíz de modelos (máxima precedencia, todos los plugins).",
401
+ ),
402
+ ) -> None:
334
403
  """Re-hash local files against the manifest.
335
404
 
336
405
  Exit 1 si algo falta, está corrupto o no es verificable (apto para
337
406
  CI); 'unverified' (sin recibo legacy o sin hash) NO es "ok":
338
407
  presencia no es integridad. Regenera con `models download --force`.
339
408
  """
340
- from aioli_contract import ModelFile, default_bin_dir, default_models_dir, verify
409
+ from aioli_contract import ModelFile, default_bin_dir, verify
341
410
 
342
- models_dir = default_models_dir()
411
+ cfg = AioliConfig.load()
343
412
  bin_dir = default_bin_dir()
413
+ wanted = _wanted(plugin)
414
+ roots = _plugin_roots(wanted, cfg, models_root)
344
415
  bad = 0
345
- for pname, raw in _wanted(plugin):
416
+ for pname, raw in wanted:
346
417
  entry = ModelFile(**raw)
347
- status = verify(entry, models_dir, bin_dir)
418
+ status = verify(entry, roots[pname], bin_dir)
348
419
  icon = {"ok": "✅", "missing": "⬇️", "corrupt": "⚠️", "unverified": "❔"}[status]
349
420
  console.print(f"{icon} [{pname}] {entry.rel}: {status}")
350
421
  if status in ("corrupt", "missing", "unverified"):
@@ -353,6 +424,37 @@ def models_verify(plugin: str | None = typer.Argument(None)) -> None:
353
424
  raise typer.Exit(code=1)
354
425
 
355
426
 
427
+ @models_app.command("import")
428
+ def models_import(
429
+ plugin: str = typer.Argument(..., help="Plugin requerido: image,video,music,voice."),
430
+ src_dir: Path = typer.Argument(..., help="Directorio origen existente, no se borra."),
431
+ recursive: bool = typer.Option(False, "--recursive", "-r", help="Escanea subdirectorios."),
432
+ models_root: str | None = typer.Option(None, "--models-root", help="Raiz de modelos."),
433
+ ) -> None:
434
+ from aioli_contract import default_bin_dir
435
+ from aioli_contract.fetch import ModelFile, import_models
436
+
437
+ if not src_dir.is_dir():
438
+ raise typer.BadParameter(f"dir origen inexistente: {src_dir}")
439
+ cfg = AioliConfig.load()
440
+ bin_dir = default_bin_dir()
441
+ root = cfg.resolve_models_root(plugin, cli_flag=models_root)
442
+ wanted = _wanted(plugin, backend="all")
443
+ entries = [ModelFile(**raw) for _, raw in wanted]
444
+ report = import_models(entries, src_dir, root, bin_dir, recursive=recursive)
445
+ console.print(f"adoptados: {len(report.adopted)}")
446
+ for rel in sorted(report.adopted):
447
+ console.print(f" adoptados {rel}")
448
+ console.print(f"desconocidos: {len(report.unknown)}")
449
+ for name in sorted(report.unknown):
450
+ console.print(f" desconocidos {name}")
451
+ console.print(f"discrepantes: {len(report.discrepant)}")
452
+ for name in sorted(report.discrepant):
453
+ console.print(f" discrepantes {name}")
454
+ if report.discrepant:
455
+ raise typer.Exit(code=1)
456
+
457
+
356
458
  def main() -> None:
357
459
  from aioli_cli import _plugins
358
460