aioli-cli 0.1.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.
aioli_cli/_plugins.py ADDED
@@ -0,0 +1,118 @@
1
+ """Lazy plugin loader: import each installed plugin package and register it.
2
+
3
+ Plugins are optional — a missing package is silently skipped so the `aioli`
4
+ binary keeps working with whatever is installed.
5
+ """
6
+
7
+ from aioli_cli.registry import Plugin, registry
8
+
9
+
10
+ _LOADED = False
11
+
12
+
13
+ def load_all() -> None:
14
+ """Import plugin packages and register their Typer apps + checks."""
15
+ global _LOADED
16
+ if _LOADED:
17
+ return
18
+ _LOADED = True
19
+ try:
20
+ from aioli_voice.models_data import DEFAULT_BACKEND as VOICE_DEFAULT
21
+ from aioli_voice.models_data import MODELS as VOICE_MODELS
22
+ from aioli_voice.plugin import DESCRIPTION, voice_app, voice_checks
23
+
24
+ registry.register(
25
+ Plugin(
26
+ name="voice",
27
+ description=DESCRIPTION,
28
+ app=voice_app,
29
+ checks=voice_checks,
30
+ models=lambda: VOICE_MODELS,
31
+ default_backend=VOICE_DEFAULT,
32
+ )
33
+ )
34
+ except ImportError:
35
+ pass
36
+ try:
37
+ from aioli_image.models_data import GATED_NOTE as IMAGE_GATED
38
+ from aioli_image.models_data import DEFAULT_BACKEND as IMAGE_DEFAULT
39
+ from aioli_image.models_data import MODELS as IMAGE_MODELS
40
+ from aioli_image.plugin import DESCRIPTION, image_app, image_checks
41
+
42
+ registry.register(
43
+ Plugin(
44
+ name="image",
45
+ description=DESCRIPTION,
46
+ app=image_app,
47
+ checks=image_checks,
48
+ models=lambda: IMAGE_MODELS,
49
+ default_backend=IMAGE_DEFAULT,
50
+ gated_note=IMAGE_GATED,
51
+ )
52
+ )
53
+ except ImportError:
54
+ pass
55
+ try:
56
+ from aioli_video.models_data import GATED_NOTE as VIDEO_GATED
57
+ from aioli_video.models_data import DEFAULT_BACKEND as VIDEO_DEFAULT
58
+ from aioli_video.models_data import MODELS as VIDEO_MODELS
59
+ from aioli_video.plugin import DESCRIPTION, video_app, video_checks
60
+
61
+ registry.register(
62
+ Plugin(
63
+ name="video",
64
+ description=DESCRIPTION,
65
+ app=video_app,
66
+ checks=video_checks,
67
+ models=lambda: VIDEO_MODELS,
68
+ default_backend=VIDEO_DEFAULT,
69
+ gated_note=VIDEO_GATED,
70
+ )
71
+ )
72
+ except ImportError:
73
+ pass
74
+ try:
75
+ from aioli_podcast.plugin import DESCRIPTION, podcast_app, podcast_checks
76
+
77
+ registry.register(
78
+ Plugin(
79
+ name="podcast",
80
+ description=DESCRIPTION,
81
+ app=podcast_app,
82
+ checks=podcast_checks,
83
+ )
84
+ )
85
+ except ImportError:
86
+ pass
87
+ try:
88
+ from aioli_master.plugin import DESCRIPTION, master_app, master_checks
89
+
90
+ registry.register(
91
+ Plugin(
92
+ name="master",
93
+ description=DESCRIPTION,
94
+ app=master_app,
95
+ checks=master_checks,
96
+ )
97
+ )
98
+ except ImportError:
99
+ pass
100
+ try:
101
+ from aioli_music.models_data import GATED_NOTE as MUSIC_GATED
102
+ from aioli_music.models_data import DEFAULT_BACKEND as MUSIC_DEFAULT
103
+ from aioli_music.models_data import MODELS as MUSIC_MODELS
104
+ from aioli_music.plugin import DESCRIPTION, music_app, music_checks
105
+
106
+ registry.register(
107
+ Plugin(
108
+ name="music",
109
+ description=DESCRIPTION,
110
+ app=music_app,
111
+ checks=music_checks,
112
+ models=lambda: MUSIC_MODELS,
113
+ default_backend=MUSIC_DEFAULT,
114
+ gated_note=MUSIC_GATED,
115
+ )
116
+ )
117
+ except ImportError:
118
+ pass
aioli_cli/main.py ADDED
@@ -0,0 +1,215 @@
1
+ """aioli CLI entry point."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from aioli_cli.registry import registry
8
+ from aioli_contract import AioliConfig, run_checks
9
+
10
+ app = typer.Typer(
11
+ name="aioli",
12
+ help="🧄 aioli — local-first AI media suite.",
13
+ rich_markup_mode="rich",
14
+ )
15
+ console = Console()
16
+
17
+
18
+ @app.command()
19
+ def doctor() -> None:
20
+ """Preflight: python, ffmpeg, CUDA. Plugins append their own checks."""
21
+ cfg = AioliConfig.load()
22
+ table = Table(title="aioli doctor")
23
+ table.add_column("check")
24
+ table.add_column("status")
25
+ table.add_column("detail")
26
+ for check in run_checks():
27
+ table.add_row(check.name, "✅" if check.ok else "❌", check.hint)
28
+ for plugin in registry.all():
29
+ if plugin.checks is None:
30
+ continue
31
+ for check in plugin.checks():
32
+ table.add_row(
33
+ f"{plugin.name}/{check.name}",
34
+ "✅" if check.ok else "❌",
35
+ check.hint,
36
+ )
37
+ table.add_row("models_dir", "📁", str(cfg.models_dir))
38
+ table.add_row("plugins", "🔌", ", ".join(registry.names()) or "(none yet)")
39
+ console.print(table)
40
+
41
+
42
+ @app.command()
43
+ def setup(
44
+ yes: bool = typer.Option(False, "--yes", "-y", help="Sin preguntas: instala todo lo que quepa."),
45
+ plugins: str | None = typer.Option(None, "--plugins", "-p", help="Solo estos plugins (coma): image,video,music,voice."),
46
+ ) -> None:
47
+ """Asistente de primera instalación: equipo → consejo → descarga."""
48
+ from aioli_cli.setup import run_setup
49
+
50
+ run_setup(yes=yes, plugins=plugins)
51
+
52
+
53
+ @app.command()
54
+ def version() -> None:
55
+ """Show version."""
56
+ console.print("aioli 0.1.0")
57
+
58
+
59
+ models_app = typer.Typer(help="Download + verify weight files.")
60
+ app.add_typer(models_app, name="models")
61
+
62
+
63
+ def _wanted(plugin: str | None, backend: str | None = None) -> list[tuple[str, dict]]:
64
+ from aioli_cli import _plugins # noqa: PLC0415 — ensure loaded
65
+
66
+ _plugins.load_all()
67
+ out: list[tuple[str, dict]] = []
68
+ for p in registry.all():
69
+ if p.models is None:
70
+ continue
71
+ if plugin is not None and p.name != plugin:
72
+ continue
73
+ want = backend or p.default_backend or None
74
+ for entry in p.models():
75
+ if want is not None and want != "all" and entry.get("backend", "") != want:
76
+ continue
77
+ out.append((p.name, entry))
78
+ if plugin is not None and not out:
79
+ raise typer.BadParameter(f"no model manifest for plugin '{plugin}'")
80
+ return out
81
+
82
+
83
+ def _backends() -> dict[str, list[str]]:
84
+ from aioli_cli import _plugins # noqa: PLC0415 — ensure loaded
85
+
86
+ _plugins.load_all()
87
+ out: dict[str, list[str]] = {}
88
+ for p in registry.all():
89
+ if p.models is None:
90
+ continue
91
+ seen = sorted({e.get("backend", "?") for e in p.models()})
92
+ out[p.name] = seen
93
+ return out
94
+
95
+
96
+ @models_app.command("list")
97
+ def models_list(
98
+ plugin: str | None = typer.Argument(None),
99
+ backend: str | None = typer.Option(None, "--backend", "-b", help="Engine filter (default: plugin default; 'all' = every engine)."),
100
+ ) -> None:
101
+ """Show manifest entries + local status."""
102
+ from aioli_contract import ModelFile, default_models_dir, human_gb, verify
103
+
104
+ models_dir = default_models_dir()
105
+ table = Table(title=f"aioli models → {models_dir}")
106
+ table.add_column("plugin")
107
+ table.add_column("engine")
108
+ table.add_column("file")
109
+ table.add_column("size")
110
+ table.add_column("status")
111
+ for pname, raw in _wanted(plugin, backend):
112
+ entry = ModelFile(**raw)
113
+ status = verify(entry, models_dir)
114
+ icon = {"ok": "✅", "missing": "⬇️", "corrupt": "⚠️", "unverified": "❔"}[status]
115
+ table.add_row(pname, entry.backend or "-", entry.rel, human_gb(entry.size), f"{icon} {status}")
116
+ console.print(table)
117
+ if plugin is None and backend in (None, "all"):
118
+ for pname, engines in _backends().items():
119
+ console.print(f" {pname} engines: {', '.join(engines)}")
120
+
121
+
122
+ @models_app.command("download")
123
+ def models_download(
124
+ plugin: str | None = typer.Argument(None),
125
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
126
+ backend: str | None = typer.Option(None, "--backend", "-b", help="Engine only (default: plugin default; 'all' = every engine)."),
127
+ with_optional: bool = typer.Option(False, "--with-optional", help="Include optional files (ASR, alt quants)."),
128
+ ) -> None:
129
+ """Download weights with resume + sha256 verification."""
130
+ from aioli_contract import (
131
+ ModelFile,
132
+ check_space,
133
+ default_models_dir,
134
+ download,
135
+ human_gb,
136
+ verify,
137
+ )
138
+ from rich.progress import Progress
139
+
140
+ models_dir = default_models_dir()
141
+ wanted = [(pn, ModelFile(**raw)) for pn, raw in _wanted(plugin, backend)]
142
+ if not with_optional:
143
+ skipped = [e.rel for _, e in wanted if e.optional]
144
+ wanted = [(pn, e) for pn, e in wanted if not e.optional]
145
+ for rel in skipped:
146
+ console.print(f"⏭️ optional skipped: {rel} (use --with-optional)")
147
+ missing = [(pn, e) for pn, e in wanted if verify(e, models_dir) != "ok"]
148
+ if not missing:
149
+ console.print("✅ all models present + verified")
150
+ return
151
+ total = sum(e.size for _, e in missing)
152
+ fits, _needed, free = check_space([e for _, e in missing], models_dir)
153
+ console.print(f"⬇️ {len(missing)} file(s), {human_gb(total)} → {models_dir}")
154
+ if free:
155
+ console.print(f" disk free: {human_gb(free)}")
156
+ if not fits:
157
+ raise typer.Abort(f"❌ not enough disk space for {human_gb(total)}")
158
+ notes = {registry._plugins[pn].gated_note for pn, _ in missing}
159
+ for note in sorted(n for n in notes if n):
160
+ console.print(f"🔑 {note}")
161
+ if not yes and not typer.confirm("Download now?"):
162
+ raise typer.Abort()
163
+
164
+ class _Bar:
165
+ def __init__(self, progress: Progress) -> None:
166
+ self.progress = progress
167
+ self.tasks: dict[str, int] = {}
168
+
169
+ def update(self, path, done_bytes: int, total_bytes: int) -> None:
170
+ key = str(path)
171
+ task = self.tasks.get(key)
172
+ if task is None:
173
+ task = self.progress.add_task(path.name, total=total_bytes or 1)
174
+ self.tasks[key] = task
175
+ self.progress.update(task, completed=done_bytes, total=total_bytes or 1)
176
+
177
+ with Progress() as progress:
178
+ bar = _Bar(progress)
179
+ for _pn, entry in missing:
180
+ try:
181
+ download(entry, models_dir, progress=bar)
182
+ except (IOError, ValueError) as exc:
183
+ console.print(f"❌ {entry.rel}: {exc}")
184
+ raise typer.Abort() from exc
185
+ console.print("✅ all models present + verified")
186
+
187
+
188
+ @models_app.command("verify")
189
+ def models_verify(plugin: str | None = typer.Argument(None)) -> None:
190
+ """Re-hash local files against the manifest."""
191
+ from aioli_contract import ModelFile, default_models_dir, verify
192
+
193
+ models_dir = default_models_dir()
194
+ bad = 0
195
+ for pname, raw in _wanted(plugin):
196
+ entry = ModelFile(**raw)
197
+ status = verify(entry, models_dir)
198
+ icon = {"ok": "✅", "missing": "⬇️", "corrupt": "⚠️", "unverified": "❔"}[status]
199
+ console.print(f"{icon} [{pname}] {entry.rel}: {status}")
200
+ if status == "corrupt":
201
+ bad += 1
202
+ if bad:
203
+ raise typer.Exit(code=1)
204
+
205
+
206
+ def main() -> None:
207
+ from aioli_cli import _plugins
208
+
209
+ _plugins.load_all()
210
+ registry.attach(app)
211
+ app()
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
aioli_cli/registry.py ADDED
@@ -0,0 +1,45 @@
1
+ """Plugin registry. Each modality registers a Typer app + metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+
8
+ import typer
9
+
10
+ from aioli_contract.doctor import Check
11
+
12
+
13
+ @dataclass
14
+ class Plugin:
15
+ name: str
16
+ description: str
17
+ app: typer.Typer = field(default_factory=typer.Typer)
18
+ checks: Callable[[], list[Check]] | None = None
19
+ models: Callable[[], list[dict]] | None = None
20
+ gated_note: str = ""
21
+ default_backend: str = ""
22
+
23
+
24
+ class Registry:
25
+ def __init__(self) -> None:
26
+ self._plugins: dict[str, Plugin] = {}
27
+
28
+ def register(self, plugin: Plugin) -> None:
29
+ if plugin.name in self._plugins:
30
+ raise ValueError(f"plugin already registered: {plugin.name}")
31
+ self._plugins[plugin.name] = plugin
32
+
33
+ def names(self) -> list[str]:
34
+ return sorted(self._plugins)
35
+
36
+ def all(self) -> list[Plugin]:
37
+ return [self._plugins[name] for name in self.names()]
38
+
39
+ def attach(self, app: typer.Typer) -> None:
40
+ for name in self.names():
41
+ plugin = self._plugins[name]
42
+ app.add_typer(plugin.app, name=name, help=plugin.description)
43
+
44
+
45
+ registry = Registry()
aioli_cli/setup.py ADDED
@@ -0,0 +1,151 @@
1
+ """`aioli setup`: pretty first-run wizard (probe → advise → download)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.prompt import Confirm, Prompt
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+ LEAGUE_LINES = {
14
+ "patata": "🥔 Sin NVIDIA a la vista: tiraremos de CPU (voice) y poco más.",
15
+ "modesta": "🛵 GPU modesta: motores ligeros y sin prisas.",
16
+ "capaz": "💪 GPU capaz: el set default corre bien.",
17
+ "bicha": "🔥 Menuda bicha: todo el set default sobrado.",
18
+ }
19
+
20
+
21
+ def _load():
22
+ from aioli_cli import _plugins # noqa: PLC0415
23
+ from aioli_cli.registry import registry # noqa: PLC0415
24
+
25
+ _plugins.load_all()
26
+ return registry
27
+
28
+
29
+ def machine_panel(machine) -> Panel:
30
+ lines = []
31
+ if machine.gpus:
32
+ for g in machine.gpus:
33
+ lines.append(f"🎮 {g.name} — {g.total_gb:.0f} GB ({g.free_gb:.0f} libres)")
34
+ else:
35
+ lines.append("🎮 sin GPU NVIDIA")
36
+ lines.append(f"🧠 RAM: {machine.ram_gb:.0f} GB 💾 disco libre: {machine.disk_free_gb:.0f} GB")
37
+ lines.append("")
38
+ lines.append(LEAGUE_LINES[machine.league])
39
+ return Panel("\n".join(lines), title="🧄 aioli setup — tu equipo", border_style="yellow")
40
+
41
+
42
+ def verdict_table(registry, machine) -> Table:
43
+ from aioli_contract.advise import judge
44
+
45
+ table = Table(title="¿Qué corre aquí?")
46
+ table.add_column("plugin")
47
+ table.add_column("motor")
48
+ table.add_column("")
49
+ table.add_column("veredicto")
50
+ for p in registry.all():
51
+ if p.models is None:
52
+ continue
53
+ engines = sorted({e.get("backend", "?") for e in p.models()})
54
+ for engine in engines:
55
+ v = judge(p.name, engine, machine)
56
+ table.add_row(p.name, engine, v.icon, v.headline + (f"\n[yellow]{v.detail}[/yellow]" if v.detail else ""))
57
+ return table
58
+
59
+
60
+ def run_setup(yes: bool = False, plugins: str | None = None) -> None:
61
+ from aioli_contract import ModelFile, default_models_dir, download, human_gb, verify
62
+ from aioli_contract.advise import judge, read_machine
63
+ from rich.progress import Progress
64
+
65
+ registry = _load()
66
+ models_dir = default_models_dir()
67
+ machine = read_machine(models_dir)
68
+
69
+ console.print()
70
+ console.print(machine_panel(machine))
71
+ console.print()
72
+ console.print(verdict_table(registry, machine))
73
+ console.print()
74
+
75
+ # Candidate set: default backend of each plugin that fits (✅/⚠️).
76
+ candidates: list[tuple[str, ModelFile]] = []
77
+ for p in registry.all():
78
+ if p.models is None:
79
+ continue
80
+ for raw in p.models():
81
+ entry = ModelFile(**raw)
82
+ if entry.backend != (p.default_backend or entry.backend):
83
+ continue
84
+ if entry.optional:
85
+ continue
86
+ v = judge(p.name, entry.backend, machine)
87
+ if v.fits:
88
+ candidates.append((p.name, entry))
89
+
90
+ by_plugin: dict[str, list[ModelFile]] = {}
91
+ for pname, entry in candidates:
92
+ by_plugin.setdefault(pname, []).append(entry)
93
+
94
+ if plugins:
95
+ chosen = [p.strip() for p in plugins.split(",") if p.strip()]
96
+ elif yes:
97
+ chosen = sorted(by_plugin)
98
+ else:
99
+ options = ", ".join(
100
+ f"{pn} ({human_gb(sum(e.size for e in es))})" for pn, es in sorted(by_plugin.items())
101
+ )
102
+ answer = Prompt.ask(
103
+ f"¿Qué instalo? [{options}]",
104
+ default=",".join(sorted(by_plugin)),
105
+ )
106
+ chosen = [p.strip() for p in answer.split(",") if p.strip()]
107
+
108
+ wanted = [(pn, e) for pn, e in candidates if pn in chosen]
109
+ missing = [(pn, e) for pn, e in wanted if verify(e, models_dir) != "ok"]
110
+ if not missing:
111
+ console.print(Panel("✅ Todo lo elegido ya está descargado y verificado.", border_style="green"))
112
+ return
113
+
114
+ total = sum(e.size for _, e in missing)
115
+ console.print(f"⬇️ {len(missing)} ficheros, {human_gb(total)} → {models_dir}")
116
+ notes = {registry._plugins[pn].gated_note for pn, _ in missing}
117
+ for note in sorted(n for n in notes if n):
118
+ console.print(f"🔑 {note}")
119
+ if not yes and not Confirm.ask("¿Descargo ahora?", default=True):
120
+ raise typer.Abort()
121
+
122
+ class _Bar:
123
+ def __init__(self, progress: Progress) -> None:
124
+ self.progress = progress
125
+ self.tasks: dict[str, int] = {}
126
+
127
+ def update(self, path, done_bytes: int, total_bytes: int) -> None:
128
+ key = str(path)
129
+ task = self.tasks.get(key)
130
+ if task is None:
131
+ task = self.progress.add_task(path.name, total=total_bytes or 1)
132
+ self.tasks[key] = task
133
+ self.progress.update(task, completed=done_bytes, total=total_bytes or 1)
134
+
135
+ with Progress() as progress:
136
+ for _pn, entry in missing:
137
+ try:
138
+ download(entry, models_dir, progress=_Bar(progress))
139
+ except (IOError, ValueError) as exc:
140
+ console.print(f"❌ {entry.rel}: {exc}")
141
+ raise typer.Abort() from exc
142
+
143
+ console.print(
144
+ Panel(
145
+ "✅ Listo. Prueba:\n"
146
+ " aioli doctor\n"
147
+ ' aioli image generate "un faro en tormenta" -o faro.png',
148
+ title="🧄 a generar",
149
+ border_style="green",
150
+ )
151
+ )
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: aioli-cli
3
+ Version: 0.1.0
4
+ Summary: aioli — local-first AI media suite CLI
5
+ Author: aioli contributors
6
+ License: MIT
7
+ Keywords: ai,generative,image,local-first,music,tts,video
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: aioli-contract
14
+ Requires-Dist: rich>=13
15
+ Requires-Dist: typer>=0.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # aioli (see root README)
@@ -0,0 +1,8 @@
1
+ aioli_cli/_plugins.py,sha256=e6PbLZ5ZC8hwYO-aSWjGNqXmkJaLfRte-KJdU5rFVAA,3734
2
+ aioli_cli/main.py,sha256=wl3DlcT1CWetRlMBCGnRsLrTYda7XTHxrUEudvOJt5I,7639
3
+ aioli_cli/registry.py,sha256=iCx4TPJBjnkwqWh6c67igoA5Vx_0vyYGgnnBmuOohto,1215
4
+ aioli_cli/setup.py,sha256=QXgHHzdNMo2C_w9wB55MkndEoLn2CyXT2X6zTG6oTyk,5347
5
+ aioli_cli-0.1.0.dist-info/METADATA,sha256=qIaGh4bj8U_zsOtt9LKWHHQmBXCEPRfVjjjvet8iUcU,550
6
+ aioli_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ aioli_cli-0.1.0.dist-info/entry_points.txt,sha256=XMf3mcJqGsedAQELaeg97uvrMU9LEoGeksbIzXZVpf0,46
8
+ aioli_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aioli = aioli_cli.main:main