nerdit 0.2.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.
nerdit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Nerdit — Le moteur de l'IA locale."""
2
+
3
+ __version__ = "0.2.0"
nerdit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from nerdit.cli.app import main
2
+
3
+ main()
nerdit/cli/__init__.py ADDED
File without changes
nerdit/cli/app.py ADDED
@@ -0,0 +1,61 @@
1
+ """Nerdit CLI application."""
2
+
3
+ import typer
4
+
5
+ import nerdit
6
+
7
+ app = typer.Typer(
8
+ name="nerdit",
9
+ help="The local AI engine — HPC orchestrator for local GPUs",
10
+ no_args_is_help=True,
11
+ )
12
+
13
+
14
+ def version_callback(value: bool) -> None:
15
+ if value:
16
+ typer.echo(f"nerdit {nerdit.__version__}")
17
+ raise typer.Exit()
18
+
19
+
20
+ @app.callback()
21
+ def main_callback(
22
+ version: bool = typer.Option(
23
+ False,
24
+ "--version",
25
+ "-v",
26
+ help="Show version",
27
+ callback=version_callback,
28
+ is_eager=True,
29
+ ),
30
+ ) -> None:
31
+ pass
32
+
33
+
34
+ # Import and register commands (deferred to avoid circular imports)
35
+ def _register_commands() -> None:
36
+ from nerdit.cli.commands.check_deps import check_deps # noqa: F811
37
+ from nerdit.cli.commands.cleanup import cleanup # noqa: F811
38
+ from nerdit.cli.commands.connect import connect # noqa: F811
39
+ from nerdit.cli.commands.init import init # noqa: F811
40
+ from nerdit.cli.commands.logs import logs # noqa: F811
41
+ from nerdit.cli.commands.run import run # noqa: F811
42
+ from nerdit.cli.commands.status import status # noqa: F811
43
+ from nerdit.cli.commands.stop import stop # noqa: F811
44
+ from nerdit.cli.commands.token import token # noqa: F811
45
+
46
+ app.command(name="check-deps")(check_deps)
47
+ app.command()(init)
48
+ app.command()(run)
49
+ app.command()(status)
50
+ app.command()(logs)
51
+ app.command()(stop)
52
+ app.command()(cleanup)
53
+ app.command()(connect)
54
+ app.command()(token)
55
+
56
+
57
+ _register_commands()
58
+
59
+
60
+ def main() -> None:
61
+ app()
nerdit/cli/client.py ADDED
@@ -0,0 +1,208 @@
1
+ """HTTP client for communicating with nerditd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+
7
+ import httpx
8
+
9
+ from nerdit.config.defaults import DEFAULT_HOST, DEFAULT_PORT
10
+
11
+
12
+ class NerditClient:
13
+ """Async HTTP client wrapping the nerditd REST API."""
14
+
15
+ def __init__(
16
+ self,
17
+ host: str = DEFAULT_HOST,
18
+ port: int = DEFAULT_PORT,
19
+ token: str | None = None,
20
+ ) -> None:
21
+ self._base_url = f"http://{host}:{port}"
22
+ self._headers: dict[str, str] = {}
23
+ if token:
24
+ self._headers["Authorization"] = f"Bearer {token}"
25
+
26
+ def _client(self, **kwargs) -> httpx.AsyncClient:
27
+ """Create an httpx.AsyncClient with auth headers."""
28
+ return httpx.AsyncClient(headers=self._headers, **kwargs)
29
+
30
+ async def health(self) -> dict:
31
+ """Call ``GET /health`` and return the parsed JSON response."""
32
+ async with self._client() as client:
33
+ resp = await client.get(f"{self._base_url}/health", timeout=5.0)
34
+ resp.raise_for_status()
35
+ return resp.json()
36
+
37
+ async def list_gpus(self) -> list[dict]:
38
+ """Fetch all GPUs from the daemon."""
39
+ async with self._client() as client:
40
+ resp = await client.get(f"{self._base_url}/gpus", timeout=5.0)
41
+ resp.raise_for_status()
42
+ return resp.json()
43
+
44
+ async def create_job(
45
+ self,
46
+ script_path: str,
47
+ *,
48
+ name: str | None = None,
49
+ gpu_count: int = 1,
50
+ priority: int = 5,
51
+ image: str | None = None,
52
+ max_retries: int = 3,
53
+ volumes: dict[str, str] | None = None,
54
+ tags: list[str] | None = None,
55
+ preemptible: bool = False,
56
+ max_runtime: str | None = None,
57
+ time_window: str | None = None,
58
+ ) -> dict:
59
+ """Submit a new job via ``POST /jobs``."""
60
+ payload: dict = {
61
+ "script_path": script_path,
62
+ "gpu_count": gpu_count,
63
+ "priority": priority,
64
+ "max_retries": max_retries,
65
+ }
66
+ if name:
67
+ payload["name"] = name
68
+ if image:
69
+ payload["image"] = image
70
+ if volumes:
71
+ payload["volumes"] = volumes
72
+ if tags:
73
+ payload["tags"] = tags
74
+ if preemptible:
75
+ payload["preemptible"] = True
76
+ if max_runtime:
77
+ payload["max_runtime"] = max_runtime
78
+ if time_window:
79
+ payload["time_window"] = time_window
80
+
81
+ async with self._client() as client:
82
+ resp = await client.post(f"{self._base_url}/jobs", json=payload, timeout=10.0)
83
+ resp.raise_for_status()
84
+ return resp.json()
85
+
86
+ async def upload_job(
87
+ self,
88
+ zip_bytes: bytes,
89
+ script_name: str,
90
+ *,
91
+ name: str | None = None,
92
+ gpu_count: int = 1,
93
+ priority: int = 5,
94
+ image: str | None = None,
95
+ max_retries: int = 3,
96
+ volumes: dict[str, str] | None = None,
97
+ tags: list[str] | None = None,
98
+ preemptible: bool = False,
99
+ max_runtime: str | None = None,
100
+ time_window: str | None = None,
101
+ ) -> dict:
102
+ """Upload a ZIP archive and create a job on the remote daemon."""
103
+ import json
104
+
105
+ files = {"archive": ("scripts.zip", zip_bytes, "application/zip")}
106
+ data: dict[str, str] = {
107
+ "script_name": script_name,
108
+ "gpu_count": str(gpu_count),
109
+ "priority": str(priority),
110
+ "max_retries": str(max_retries),
111
+ }
112
+ if name:
113
+ data["name"] = name
114
+ if image:
115
+ data["image"] = image
116
+ if volumes:
117
+ data["volumes"] = json.dumps(volumes)
118
+ if tags:
119
+ data["tags"] = json.dumps(tags)
120
+ if preemptible:
121
+ data["preemptible"] = "true"
122
+ if max_runtime:
123
+ data["max_runtime"] = max_runtime
124
+ if time_window:
125
+ data["time_window"] = time_window
126
+
127
+ async with self._client() as client:
128
+ resp = await client.post(
129
+ f"{self._base_url}/jobs/upload",
130
+ files=files,
131
+ data=data,
132
+ timeout=120.0,
133
+ )
134
+ resp.raise_for_status()
135
+ return resp.json()
136
+
137
+ async def list_jobs(self, status: str | None = None) -> list[dict]:
138
+ """List jobs, optionally filtered by status."""
139
+ params = {}
140
+ if status:
141
+ params["status"] = status
142
+
143
+ async with self._client() as client:
144
+ resp = await client.get(f"{self._base_url}/jobs", params=params, timeout=5.0)
145
+ resp.raise_for_status()
146
+ return resp.json()
147
+
148
+ async def get_job(self, job_id: str) -> dict:
149
+ """Fetch a single job by ID."""
150
+ async with self._client() as client:
151
+ resp = await client.get(f"{self._base_url}/jobs/{job_id}", timeout=5.0)
152
+ resp.raise_for_status()
153
+ return resp.json()
154
+
155
+ async def stop_job(self, job_id: str) -> dict:
156
+ """Request the daemon to stop a job."""
157
+ async with self._client() as client:
158
+ resp = await client.post(f"{self._base_url}/jobs/{job_id}/stop", timeout=10.0)
159
+ resp.raise_for_status()
160
+ return resp.json()
161
+
162
+ async def get_logs(self, job_id: str, since_id: int = 0) -> list[dict]:
163
+ """Fetch log entries for a job since a given ID."""
164
+ async with self._client() as client:
165
+ resp = await client.get(
166
+ f"{self._base_url}/jobs/{job_id}/logs",
167
+ params={"since_id": since_id},
168
+ timeout=5.0,
169
+ )
170
+ resp.raise_for_status()
171
+ return resp.json()
172
+
173
+ async def stream_logs(self, job_id: str) -> AsyncIterator[dict]:
174
+ """Consume SSE log stream from the daemon. Yields parsed event dicts."""
175
+ import httpx_sse
176
+
177
+ timeout = httpx.Timeout(5.0, read=None) # no read timeout for SSE
178
+ async with self._client(timeout=timeout) as client:
179
+ async with httpx_sse.aconnect_sse(
180
+ client, "GET", f"{self._base_url}/jobs/{job_id}/logs/stream"
181
+ ) as sse:
182
+ async for event in sse.aiter_sse():
183
+ import json
184
+
185
+ if event.event == "done":
186
+ yield {"event": "done", "data": json.loads(event.data)}
187
+ return
188
+ if event.event == "log":
189
+ yield {"event": "log", "data": json.loads(event.data)}
190
+
191
+ async def cleanup(self) -> dict:
192
+ """Request the daemon to stop all active jobs."""
193
+ async with self._client() as client:
194
+ resp = await client.post(f"{self._base_url}/cleanup", timeout=10.0)
195
+ resp.raise_for_status()
196
+ return resp.json()
197
+
198
+
199
+ def get_configured_client() -> NerditClient:
200
+ """Create a NerditClient from ~/.nerdit/config.toml settings.
201
+
202
+ If [client].remote_host is set, connects to the remote daemon with token.
203
+ Otherwise falls back to localhost (v0.1 compat).
204
+ """
205
+ from nerdit.config.settings import get_client_config
206
+
207
+ host, port, token = get_client_config()
208
+ return NerditClient(host=host, port=port, token=token)
File without changes
@@ -0,0 +1,363 @@
1
+ """nerdit check-deps — Check and optionally install system dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.table import Table
12
+
13
+ from nerdit.cli.display import console
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Check functions — each returns (ok, detail_string)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _LIB_DIRS = ["/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib"]
20
+
21
+
22
+ def _check_python() -> tuple[bool, str]:
23
+ """Check Python >= 3.11."""
24
+ major, minor, micro = sys.version_info[0], sys.version_info[1], sys.version_info[2]
25
+ ver = f"{major}.{minor}.{micro}"
26
+ if major >= 3 and minor >= 11:
27
+ return True, ver
28
+ return False, f"{ver} (need >=3.11)"
29
+
30
+
31
+ def _check_nvidia_driver() -> tuple[bool, str]:
32
+ """Check NVIDIA driver via nvidia-smi."""
33
+ if shutil.which("nvidia-smi") is None:
34
+ return False, "nvidia-smi not found"
35
+ try:
36
+ result = subprocess.run(
37
+ ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=10,
41
+ )
42
+ if result.returncode != 0:
43
+ return False, "nvidia-smi failed"
44
+ driver_ver = result.stdout.strip().splitlines()[0]
45
+ major = int(driver_ver.split(".")[0])
46
+ if major >= 535:
47
+ return True, driver_ver
48
+ return False, f"{driver_ver} (need >=535)"
49
+ except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, IndexError):
50
+ return False, "nvidia-smi error"
51
+
52
+
53
+ def _check_nvidia_lib() -> tuple[bool, str]:
54
+ """Check libnvidia-ml.so.1 on the host."""
55
+ for d in _LIB_DIRS:
56
+ p = Path(d) / "libnvidia-ml.so.1"
57
+ if p.exists():
58
+ return True, str(p)
59
+ return False, "not found"
60
+
61
+
62
+ def _check_docker() -> tuple[bool, str]:
63
+ """Check Docker Engine is running."""
64
+ if shutil.which("docker") is None:
65
+ return False, "docker not found"
66
+ try:
67
+ result = subprocess.run(
68
+ ["docker", "info"],
69
+ capture_output=True,
70
+ timeout=10,
71
+ )
72
+ if result.returncode != 0:
73
+ return False, "not running or no permission"
74
+ ver_result = subprocess.run(
75
+ ["docker", "version", "--format", "{{.Server.Version}}"],
76
+ capture_output=True,
77
+ text=True,
78
+ timeout=10,
79
+ )
80
+ ver = ver_result.stdout.strip() if ver_result.returncode == 0 else "unknown"
81
+ return True, ver
82
+ except (subprocess.TimeoutExpired, FileNotFoundError):
83
+ return False, "error"
84
+
85
+
86
+ def _check_nvidia_toolkit() -> tuple[bool, str]:
87
+ """Check NVIDIA Container Toolkit in Docker runtimes."""
88
+ # If Docker CLI itself is not available, skip this check.
89
+ if shutil.which("docker") is None:
90
+ return False, "skipped (Docker not installed)"
91
+ try:
92
+ result = subprocess.run(
93
+ ["docker", "info", "--format", "{{json .Runtimes}}"],
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=10,
97
+ )
98
+ if result.returncode != 0:
99
+ detail = "skipped (docker info failed; is Docker running / do you have permission?)"
100
+ if result.stderr:
101
+ stderr = result.stderr.strip()
102
+ if stderr:
103
+ detail = f"{detail}: {stderr}"
104
+ return False, detail
105
+ if "nvidia" in result.stdout:
106
+ return True, "registered"
107
+ return False, "not found in Docker runtimes"
108
+ except (subprocess.TimeoutExpired, FileNotFoundError):
109
+ return False, "skipped (Docker not available)"
110
+
111
+
112
+ def _check_runtime_image() -> tuple[bool, str]:
113
+ """Check if nerdit-runtime:0.1 Docker image exists."""
114
+ try:
115
+ result = subprocess.run(
116
+ ["docker", "image", "inspect", "nerdit-runtime:0.1"],
117
+ capture_output=True,
118
+ timeout=10,
119
+ )
120
+ if result.returncode == 0:
121
+ return True, "available"
122
+ return False, "image not found"
123
+ except (subprocess.TimeoutExpired, FileNotFoundError):
124
+ return False, "skipped (Docker not available)"
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Install helpers
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _run_install(description: str, cmd: list[str], *, shell: bool = False) -> bool:
133
+ """Run an install command with user-visible output. Returns success."""
134
+ console.print(f" [dim]Running: {' '.join(cmd)}[/dim]")
135
+ try:
136
+ if shell:
137
+ result = subprocess.run(
138
+ " ".join(cmd),
139
+ shell=True,
140
+ timeout=300, # noqa: S602
141
+ )
142
+ else:
143
+ result = subprocess.run(cmd, timeout=300)
144
+ return result.returncode == 0
145
+ except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
146
+ console.print(f" [red]Failed: {exc}[/red]")
147
+ return False
148
+
149
+
150
+ def _install_python() -> bool:
151
+ return _run_install(
152
+ "Python 3.11",
153
+ ["sudo", "apt-get", "install", "-y", "python3.11"],
154
+ )
155
+
156
+
157
+ def _install_nvidia_driver() -> bool:
158
+ ok = _run_install(
159
+ "NVIDIA Driver",
160
+ ["sudo", "apt-get", "install", "-y", "nvidia-driver-535"],
161
+ )
162
+ if ok:
163
+ console.print(" [yellow]A reboot may be required.[/yellow]")
164
+ return ok
165
+
166
+
167
+ def _install_docker() -> bool:
168
+ ok = _run_install(
169
+ "Docker Engine",
170
+ ["sh", "-c", "curl -fsSL https://get.docker.com | sudo sh"],
171
+ )
172
+ if ok:
173
+ subprocess.run(
174
+ ["sudo", "usermod", "-aG", "docker", str(Path.home().name)],
175
+ timeout=10,
176
+ )
177
+ console.print(" [yellow]Log out and back in for group changes to take effect.[/yellow]")
178
+ return ok
179
+
180
+
181
+ def _install_nvidia_toolkit() -> bool:
182
+ cmds = [
183
+ "curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey"
184
+ " | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg",
185
+ "curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/"
186
+ "nvidia-container-toolkit.list"
187
+ " | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/"
188
+ "nvidia-container-toolkit-keyring.gpg] https://#g'"
189
+ " | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list"
190
+ " > /dev/null",
191
+ "sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit",
192
+ "sudo nvidia-ctk runtime configure --runtime=docker",
193
+ "sudo systemctl restart docker",
194
+ ]
195
+ for cmd in cmds:
196
+ result = subprocess.run(cmd, shell=True, timeout=120) # noqa: S602
197
+ if result.returncode != 0:
198
+ console.print(f" [red]Step failed: {cmd}[/red]")
199
+ return False
200
+ return True
201
+
202
+
203
+ def _find_dockerfile() -> Path | None:
204
+ candidates = [
205
+ Path("docker/Dockerfile"),
206
+ Path.cwd() / "docker" / "Dockerfile",
207
+ ]
208
+ pkg_root = Path(__file__).resolve().parents[4]
209
+ candidates.append(pkg_root / "docker" / "Dockerfile")
210
+ for path in candidates:
211
+ resolved = path.resolve()
212
+ if resolved.is_file():
213
+ return resolved
214
+ return None
215
+
216
+
217
+ def _install_runtime_image() -> bool:
218
+ dockerfile = _find_dockerfile()
219
+ if dockerfile is None:
220
+ console.print(" [red]Dockerfile not found. Cannot build image.[/red]")
221
+ return False
222
+ return _run_install(
223
+ "nerdit-runtime:0.1",
224
+ ["docker", "build", "-t", "nerdit-runtime:0.1", str(dockerfile.parent)],
225
+ )
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Dependency registry
230
+ # ---------------------------------------------------------------------------
231
+
232
+ _DEPS: list[
233
+ tuple[str, callable, callable | None, str] # type: ignore[type-arg]
234
+ ] = [
235
+ (
236
+ "Python >=3.11",
237
+ _check_python,
238
+ _install_python,
239
+ "sudo apt install python3.11",
240
+ ),
241
+ (
242
+ "NVIDIA Driver (>=535)",
243
+ _check_nvidia_driver,
244
+ _install_nvidia_driver,
245
+ "sudo apt install nvidia-driver-535",
246
+ ),
247
+ (
248
+ "libnvidia-ml.so.1",
249
+ _check_nvidia_lib,
250
+ None, # installed with driver
251
+ "Provided by NVIDIA driver — try: sudo ldconfig",
252
+ ),
253
+ (
254
+ "Docker Engine",
255
+ _check_docker,
256
+ _install_docker,
257
+ "curl -fsSL https://get.docker.com | sudo sh",
258
+ ),
259
+ (
260
+ "NVIDIA Container Toolkit",
261
+ _check_nvidia_toolkit,
262
+ _install_nvidia_toolkit,
263
+ "sudo apt install nvidia-container-toolkit",
264
+ ),
265
+ (
266
+ "nerdit-runtime:0.1",
267
+ _check_runtime_image,
268
+ _install_runtime_image,
269
+ "docker build -t nerdit-runtime:0.1 docker/",
270
+ ),
271
+ ]
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Command
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ def check_deps(
280
+ install: bool = typer.Option(
281
+ False,
282
+ "--install",
283
+ "-i",
284
+ help="Offer to install missing dependencies (with confirmation)",
285
+ ),
286
+ ) -> None:
287
+ """Check system dependencies and optionally install missing ones."""
288
+ results: list[tuple[str, bool, str]] = []
289
+ for name, check_fn, _, _ in _DEPS:
290
+ ok, detail = check_fn()
291
+ results.append((name, ok, detail))
292
+
293
+ # Display table
294
+ table = Table(title="Nerdit — Dependency Check")
295
+ table.add_column("Dependency", style="bold")
296
+ table.add_column("Status", justify="center")
297
+ table.add_column("Detail")
298
+
299
+ for name, ok, detail in results:
300
+ status = "[green]✓[/green]" if ok else "[red]✗[/red]"
301
+ detail_style = "" if ok else "[yellow]"
302
+ detail_end = "" if ok else "[/yellow]"
303
+ table.add_row(name, status, f"{detail_style}{detail}{detail_end}")
304
+
305
+ console.print()
306
+ console.print(table)
307
+ console.print()
308
+
309
+ missing = [(i, name) for i, (name, ok, _) in enumerate(results) if not ok]
310
+ if not missing:
311
+ console.print("[bold green]All dependencies are installed.[/bold green]")
312
+ return
313
+
314
+ console.print(
315
+ f"[yellow]{len(missing)} missing dependenc{'y' if len(missing) == 1 else 'ies'}.[/yellow]"
316
+ )
317
+
318
+ if not install:
319
+ console.print("[dim]Run with --install to interactively install missing deps.[/dim]")
320
+ console.print()
321
+ for idx, name in missing:
322
+ _, _, _, hint = _DEPS[idx]
323
+ console.print(f" [dim]{name}:[/dim] {hint}")
324
+ raise typer.Exit(1)
325
+
326
+ # Interactive install flow
327
+ console.print()
328
+ any_installed = False
329
+ for idx, name in missing:
330
+ _, _, install_fn, hint = _DEPS[idx]
331
+ if install_fn is None:
332
+ console.print(f"[dim]{name}: no automatic installer — {hint}[/dim]")
333
+ continue
334
+ if typer.confirm(f"Install {name}?", default=False):
335
+ success = install_fn()
336
+ if success:
337
+ console.print(f" [green]✓ {name} installed[/green]")
338
+ any_installed = True
339
+ else:
340
+ console.print(f" [red]✗ {name} installation failed[/red]")
341
+ else:
342
+ console.print(f" [dim]Skipped {name}[/dim]")
343
+
344
+ # Re-check if anything was installed
345
+ if any_installed:
346
+ console.print("\n[bold]Re-checking...[/bold]\n")
347
+ still_missing = 0
348
+ for name, check_fn, _, _ in _DEPS:
349
+ ok, detail = check_fn()
350
+ status = "[green]✓[/green]" if ok else "[red]✗[/red]"
351
+ console.print(f" {status} {name}: {detail}")
352
+ if not ok:
353
+ still_missing += 1
354
+ if still_missing == 0:
355
+ console.print("\n[bold green]All dependencies are now installed.[/bold green]")
356
+ else:
357
+ console.print(
358
+ f"\n[yellow]{still_missing} dependenc{'y' if still_missing == 1 else 'ies'}"
359
+ " still missing.[/yellow]"
360
+ )
361
+ raise typer.Exit(1)
362
+ else:
363
+ raise typer.Exit(1)
@@ -0,0 +1,30 @@
1
+ """nerdit cleanup — Force-stop all running jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from nerdit.cli.display import console
10
+
11
+
12
+ def cleanup() -> None:
13
+ """Force-stop all jobs and release resources."""
14
+ asyncio.run(_cleanup_async())
15
+
16
+
17
+ async def _cleanup_async() -> None:
18
+ from nerdit.cli.client import get_configured_client
19
+
20
+ client = get_configured_client()
21
+ try:
22
+ result = await client.cleanup()
23
+ count = result.get("stopped", 0)
24
+ if count > 0:
25
+ console.print(f"[green]{count} job(s) stopped[/green]")
26
+ else:
27
+ console.print("[dim]No running jobs to stop[/dim]")
28
+ except Exception as exc:
29
+ console.print(f"[red]Error:[/red] {exc}")
30
+ raise typer.Exit(1)