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,443 @@
1
+ """Config management commands for kctl-api.
2
+
3
+ Manage connection profiles, switch environments, and test connectivity.
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.config import (
14
+ CONFIG_FILE,
15
+ SERVICE_KEY,
16
+ ServiceConfig,
17
+ _is_service_scoped,
18
+ get_all_services_in_profile,
19
+ get_default_profile,
20
+ get_profile_names,
21
+ get_service_config,
22
+ load_raw_config,
23
+ remove_profile,
24
+ resolve_active_profile_name,
25
+ save_raw_config,
26
+ set_default_profile,
27
+ set_service_config,
28
+ )
29
+ from kctl_api.core.utils import mask_secret
30
+
31
+ app = typer.Typer(name="config", help="Manage connection profiles and configuration.", no_args_is_help=True)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # init
36
+ # ---------------------------------------------------------------------------
37
+ @app.command()
38
+ def init(ctx: typer.Context) -> None:
39
+ """Interactive setup wizard: prompt for URL, email, password; login to get JWT; save profile."""
40
+ actx: AppContext = ctx.obj
41
+ out = actx.output
42
+
43
+ out.header("kctl-api Setup Wizard")
44
+
45
+ profile_name = typer.prompt("Profile name", default="default")
46
+ url = typer.prompt("API URL (api-main)", default="http://localhost:8000")
47
+ ai_url = typer.prompt("AI URL (ai-main, optional)", default="")
48
+ email = typer.prompt("Email")
49
+ password = typer.prompt("Password", hide_input=True)
50
+
51
+ out.info(f"Authenticating against {url} ...")
52
+
53
+ from kctl_api.core.client import ApiClient
54
+
55
+ try:
56
+ client = ApiClient(base_url=url)
57
+ tokens = client.login(email, password)
58
+ client.close()
59
+ except Exception as e:
60
+ out.error(f"Login failed: {e}")
61
+ raise typer.Exit(1) from None
62
+
63
+ api_key = tokens.get("access_token", "")
64
+ svc = ServiceConfig(url=url, ai_url=ai_url, api_key=api_key)
65
+ set_service_config(profile_name, svc)
66
+ set_default_profile(profile_name)
67
+
68
+ out.success(f"Profile '{profile_name}' created and set as default.")
69
+ out.info(f"Config saved to {CONFIG_FILE}")
70
+
71
+ if actx.json_mode:
72
+ out.raw_json(
73
+ {
74
+ "profile": profile_name,
75
+ "url": url,
76
+ "ai_url": ai_url,
77
+ "authenticated": True,
78
+ "config_file": str(CONFIG_FILE),
79
+ }
80
+ )
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # add
85
+ # ---------------------------------------------------------------------------
86
+ @app.command()
87
+ def add(
88
+ ctx: typer.Context,
89
+ name: Annotated[str, typer.Argument(help="Profile name to create.")],
90
+ url: Annotated[str, typer.Option("--url", help="API base URL.")] = "",
91
+ ai_url: Annotated[str, typer.Option("--ai-url", help="AI API base URL.")] = "",
92
+ api_key: Annotated[str, typer.Option("--api-key", help="API key or JWT token.")] = "",
93
+ database_url: Annotated[str, typer.Option("--database-url", help="PostgreSQL async URL.")] = "",
94
+ redis_url: Annotated[str, typer.Option("--redis-url", help="Redis URL.")] = "",
95
+ default: Annotated[bool, typer.Option("--default", help="Set as default profile.")] = False,
96
+ ) -> None:
97
+ """Add a new connection profile."""
98
+ actx: AppContext = ctx.obj
99
+ out = actx.output
100
+
101
+ existing = get_profile_names()
102
+ if name in existing:
103
+ out.warn(f"Profile '{name}' already exists and will be overwritten.")
104
+
105
+ svc = ServiceConfig(
106
+ url=url,
107
+ ai_url=ai_url,
108
+ api_key=api_key,
109
+ database_url=database_url,
110
+ redis_url=redis_url,
111
+ )
112
+ set_service_config(name, svc)
113
+
114
+ if default:
115
+ set_default_profile(name)
116
+ out.success(f"Profile '{name}' added and set as default.")
117
+ else:
118
+ out.success(f"Profile '{name}' added.")
119
+
120
+ if actx.json_mode:
121
+ out.raw_json({"profile": name, "default": default})
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # use
126
+ # ---------------------------------------------------------------------------
127
+ @app.command()
128
+ def use(
129
+ ctx: typer.Context,
130
+ name: Annotated[str, typer.Argument(help="Profile name to activate.")],
131
+ ) -> None:
132
+ """Switch the default profile."""
133
+ actx: AppContext = ctx.obj
134
+ out = actx.output
135
+
136
+ existing = get_profile_names()
137
+ if name not in existing:
138
+ out.error(f"Profile '{name}' does not exist. Available: {', '.join(existing) or '(none)'}")
139
+ raise typer.Exit(1)
140
+
141
+ set_default_profile(name)
142
+ out.success(f"Default profile set to '{name}'.")
143
+
144
+ if actx.json_mode:
145
+ out.raw_json({"default_profile": name})
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # remove
150
+ # ---------------------------------------------------------------------------
151
+ @app.command(name="remove")
152
+ def remove_cmd(
153
+ ctx: typer.Context,
154
+ name: Annotated[str, typer.Argument(help="Profile name to delete.")],
155
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
156
+ ) -> None:
157
+ """Delete a connection profile."""
158
+ actx: AppContext = ctx.obj
159
+ out = actx.output
160
+
161
+ existing = get_profile_names()
162
+ if name not in existing:
163
+ out.error(f"Profile '{name}' does not exist.")
164
+ raise typer.Exit(1)
165
+
166
+ if not force:
167
+ confirm = typer.confirm(f"Delete profile '{name}'?", default=False)
168
+ if not confirm:
169
+ out.info("Cancelled.")
170
+ raise typer.Exit(0)
171
+
172
+ remove_profile(name)
173
+ out.success(f"Profile '{name}' removed.")
174
+
175
+ if actx.json_mode:
176
+ out.raw_json({"removed": name})
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # test
181
+ # ---------------------------------------------------------------------------
182
+ @app.command()
183
+ def test(ctx: typer.Context) -> None:
184
+ """Test current profile connectivity (GET /api/v1/health)."""
185
+ actx: AppContext = ctx.obj
186
+ out = actx.output
187
+
188
+ profile_name = resolve_active_profile_name(actx.profile)
189
+ svc = get_service_config(profile_name)
190
+
191
+ if not svc.url:
192
+ out.error(f"Profile '{profile_name}' has no URL configured. Run: kctl-api config add {profile_name} --url ...")
193
+ raise typer.Exit(1)
194
+
195
+ out.info(f"Testing connectivity to {svc.url} (profile: {profile_name}) ...")
196
+
197
+ from kctl_api.core.client import ApiClient
198
+
199
+ try:
200
+ client = ApiClient(base_url=svc.url, api_key=svc.api_key)
201
+ healthy, details = client.health_check()
202
+ client.close()
203
+ except Exception as e:
204
+ out.error(f"Connection failed: {e}")
205
+ if actx.json_mode:
206
+ out.raw_json({"profile": profile_name, "url": svc.url, "healthy": False, "error": str(e)})
207
+ raise typer.Exit(1) from None
208
+
209
+ status = details.get("status", "unknown")
210
+ if healthy:
211
+ out.success(f"API is healthy (status: {status})")
212
+ else:
213
+ out.error(f"API returned unhealthy status: {status}")
214
+
215
+ if actx.json_mode:
216
+ out.raw_json({"profile": profile_name, "url": svc.url, "healthy": healthy, **details})
217
+ elif not healthy:
218
+ raise typer.Exit(1)
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # current
223
+ # ---------------------------------------------------------------------------
224
+ @app.command()
225
+ def current(ctx: typer.Context) -> None:
226
+ """Show active profile name and connection status."""
227
+ actx: AppContext = ctx.obj
228
+ out = actx.output
229
+
230
+ profile_name = resolve_active_profile_name(actx.profile)
231
+ svc = get_service_config(profile_name)
232
+
233
+ connected = False
234
+ status_text = "not tested"
235
+ if svc.url:
236
+ from kctl_api.core.client import ApiClient
237
+
238
+ try:
239
+ client = ApiClient(base_url=svc.url, api_key=svc.api_key)
240
+ healthy, _details = client.health_check()
241
+ client.close()
242
+ connected = healthy
243
+ status_text = "[green]connected[/green]" if healthy else "[red]unreachable[/red]"
244
+ except Exception:
245
+ status_text = "[red]unreachable[/red]"
246
+
247
+ data = {
248
+ "profile": profile_name,
249
+ "url": svc.url or "(not set)",
250
+ "connected": connected,
251
+ }
252
+
253
+ out.detail(
254
+ title="Active Profile",
255
+ sections=[
256
+ (
257
+ "Connection",
258
+ [
259
+ ("Profile", profile_name),
260
+ ("URL", svc.url or "(not set)"),
261
+ ("Status", status_text),
262
+ ("Default", str(profile_name == get_default_profile())),
263
+ ],
264
+ ),
265
+ ],
266
+ data_for_json=data,
267
+ )
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # show
272
+ # ---------------------------------------------------------------------------
273
+ @app.command()
274
+ def show(ctx: typer.Context) -> None:
275
+ """Show full configuration with masked secrets."""
276
+ actx: AppContext = ctx.obj
277
+ out = actx.output
278
+
279
+ profile_name = resolve_active_profile_name(actx.profile)
280
+ all_services = get_all_services_in_profile(profile_name)
281
+
282
+ sections: list[tuple[str, list[tuple[str, str]]]] = []
283
+ json_data: dict[str, object] = {"profile": profile_name, "config_file": str(CONFIG_FILE), "services": {}}
284
+
285
+ sections.append(
286
+ (
287
+ "General",
288
+ [
289
+ ("Profile", profile_name),
290
+ ("Config File", str(CONFIG_FILE)),
291
+ ("Default Profile", get_default_profile()),
292
+ ],
293
+ )
294
+ )
295
+
296
+ for svc_name, svc_data in all_services.items():
297
+ kvs: list[tuple[str, str]] = []
298
+ svc_json: dict[str, str] = {}
299
+ for key, value in svc_data.items():
300
+ display_val = str(value) if value else "(not set)"
301
+ if key in ("api_key", "database_url", "redis_url") and value:
302
+ display_val = mask_secret(str(value))
303
+ kvs.append((key, display_val))
304
+ svc_json[key] = (
305
+ mask_secret(str(value)) if key in ("api_key", "database_url", "redis_url") and value else str(value)
306
+ )
307
+ sections.append((f"Service: {svc_name}", kvs))
308
+ if isinstance(json_data["services"], dict):
309
+ json_data["services"][svc_name] = svc_json
310
+
311
+ out.detail(title=f"Config — {profile_name}", sections=sections, data_for_json=json_data)
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # set
316
+ # ---------------------------------------------------------------------------
317
+ @app.command(name="set")
318
+ def set_cmd(
319
+ ctx: typer.Context,
320
+ key: Annotated[str, typer.Argument(help="Config key: url, ai_url, api_key, database_url, redis_url.")],
321
+ value: Annotated[str, typer.Argument(help="Config value.")],
322
+ ) -> None:
323
+ """Set a config value on the active profile."""
324
+ actx: AppContext = ctx.obj
325
+ out = actx.output
326
+
327
+ valid_keys = set(ServiceConfig.model_fields.keys())
328
+ if key not in valid_keys:
329
+ out.error(f"Invalid key '{key}'. Valid keys: {', '.join(sorted(valid_keys))}")
330
+ raise typer.Exit(1)
331
+
332
+ profile_name = resolve_active_profile_name(actx.profile)
333
+ svc = get_service_config(profile_name)
334
+ svc_dict = svc.model_dump()
335
+ svc_dict[key] = value
336
+ updated = ServiceConfig(**svc_dict)
337
+ set_service_config(profile_name, updated)
338
+
339
+ display_val = mask_secret(value) if key in ("api_key", "database_url", "redis_url") else value
340
+ out.success(f"Set {key}={display_val} on profile '{profile_name}'.")
341
+
342
+ if actx.json_mode:
343
+ out.raw_json({"profile": profile_name, "key": key, "value": display_val})
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # profiles
348
+ # ---------------------------------------------------------------------------
349
+ @app.command()
350
+ def profiles(ctx: typer.Context) -> None:
351
+ """List all connection profiles."""
352
+ actx: AppContext = ctx.obj
353
+ out = actx.output
354
+
355
+ names = get_profile_names()
356
+ default = get_default_profile()
357
+
358
+ if not names:
359
+ out.info("No profiles configured. Run: kctl-api config init")
360
+ if actx.json_mode:
361
+ out.raw_json([])
362
+ return
363
+
364
+ rows: list[list[str]] = []
365
+ json_data: list[dict[str, object]] = []
366
+
367
+ for name in names:
368
+ svc = get_service_config(name)
369
+ is_default = name == default
370
+ marker = "[green]*[/green]" if is_default else ""
371
+ services = get_all_services_in_profile(name)
372
+ svc_names = ", ".join(sorted(services.keys())) if services else "(empty)"
373
+
374
+ rows.append(
375
+ [
376
+ f"{marker} {name}" if marker else name,
377
+ svc.url or "(not set)",
378
+ svc_names,
379
+ "yes" if is_default else "",
380
+ ]
381
+ )
382
+ json_data.append(
383
+ {
384
+ "name": name,
385
+ "url": svc.url,
386
+ "services": list(services.keys()),
387
+ "default": is_default,
388
+ }
389
+ )
390
+
391
+ out.table(
392
+ title="Profiles",
393
+ columns=[
394
+ ("Name", "bold"),
395
+ ("URL", ""),
396
+ ("Services", "dim"),
397
+ ("Default", "green"),
398
+ ],
399
+ rows=rows,
400
+ data_for_json=json_data,
401
+ )
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # migrate
406
+ # ---------------------------------------------------------------------------
407
+ @app.command()
408
+ def migrate(ctx: typer.Context) -> None:
409
+ """Migrate from old flat format to service-scoped YAML."""
410
+ actx: AppContext = ctx.obj
411
+ out = actx.output
412
+
413
+ raw = load_raw_config()
414
+ profiles_data = raw.get("profiles", {})
415
+
416
+ if not profiles_data:
417
+ out.info("No profiles found. Nothing to migrate.")
418
+ return
419
+
420
+ migrated_count = 0
421
+ for _name, profile_data in profiles_data.items():
422
+ if not isinstance(profile_data, dict):
423
+ continue
424
+ if _is_service_scoped(profile_data):
425
+ continue
426
+
427
+ # Flat format detected — wrap under SERVICE_KEY
428
+ old_data = dict(profile_data)
429
+ profile_data.clear()
430
+ profile_data[SERVICE_KEY] = old_data
431
+ migrated_count += 1
432
+
433
+ if migrated_count == 0:
434
+ out.info("All profiles already use service-scoped format. Nothing to migrate.")
435
+ if actx.json_mode:
436
+ out.raw_json({"migrated": 0})
437
+ return
438
+
439
+ save_raw_config(raw)
440
+ out.success(f"Migrated {migrated_count} profile(s) to service-scoped format.")
441
+
442
+ if actx.json_mode:
443
+ out.raw_json({"migrated": migrated_count, "profiles": list(profiles_data.keys())})
@@ -0,0 +1,139 @@
1
+ """Dashboard and overview commands for kctl-api.
2
+
3
+ Aggregate health, job, and service information.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+
11
+ import typer
12
+
13
+ from kctl_api.core.callbacks import AppContext
14
+ from kctl_api.core.utils import service_status_color
15
+
16
+ app = typer.Typer(name="dashboard", help="Dashboard — overview, live monitoring.", no_args_is_help=True)
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # overview
21
+ # ---------------------------------------------------------------------------
22
+ @app.command()
23
+ def overview(ctx: typer.Context) -> None:
24
+ """Show a combined overview: health + jobs + recent activity."""
25
+ actx: AppContext = ctx.obj
26
+ out = actx.output
27
+
28
+ # Gather health checks
29
+ health_results: list[dict] = []
30
+
31
+ # API health
32
+ try:
33
+ healthy, details = actx.client.health_check()
34
+ health_results.append({"service": "api-main", "healthy": healthy, "status": details.get("status", "unknown")})
35
+ except Exception as e:
36
+ health_results.append({"service": "api-main", "healthy": False, "status": "error", "error": str(e)})
37
+
38
+ # AI health
39
+ try:
40
+ ai_data = actx.ai_client.get("/api/v1/ai/health")
41
+ ai_ok = ai_data.get("status") == "ok" if ai_data else False
42
+ health_results.append({"service": "ai-main", "healthy": ai_ok, "status": ai_data.get("status", "unknown")})
43
+ except Exception as e:
44
+ health_results.append({"service": "ai-main", "healthy": False, "status": "error", "error": str(e)})
45
+
46
+ # DB health
47
+ db_url = actx.database_url
48
+ if db_url:
49
+
50
+ async def _check_db() -> dict:
51
+ from kctl_api.core.db import dispose_engine, execute_query
52
+
53
+ try:
54
+ rows = await execute_query(db_url, "SELECT 1 AS ping")
55
+ await dispose_engine()
56
+ ok = len(rows) > 0 and rows[0].get("ping") == 1
57
+ return {"service": "database", "healthy": ok, "status": "ok" if ok else "error"}
58
+ except Exception as e:
59
+ with contextlib.suppress(Exception):
60
+ await dispose_engine()
61
+ return {"service": "database", "healthy": False, "status": "error", "error": str(e)}
62
+
63
+ health_results.append(asyncio.run(_check_db()))
64
+
65
+ # Redis health
66
+ redis_url = actx.redis_url
67
+ if redis_url:
68
+
69
+ async def _check_redis() -> dict:
70
+ from kctl_api.core.redis import close_redis, get_redis
71
+
72
+ try:
73
+ client = get_redis(redis_url)
74
+ pong = await client.ping()
75
+ await close_redis()
76
+ return {"service": "redis", "healthy": bool(pong), "status": "ok" if pong else "error"}
77
+ except Exception as e:
78
+ with contextlib.suppress(Exception):
79
+ await close_redis()
80
+ return {"service": "redis", "healthy": False, "status": "error", "error": str(e)}
81
+
82
+ health_results.append(asyncio.run(_check_redis()))
83
+
84
+ # Display health table
85
+ health_rows: list[list[str]] = []
86
+ for r in health_results:
87
+ health_rows.append(
88
+ [
89
+ r["service"],
90
+ service_status_color(r.get("status", "unknown")),
91
+ "[green]yes[/green]" if r["healthy"] else "[red]no[/red]",
92
+ ]
93
+ )
94
+
95
+ out.table(
96
+ title="Service Health",
97
+ columns=[
98
+ ("Service", "bold"),
99
+ ("Status", ""),
100
+ ("Healthy", ""),
101
+ ],
102
+ rows=health_rows,
103
+ data_for_json=health_results,
104
+ )
105
+
106
+ # Try to show job overview
107
+ try:
108
+ jobs_data = actx.client.get("/api/v1/jobs/queues/overview")
109
+ if jobs_data:
110
+ out.header("Job Queues")
111
+ if isinstance(jobs_data, list):
112
+ for q in jobs_data:
113
+ out.kv(q.get("name", "queue"), f"queued={q.get('queued', 0)} active={q.get('active', 0)}")
114
+ elif isinstance(jobs_data, dict):
115
+ for k, v in jobs_data.items():
116
+ out.kv(k, str(v))
117
+ except Exception:
118
+ pass
119
+
120
+ # Summary
121
+ total = len(health_results)
122
+ healthy_count = sum(1 for r in health_results if r["healthy"])
123
+ if healthy_count == total:
124
+ out.success(f"All {total} services healthy.")
125
+ else:
126
+ out.warn(f"{healthy_count}/{total} services healthy.")
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # live
131
+ # ---------------------------------------------------------------------------
132
+ @app.command()
133
+ def live(
134
+ ctx: typer.Context,
135
+ ) -> None:
136
+ """Live dashboard with auto-refresh (not yet implemented)."""
137
+ actx: AppContext = ctx.obj
138
+ out = actx.output
139
+ out.info("Not yet implemented. Will show a live Rich dashboard with auto-refresh.")