kctl-api 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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,479 @@
1
+ """Health check commands for kctl-api.
2
+
3
+ Check connectivity to api-main, ai-main, database, and Redis.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import time
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from kctl_api.core.callbacks import AppContext
16
+ from kctl_api.core.utils import service_status_color
17
+
18
+ app = typer.Typer(name="health", help="Health checks for API services, database, and Redis.", no_args_is_help=True)
19
+
20
+ # Default watch interval in seconds
21
+ _DEFAULT_INTERVAL = 5
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _check_api(actx: AppContext) -> dict:
30
+ """Check api-main health via HTTP client."""
31
+ try:
32
+ healthy, details = actx.client.health_check()
33
+ return {"service": "api-main", "healthy": healthy, "status": details.get("status", "unknown"), **details}
34
+ except Exception as e:
35
+ return {"service": "api-main", "healthy": False, "status": "error", "error": str(e)}
36
+
37
+
38
+ def _check_ai(actx: AppContext) -> dict:
39
+ """Check ai-main health via HTTP client."""
40
+ try:
41
+ data = actx.ai_client.get("/api/v1/ai/health")
42
+ healthy = data.get("status") == "ok" if data else False
43
+ return {"service": "ai-main", "healthy": healthy, "status": data.get("status", "unknown"), **(data or {})}
44
+ except Exception as e:
45
+ return {"service": "ai-main", "healthy": False, "status": "error", "error": str(e)}
46
+
47
+
48
+ async def _check_db_async(actx: AppContext) -> dict:
49
+ """Check database connectivity with SELECT 1."""
50
+ from kctl_api.core.db import dispose_engine, execute_query
51
+
52
+ db_url = actx.database_url
53
+ if not db_url:
54
+ return {"service": "database", "healthy": False, "status": "error", "error": "No database_url configured."}
55
+
56
+ try:
57
+ rows = await execute_query(db_url, "SELECT 1 AS ping")
58
+ await dispose_engine()
59
+ ok = len(rows) > 0 and rows[0].get("ping") == 1
60
+ return {"service": "database", "healthy": ok, "status": "ok" if ok else "error"}
61
+ except ImportError as e:
62
+ return {"service": "database", "healthy": False, "status": "error", "error": str(e)}
63
+ except Exception as e:
64
+ with contextlib.suppress(Exception):
65
+ await dispose_engine()
66
+ return {"service": "database", "healthy": False, "status": "error", "error": str(e)}
67
+
68
+
69
+ async def _check_redis_async(actx: AppContext) -> dict:
70
+ """Check Redis connectivity with PING."""
71
+ from kctl_api.core.redis import close_redis, get_redis
72
+
73
+ redis_url = actx.redis_url
74
+ if not redis_url:
75
+ return {"service": "redis", "healthy": False, "status": "error", "error": "No redis_url configured."}
76
+
77
+ try:
78
+ client = get_redis(redis_url)
79
+ pong = await client.ping()
80
+ await close_redis()
81
+ return {"service": "redis", "healthy": bool(pong), "status": "ok" if pong else "error"}
82
+ except ImportError as e:
83
+ return {"service": "redis", "healthy": False, "status": "error", "error": str(e)}
84
+ except Exception as e:
85
+ with contextlib.suppress(Exception):
86
+ await close_redis()
87
+ return {"service": "redis", "healthy": False, "status": "error", "error": str(e)}
88
+
89
+
90
+ def _display_single_check(actx: AppContext, result: dict) -> None:
91
+ """Display a single health check result."""
92
+ out = actx.output
93
+ service = result["service"]
94
+ healthy = result["healthy"]
95
+ status = result.get("status", "unknown")
96
+
97
+ if actx.json_mode:
98
+ out.raw_json(result)
99
+ return
100
+
101
+ sections: list[tuple[str, list[tuple[str, str]]]] = [
102
+ (
103
+ service,
104
+ [
105
+ ("Status", service_status_color(status)),
106
+ ("Healthy", "[green]yes[/green]" if healthy else "[red]no[/red]"),
107
+ ],
108
+ ),
109
+ ]
110
+ # Add extra details
111
+ error = result.get("error")
112
+ if error:
113
+ sections[0][1].append(("Error", f"[red]{error}[/red]"))
114
+
115
+ # Include version/uptime if present
116
+ for extra_key in ("version", "uptime", "database", "redis", "workers"):
117
+ val = result.get(extra_key)
118
+ if val is not None:
119
+ sections[0][1].append((extra_key.title(), str(val)))
120
+
121
+ out.detail(title=f"Health: {service}", sections=sections, data_for_json=result)
122
+
123
+
124
+ def _display_all_results(actx: AppContext, results: list[dict]) -> None:
125
+ """Display aggregated health check results as a table."""
126
+ out = actx.output
127
+
128
+ if actx.json_mode:
129
+ out.raw_json(results)
130
+ return
131
+
132
+ rows: list[list[str]] = []
133
+ for r in results:
134
+ status = r.get("status", "unknown")
135
+ healthy = r["healthy"]
136
+ error = r.get("error", "")
137
+ rows.append(
138
+ [
139
+ r["service"],
140
+ service_status_color(status),
141
+ "[green]yes[/green]" if healthy else "[red]no[/red]",
142
+ error if error else "",
143
+ ]
144
+ )
145
+
146
+ out.table(
147
+ title="Health Check Summary",
148
+ columns=[
149
+ ("Service", "bold"),
150
+ ("Status", ""),
151
+ ("Healthy", ""),
152
+ ("Error", "red"),
153
+ ],
154
+ rows=rows,
155
+ data_for_json=results,
156
+ )
157
+
158
+ # Summary line
159
+ total = len(results)
160
+ healthy_count = sum(1 for r in results if r["healthy"])
161
+ if healthy_count == total:
162
+ out.success(f"All {total} services healthy.")
163
+ else:
164
+ out.warn(f"{healthy_count}/{total} services healthy.")
165
+
166
+
167
+ def _run_watch(
168
+ check_fn: object,
169
+ actx: AppContext,
170
+ interval: int,
171
+ *,
172
+ is_async: bool = False,
173
+ display_fn: object | None = None,
174
+ ) -> None:
175
+ """Run a check function in a watch loop with console clearing."""
176
+ import datetime as dt
177
+
178
+ console = actx.output.console
179
+ disp = display_fn or _display_single_check
180
+
181
+ try:
182
+ while True:
183
+ console.clear()
184
+ actx.output.info(f"Watch mode (every {interval}s) — {dt.datetime.now(tz=dt.UTC).strftime('%H:%M:%S UTC')}")
185
+ result = ( # type: ignore[operator]
186
+ asyncio.run(check_fn(actx)) if is_async else check_fn(actx) # type: ignore[operator]
187
+ )
188
+ disp(actx, result) # type: ignore[operator]
189
+ time.sleep(interval)
190
+ except KeyboardInterrupt:
191
+ actx.output.info("Watch stopped.")
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # api
196
+ # ---------------------------------------------------------------------------
197
+ @app.command()
198
+ def api(
199
+ ctx: typer.Context,
200
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
201
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
202
+ ) -> None:
203
+ """Check api-main health (GET /api/v1/health)."""
204
+ actx: AppContext = ctx.obj
205
+
206
+ if watch:
207
+ _run_watch(_check_api, actx, interval)
208
+ return
209
+
210
+ result = _check_api(actx)
211
+ _display_single_check(actx, result)
212
+ if not result["healthy"]:
213
+ raise typer.Exit(1)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # db
218
+ # ---------------------------------------------------------------------------
219
+ @app.command()
220
+ def db(
221
+ ctx: typer.Context,
222
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
223
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
224
+ ) -> None:
225
+ """Check database connectivity (async SELECT 1)."""
226
+ actx: AppContext = ctx.obj
227
+
228
+ if watch:
229
+ _run_watch(_check_db_async, actx, interval, is_async=True)
230
+ return
231
+
232
+ result = asyncio.run(_check_db_async(actx))
233
+ _display_single_check(actx, result)
234
+ if not result["healthy"]:
235
+ raise typer.Exit(1)
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # redis
240
+ # ---------------------------------------------------------------------------
241
+ @app.command()
242
+ def redis(
243
+ ctx: typer.Context,
244
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
245
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
246
+ ) -> None:
247
+ """Check Redis connectivity (async PING)."""
248
+ actx: AppContext = ctx.obj
249
+
250
+ if watch:
251
+ _run_watch(_check_redis_async, actx, interval, is_async=True)
252
+ return
253
+
254
+ result = asyncio.run(_check_redis_async(actx))
255
+ _display_single_check(actx, result)
256
+ if not result["healthy"]:
257
+ raise typer.Exit(1)
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # ai
262
+ # ---------------------------------------------------------------------------
263
+ @app.command()
264
+ def ai(
265
+ ctx: typer.Context,
266
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
267
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
268
+ ) -> None:
269
+ """Check ai-main health (GET /api/v1/ai/health)."""
270
+ actx: AppContext = ctx.obj
271
+
272
+ if watch:
273
+ _run_watch(_check_ai, actx, interval)
274
+ return
275
+
276
+ result = _check_ai(actx)
277
+ _display_single_check(actx, result)
278
+ if not result["healthy"]:
279
+ raise typer.Exit(1)
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # all
284
+ # ---------------------------------------------------------------------------
285
+ @app.command(name="all")
286
+ def all_checks(
287
+ ctx: typer.Context,
288
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously poll.")] = False,
289
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds.")] = _DEFAULT_INTERVAL,
290
+ ) -> None:
291
+ """Run all health checks and display aggregated results."""
292
+ actx: AppContext = ctx.obj
293
+
294
+ def _run_all(actx: AppContext) -> list[dict]:
295
+ results: list[dict] = []
296
+ # Synchronous HTTP checks
297
+ results.append(_check_api(actx))
298
+ results.append(_check_ai(actx))
299
+ # Async infrastructure checks
300
+ results.append(asyncio.run(_check_db_async(actx)))
301
+ results.append(asyncio.run(_check_redis_async(actx)))
302
+ return results
303
+
304
+ if watch:
305
+ import datetime as dt
306
+
307
+ console = actx.output.console
308
+ try:
309
+ while True:
310
+ console.clear()
311
+ actx.output.info(
312
+ f"Watch mode (every {interval}s) — {dt.datetime.now(tz=dt.UTC).strftime('%H:%M:%S UTC')}"
313
+ )
314
+ results = _run_all(actx)
315
+ _display_all_results(actx, results)
316
+ time.sleep(interval)
317
+ except KeyboardInterrupt:
318
+ actx.output.info("Watch stopped.")
319
+ return
320
+
321
+ results = _run_all(actx)
322
+ _display_all_results(actx, results)
323
+
324
+ # Exit with error code if any check failed
325
+ if not all(r["healthy"] for r in results):
326
+ raise typer.Exit(1)
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # deep
331
+ # ---------------------------------------------------------------------------
332
+ @app.command()
333
+ def deep(ctx: typer.Context) -> None:
334
+ """Deep health check — DB, Redis, all external service dependencies."""
335
+ actx: AppContext = ctx.obj
336
+ out = actx.output
337
+
338
+ out.info("Running deep health check ...")
339
+ results: list[dict] = []
340
+
341
+ # Standard checks
342
+ results.append(_check_api(actx))
343
+ results.append(_check_ai(actx))
344
+ results.append(asyncio.run(_check_db_async(actx)))
345
+ results.append(asyncio.run(_check_redis_async(actx)))
346
+
347
+ # Deep DB check — verify schema exists
348
+ async def _deep_db() -> dict:
349
+ from kctl_api.core.db import dispose_engine, execute_query
350
+
351
+ db_url = actx.database_url
352
+ if not db_url:
353
+ return {"service": "db-schema", "healthy": False, "status": "error", "error": "No database_url"}
354
+ try:
355
+ rows = await execute_query(
356
+ db_url,
357
+ "SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema = 'public'",
358
+ )
359
+ await dispose_engine()
360
+ cnt = rows[0].get("cnt", 0) if rows else 0
361
+ return {
362
+ "service": "db-schema",
363
+ "healthy": cnt > 0,
364
+ "status": "ok" if cnt > 0 else "empty",
365
+ "tables": cnt,
366
+ }
367
+ except Exception as e:
368
+ return {"service": "db-schema", "healthy": False, "status": "error", "error": str(e)}
369
+
370
+ results.append(asyncio.run(_deep_db()))
371
+
372
+ # Deep Redis check — verify can write/read
373
+ async def _deep_redis() -> dict:
374
+ from kctl_api.core.redis import close_redis, get_redis
375
+
376
+ redis_url = actx.redis_url
377
+ if not redis_url:
378
+ return {"service": "redis-rw", "healthy": False, "status": "error", "error": "No redis_url"}
379
+ try:
380
+ import time
381
+
382
+ client = get_redis(redis_url)
383
+ test_key = f"kctl:healthcheck:{int(time.time())}"
384
+ await client.set(test_key, "1", ex=10)
385
+ val = await client.get(test_key)
386
+ await client.delete(test_key)
387
+ await close_redis()
388
+ ok = val == "1"
389
+ return {"service": "redis-rw", "healthy": ok, "status": "ok" if ok else "error"}
390
+ except Exception as e:
391
+ return {"service": "redis-rw", "healthy": False, "status": "error", "error": str(e)}
392
+
393
+ results.append(asyncio.run(_deep_redis()))
394
+
395
+ _display_all_results(actx, results)
396
+
397
+ if not all(r["healthy"] for r in results):
398
+ raise typer.Exit(1)
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # deps-check
403
+ # ---------------------------------------------------------------------------
404
+ @app.command(name="deps-check")
405
+ def deps_check(ctx: typer.Context) -> None:
406
+ """Verify external service dependencies are reachable."""
407
+ actx: AppContext = ctx.obj
408
+ out = actx.output
409
+
410
+ import httpx
411
+
412
+ # Read external deps from env or config
413
+ deps_to_check: list[dict] = []
414
+
415
+ # API itself
416
+ url = actx.client.base_url.rstrip("/")
417
+ deps_to_check.append({"name": "api-main", "url": f"{url}/api/v1/health"})
418
+
419
+ # Check AI URL if configured
420
+ try:
421
+ ai_url = actx.ai_client.base_url.rstrip("/")
422
+ deps_to_check.append({"name": "ai-main", "url": f"{ai_url}/api/v1/ai/health"})
423
+ except Exception:
424
+ pass
425
+
426
+ # Check any ODOO_URL from env
427
+ import os
428
+
429
+ odoo_url = os.environ.get("ODOO_URL")
430
+ if odoo_url:
431
+ deps_to_check.append({"name": "odoo", "url": f"{odoo_url.rstrip('/')}/web/dataset/call_kw"})
432
+
433
+ results: list[dict] = []
434
+ for dep in deps_to_check:
435
+ try:
436
+ response = httpx.get(dep["url"], timeout=5, follow_redirects=True)
437
+ healthy = response.status_code < 500
438
+ results.append(
439
+ {
440
+ "service": dep["name"],
441
+ "healthy": healthy,
442
+ "status": str(response.status_code),
443
+ "url": dep["url"],
444
+ }
445
+ )
446
+ except Exception as e:
447
+ results.append(
448
+ {
449
+ "service": dep["name"],
450
+ "healthy": False,
451
+ "status": "unreachable",
452
+ "error": str(e)[:60],
453
+ "url": dep["url"],
454
+ }
455
+ )
456
+
457
+ rows = [
458
+ [
459
+ r["service"],
460
+ r.get("url", ""),
461
+ "[green]ok[/green]" if r["healthy"] else "[red]fail[/red]",
462
+ r.get("status", ""),
463
+ r.get("error", ""),
464
+ ]
465
+ for r in results
466
+ ]
467
+ out.table(
468
+ title="External Dependency Check",
469
+ columns=[("Service", "bold"), ("URL", "dim"), ("Status", ""), ("Code", ""), ("Error", "red")],
470
+ rows=rows,
471
+ data_for_json=results,
472
+ )
473
+
474
+ failed = [r for r in results if not r["healthy"]]
475
+ if failed:
476
+ out.warn(f"{len(failed)} dependency/ies unreachable.")
477
+ raise typer.Exit(1)
478
+ else:
479
+ out.success(f"All {len(results)} dependencies reachable.")
@@ -0,0 +1,169 @@
1
+ """Job queue management commands for kctl-api.
2
+
3
+ Monitor and manage ARQ background jobs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_api.core.callbacks import AppContext
13
+ from kctl_api.core.exceptions import APIError, AuthenticationError
14
+ from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
15
+
16
+ app = typer.Typer(name="jobs", help="Background job management — overview, status, enqueue.", no_args_is_help=True)
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # overview
21
+ # ---------------------------------------------------------------------------
22
+ @app.command()
23
+ def overview(ctx: typer.Context) -> None:
24
+ """Show job queue overview (GET /api/v1/jobs/queues/overview)."""
25
+ actx: AppContext = ctx.obj
26
+ out = actx.output
27
+
28
+ try:
29
+ data = actx.client.get("/api/v1/jobs/queues/overview")
30
+ except AuthenticationError as e:
31
+ out.error(f"Auth failed: {e}")
32
+ raise typer.Exit(1) from None
33
+ except KctlConnectionError as e:
34
+ out.error(f"Connection failed: {e}")
35
+ raise typer.Exit(1) from None
36
+ except APIError as e:
37
+ out.error(f"API error: {e.detail}")
38
+ raise typer.Exit(1) from None
39
+
40
+ if not data:
41
+ out.info("No queue data available.")
42
+ return
43
+
44
+ # Display as table if data is a list of queues, or as detail if single dict
45
+ if isinstance(data, list):
46
+ rows: list[list[str]] = []
47
+ for q in data:
48
+ rows.append(
49
+ [
50
+ q.get("name", ""),
51
+ str(q.get("queued", 0)),
52
+ str(q.get("active", 0)),
53
+ str(q.get("complete", 0)),
54
+ str(q.get("failed", 0)),
55
+ ]
56
+ )
57
+ out.table(
58
+ title="Job Queues Overview",
59
+ columns=[
60
+ ("Queue", "bold"),
61
+ ("Queued", ""),
62
+ ("Active", "yellow"),
63
+ ("Complete", "green"),
64
+ ("Failed", "red"),
65
+ ],
66
+ rows=rows,
67
+ data_for_json=data,
68
+ )
69
+ else:
70
+ sections: list[tuple[str, list[tuple[str, str]]]] = [
71
+ ("Queue Stats", [(k, str(v)) for k, v in data.items()]),
72
+ ]
73
+ out.detail(title="Job Queues Overview", sections=sections, data_for_json=data)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # status
78
+ # ---------------------------------------------------------------------------
79
+ @app.command()
80
+ def status(
81
+ ctx: typer.Context,
82
+ job_id: Annotated[str, typer.Argument(help="Job ID to check.")],
83
+ ) -> None:
84
+ """Get status of a specific job via GET /api/v1/jobs/{id}."""
85
+ actx: AppContext = ctx.obj
86
+ out = actx.output
87
+
88
+ try:
89
+ data = actx.client.get(f"/api/v1/jobs/{job_id}")
90
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
91
+ out.error(str(e))
92
+ raise typer.Exit(1) from None
93
+
94
+ if not data:
95
+ out.error(f"Job not found: {job_id}")
96
+ raise typer.Exit(1)
97
+
98
+ out.detail(
99
+ title=f"Job: {job_id}",
100
+ sections=[
101
+ ("Details", [(k, str(v)) for k, v in data.items()]),
102
+ ],
103
+ data_for_json=data,
104
+ )
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # enqueue
109
+ # ---------------------------------------------------------------------------
110
+ @app.command()
111
+ def enqueue(
112
+ ctx: typer.Context,
113
+ function: Annotated[str, typer.Argument(help="Function name to enqueue.")],
114
+ data_json: Annotated[str | None, typer.Option("--data", "-d", help="JSON payload for the job.")] = None,
115
+ ) -> None:
116
+ """Enqueue a new background job (admin-only) via POST /api/v1/jobs/enqueue."""
117
+ actx: AppContext = ctx.obj
118
+ out = actx.output
119
+
120
+ import json
121
+
122
+ payload: dict = {"function": function}
123
+ if data_json:
124
+ try:
125
+ payload["data"] = json.loads(data_json)
126
+ except json.JSONDecodeError as e:
127
+ out.error(f"Invalid JSON: {e}")
128
+ raise typer.Exit(1) from None
129
+
130
+ try:
131
+ result = actx.client.post("/api/v1/jobs/enqueue", json=payload)
132
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
133
+ out.error(str(e))
134
+ raise typer.Exit(1) from None
135
+
136
+ out.success(f"Job enqueued: {function}")
137
+ if actx.json_mode:
138
+ out.raw_json(result)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # get
143
+ # ---------------------------------------------------------------------------
144
+ @app.command(name="get")
145
+ def get_job(
146
+ ctx: typer.Context,
147
+ job_id: Annotated[str, typer.Argument(help="Job ID.")],
148
+ ) -> None:
149
+ """Get detailed job information via GET /api/v1/jobs/{id}."""
150
+ actx: AppContext = ctx.obj
151
+ out = actx.output
152
+
153
+ try:
154
+ data = actx.client.get(f"/api/v1/jobs/{job_id}")
155
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
156
+ out.error(str(e))
157
+ raise typer.Exit(1) from None
158
+
159
+ if not data:
160
+ out.error(f"Job not found: {job_id}")
161
+ raise typer.Exit(1)
162
+
163
+ out.detail(
164
+ title=f"Job: {job_id}",
165
+ sections=[
166
+ ("Details", [(k, str(v)) for k, v in data.items()]),
167
+ ],
168
+ data_for_json=data,
169
+ )