xcorecli 1.0.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.
xcli/manager/cli.py ADDED
@@ -0,0 +1,537 @@
1
+ """
2
+ manager — Real-time monitoring and service lifecycle management.
3
+
4
+ Commands:
5
+ top Full-screen live dashboard (resources + logs)
6
+ logs Stream application logs (per-plugin filter, --follow)
7
+ resources CPU / memory / disk per plugin (--watch live table)
8
+ metrics Application metrics snapshot (boots xcore standalone)
9
+ services List all services with health status (--watch)
10
+ reload Reload (reconnect) a single service by name
11
+ unload Shutdown a single service by name
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import collections
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ import typer
23
+ from rich.console import Console
24
+ from rich.layout import Layout
25
+ from rich.live import Live
26
+ from rich.markup import escape
27
+ from rich.panel import Panel
28
+ from rich.table import Table
29
+ from rich.text import Text
30
+ from typer import Typer
31
+
32
+ _CTX = {"help_option_names": ["-h", "--help"]}
33
+ app = Typer(help="Runtime monitoring and service management.", context_settings=_CTX)
34
+ console = Console()
35
+ err = Console(stderr=True)
36
+
37
+
38
+ # ── config helpers ────────────────────────────────────────────
39
+
40
+ def _raw_cfg() -> dict:
41
+ from xcli._xcore import load_raw_config
42
+ return load_raw_config()
43
+
44
+
45
+ def _log_file() -> Path:
46
+ cfg = _raw_cfg()
47
+ p = cfg.get("observability", {}).get("logging", {}).get("file", "log/app.log")
48
+ return Path(p)
49
+
50
+
51
+ def _plugins_root() -> Path:
52
+ cfg = _raw_cfg()
53
+ return Path(cfg.get("plugins", {}).get("directory", "./app"))
54
+
55
+
56
+ def _plugin_names() -> list[str]:
57
+ d = _plugins_root()
58
+ if not d.exists():
59
+ return []
60
+ return sorted(p.name for p in d.iterdir() if p.is_dir() and not p.name.startswith("_"))
61
+
62
+
63
+ # ── logs ──────────────────────────────────────────────────────
64
+
65
+ def _print_log_line(line: str) -> None:
66
+ if "ERROR" in line:
67
+ console.print(f"[red]{escape(line)}[/red]")
68
+ elif "WARNING" in line:
69
+ console.print(f"[yellow]{escape(line)}[/yellow]")
70
+ elif "DEBUG" in line:
71
+ console.print(f"[dim]{escape(line)}[/dim]")
72
+ else:
73
+ console.print(escape(line))
74
+
75
+
76
+ @app.command("logs")
77
+ def logs(
78
+ plugin: Optional[str] = typer.Argument(None, help="Filter by plugin name (substring)"),
79
+ lines: int = typer.Option(50, "--lines", "-n", help="Initial lines to show"),
80
+ follow: bool = typer.Option(False, "--follow", "-f", help="Stream new log lines"),
81
+ ) -> None:
82
+ """Show application logs, optionally filtered by plugin name."""
83
+ log_file = _log_file()
84
+ if not log_file.exists():
85
+ console.print(f"[yellow]⚠[/yellow] Log file not found: [cyan]{log_file}[/cyan]")
86
+ console.print("[dim]Start the server first, or check observability.logging.file in integration.yaml[/dim]")
87
+ raise typer.Exit(1)
88
+
89
+ def _match(line: str) -> bool:
90
+ return (not plugin) or plugin in line
91
+
92
+ # Show last N matching lines
93
+ all_lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
94
+ for line in [l for l in all_lines if _match(l)][-lines:]:
95
+ _print_log_line(line)
96
+
97
+ if not follow:
98
+ return
99
+
100
+ label = f"plugin=[cyan]{plugin}[/cyan]" if plugin else "all"
101
+ console.print(f"\n[dim]--- following {log_file} ({label}) — Ctrl+C to stop ---[/dim]")
102
+ try:
103
+ with open(log_file, encoding="utf-8", errors="replace") as fh:
104
+ fh.seek(0, 2) # jump to end
105
+ while True:
106
+ line = fh.readline()
107
+ if line:
108
+ stripped = line.rstrip()
109
+ if _match(stripped):
110
+ _print_log_line(stripped)
111
+ else:
112
+ time.sleep(0.1)
113
+ except KeyboardInterrupt:
114
+ pass
115
+
116
+
117
+ # ── resources ─────────────────────────────────────────────────
118
+
119
+ def _build_resources_table() -> Table:
120
+ import psutil
121
+ import yaml
122
+ from xcore.kernel.sandbox.isolation import DiskWatcher
123
+
124
+ plugins_root = _plugins_root()
125
+ plugin_names = _plugin_names()
126
+
127
+ # Find sandboxed worker subprocesses: python worker.py <plugin_dir>
128
+ sandbox_pids: dict[str, psutil.Process] = {}
129
+ for proc in psutil.process_iter(["pid", "cmdline"]):
130
+ try:
131
+ cmd = proc.info["cmdline"] or []
132
+ if any("sandbox/worker.py" in str(c) for c in cmd):
133
+ for name in plugin_names:
134
+ plugin_dir_str = str(plugins_root / name)
135
+ if any(plugin_dir_str in str(c) for c in cmd):
136
+ sandbox_pids[name] = proc
137
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
138
+ pass
139
+
140
+ table = Table(title=f"Plugin Resources [dim]{plugins_root}[/dim]", show_lines=True)
141
+ table.add_column("Plugin", style="cyan", no_wrap=True)
142
+ table.add_column("Mode", justify="center")
143
+ table.add_column("PID", justify="right", style="dim")
144
+ table.add_column("CPU %", justify="right", style="magenta")
145
+ table.add_column("Mem MB", justify="right", style="magenta")
146
+ table.add_column("Disk MB", justify="right", style="blue")
147
+ table.add_column("Net Conn", justify="right", style="dim")
148
+ table.add_column("State", justify="center")
149
+
150
+ for name in plugin_names:
151
+ plugin_dir = plugins_root / name
152
+ manifest_path = plugin_dir / "plugin.yaml"
153
+
154
+ mode = "?"
155
+ max_disk = 100
156
+ try:
157
+ if manifest_path.exists():
158
+ m = yaml.safe_load(manifest_path.read_text()) or {}
159
+ mode = m.get("execution_mode", "?")[:9]
160
+ max_disk = m.get("resources", {}).get("max_disk_mb", 100)
161
+ except Exception:
162
+ pass
163
+
164
+ # Disk usage
165
+ disk_str = "?"
166
+ try:
167
+ dw = DiskWatcher(plugin_dir / "data", max_disk)
168
+ s = dw.stats()
169
+ color = "red" if not s["ok"] else "blue"
170
+ disk_str = f"[{color}]{s['used_mb']}/{s['max_mb']}[/{color}]"
171
+ except Exception:
172
+ pass
173
+
174
+ proc = sandbox_pids.get(name)
175
+ pid_str = cpu_str = mem_str = conn_str = "—"
176
+ state_str = "[dim]in-process[/dim]"
177
+
178
+ if proc:
179
+ try:
180
+ pid_str = str(proc.pid)
181
+ cpu = proc.cpu_percent(interval=0.05)
182
+ cpu_color = "red" if cpu > 80 else ("yellow" if cpu > 40 else "green")
183
+ cpu_str = f"[{cpu_color}]{cpu:.1f}[/{cpu_color}]"
184
+
185
+ mem = proc.memory_info().rss / 1024 ** 2
186
+ mem_color = "red" if mem > 400 else ("yellow" if mem > 200 else "magenta")
187
+ mem_str = f"[{mem_color}]{mem:.1f}[/{mem_color}]"
188
+
189
+ try:
190
+ conn_str = str(len(proc.net_connections()))
191
+ except (psutil.AccessDenied, AttributeError):
192
+ conn_str = "?"
193
+
194
+ state = proc.status()
195
+ state_str = "[green]running[/green]" if state == "running" else f"[yellow]{state}[/yellow]"
196
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
197
+ state_str = "[red]gone[/red]"
198
+
199
+ table.add_row(name, mode, pid_str, cpu_str, mem_str, disk_str, conn_str, state_str)
200
+
201
+ return table
202
+
203
+
204
+ @app.command("resources")
205
+ def resources(
206
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live refresh every second"),
207
+ ) -> None:
208
+ """Show CPU, memory, disk and network usage per plugin."""
209
+ try:
210
+ import psutil # noqa: F401
211
+ except ImportError:
212
+ err.print("[red]psutil required — run: uv add psutil[/red]")
213
+ raise typer.Exit(1)
214
+
215
+ if not watch:
216
+ console.print(_build_resources_table())
217
+ return
218
+
219
+ console.print("[dim]Ctrl+C to stop[/dim]")
220
+ try:
221
+ with Live(console=console, refresh_per_second=2) as live:
222
+ while True:
223
+ live.update(_build_resources_table())
224
+ time.sleep(1)
225
+ except KeyboardInterrupt:
226
+ pass
227
+
228
+
229
+ # ── metrics ───────────────────────────────────────────────────
230
+
231
+ @app.command("metrics")
232
+ def metrics(
233
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live refresh (boots xcore once)"),
234
+ ) -> None:
235
+ """Show application metrics snapshot (boots xcore standalone)."""
236
+ async def _fetch_metrics():
237
+ from xcli._xcore import boot
238
+ xcore = await boot()
239
+ try:
240
+ return xcore.metrics.snapshot()
241
+ finally:
242
+ await xcore.plugins.shutdown()
243
+
244
+ snap = asyncio.run(_fetch_metrics())
245
+
246
+ def _render_snap(s: dict) -> Panel:
247
+ from rich.console import Group
248
+
249
+ parts: list = []
250
+ if s.get("counters"):
251
+ t = Table(title="Counters", box=None, show_header=True, padding=(0, 2))
252
+ t.add_column("Metric", style="cyan")
253
+ t.add_column("Value", justify="right", style="magenta")
254
+ for k, v in sorted(s["counters"].items()):
255
+ t.add_row(escape(k), str(v))
256
+ parts.append(t)
257
+
258
+ if s.get("gauges"):
259
+ t = Table(title="Gauges", box=None, show_header=True, padding=(0, 2))
260
+ t.add_column("Metric", style="cyan")
261
+ t.add_column("Value", justify="right", style="green")
262
+ for k, v in sorted(s["gauges"].items()):
263
+ t.add_row(escape(k), f"{v:.4g}")
264
+ parts.append(t)
265
+
266
+ if s.get("histograms"):
267
+ t = Table(title="Histograms", box=None, show_header=True, padding=(0, 2))
268
+ t.add_column("Metric", style="cyan")
269
+ t.add_column("Count", justify="right")
270
+ t.add_column("Sum", justify="right", style="magenta")
271
+ t.add_column("Mean", justify="right", style="green")
272
+ for k, v in sorted(s["histograms"].items()):
273
+ t.add_row(escape(k), str(v["count"]), f"{v['sum']:.4f}", f"{v['mean']:.4f}")
274
+ parts.append(t)
275
+
276
+ if not parts:
277
+ return Panel("[yellow]No metrics recorded yet.[/yellow]", title="Metrics")
278
+ return Panel(Group(*parts), title="Application Metrics", border_style="cyan")
279
+
280
+ if not watch:
281
+ console.print(_render_snap(snap))
282
+ return
283
+
284
+ # Watch: re-fetch every 2s (re-boots xcore each time — only for dev use)
285
+ console.print("[dim]Re-fetching metrics every 2s — Ctrl+C to stop[/dim]")
286
+ try:
287
+ with Live(console=console, refresh_per_second=1) as live:
288
+ while True:
289
+ try:
290
+ s = asyncio.run(_fetch_metrics())
291
+ live.update(_render_snap(s))
292
+ except Exception as e:
293
+ live.update(Panel(f"[red]{escape(str(e))}[/red]", title="Metrics Error"))
294
+ time.sleep(2)
295
+ except KeyboardInterrupt:
296
+ pass
297
+
298
+
299
+ # ── services list ─────────────────────────────────────────────
300
+
301
+ async def _fetch_services() -> tuple[dict, dict]:
302
+ from xcore.configurations.loader import ConfigLoader
303
+ from xcore.services import ServiceContainer
304
+
305
+ cfg = ConfigLoader.load(None)
306
+ container = ServiceContainer(cfg.services)
307
+ container.load_default_providers()
308
+ await container.init()
309
+ health = await container.health()
310
+ status = container.status()
311
+ await container.shutdown()
312
+ return health, status
313
+
314
+
315
+ def _build_services_table() -> Table:
316
+ health, status = asyncio.run(_fetch_services())
317
+
318
+ table = Table(title="Services Status", show_lines=False)
319
+ table.add_column("Service", style="cyan", no_wrap=True)
320
+ table.add_column("Status", justify="center")
321
+ table.add_column("Health", justify="center")
322
+ table.add_column("Details", style="dim")
323
+
324
+ for svc_id, svc_info in status["services"].items():
325
+ st = svc_info.get("status", "?")
326
+ if st == "ready":
327
+ state_cell = "[green]ready[/green]"
328
+ elif st in ("initializing", "uninitialized"):
329
+ state_cell = f"[yellow]{st}[/yellow]"
330
+ else:
331
+ state_cell = f"[red]{st}[/red]"
332
+
333
+ h = health["services"].get(svc_id, {})
334
+ ok = h.get("ok", None)
335
+ health_sym = "✅" if ok else ("❌" if ok is False else "—")
336
+ health_msg = h.get("msg", "")
337
+
338
+ details = {k: v for k, v in svc_info.items() if k not in ("name", "status")}
339
+ details_str = escape(", ".join(f"{k}={v}" for k, v in details.items()))
340
+ if health_msg and not ok:
341
+ details_str = f"[red]{escape(health_msg)}[/red]"
342
+
343
+ table.add_row(svc_info.get("name", svc_id), state_cell, health_sym, details_str)
344
+
345
+ # Also show registered service keys
346
+ keys = ", ".join(sorted(status.get("registered_keys", [])))
347
+ if keys:
348
+ table.caption = f"[dim]Keys: {escape(keys)}[/dim]"
349
+
350
+ return table
351
+
352
+
353
+ @app.command("services")
354
+ def services_cmd(
355
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live refresh every 3s"),
356
+ ) -> None:
357
+ """List all services with status and health checks."""
358
+ if not watch:
359
+ try:
360
+ console.print(_build_services_table())
361
+ except Exception as e:
362
+ err.print(f"[red]Error fetching services:[/red] {escape(str(e))}")
363
+ raise typer.Exit(1)
364
+ return
365
+
366
+ console.print("[dim]Refreshing every 3s — Ctrl+C to stop[/dim]")
367
+ try:
368
+ with Live(console=console, refresh_per_second=0.5) as live:
369
+ while True:
370
+ try:
371
+ live.update(_build_services_table())
372
+ except Exception as e:
373
+ live.update(Panel(f"[red]{escape(str(e))}[/red]", title="Services Error"))
374
+ time.sleep(3)
375
+ except KeyboardInterrupt:
376
+ pass
377
+
378
+
379
+ # ── service reload ────────────────────────────────────────────
380
+
381
+ @app.command("reload")
382
+ def service_reload(name: str = typer.Argument(..., help="Service name (e.g. database, cache, scheduler)")) -> None:
383
+ """Reload (shutdown then re-init) a specific service."""
384
+ async def _reload():
385
+ from xcore.configurations.loader import ConfigLoader
386
+ from xcore.services import ServiceContainer
387
+
388
+ cfg = ConfigLoader.load(None)
389
+ container = ServiceContainer(cfg.services)
390
+ container.load_default_providers()
391
+ await container.init()
392
+
393
+ # Try exact name then <name>_service
394
+ svc = container._services.get(name) or container._services.get(f"{name}_service")
395
+ if svc is None:
396
+ available = sorted(container._services.keys())
397
+ err.print(f"[red]Service '[cyan]{name}[/cyan]' not found.[/red]")
398
+ err.print(f"Available managed services: {', '.join(available) or 'none'}")
399
+ err.print(f"Registered keys: {', '.join(sorted(container.status()['registered_keys']))}")
400
+ await container.shutdown()
401
+ raise typer.Exit(1)
402
+
403
+ svc_label = f"[cyan]{name}[/cyan]"
404
+ with console.status(f"Reloading {svc_label}..."):
405
+ await svc.shutdown()
406
+ await svc.init()
407
+ ok, msg = await svc.health_check()
408
+
409
+ sym = "[green]✓[/green]" if ok else "[red]✗[/red]"
410
+ console.print(f"{sym} Service {svc_label} reloaded — {escape(msg)}")
411
+
412
+ # Shutdown the rest cleanly
413
+ await container.shutdown()
414
+
415
+ asyncio.run(_reload())
416
+
417
+
418
+ # ── service unload ────────────────────────────────────────────
419
+
420
+ @app.command("unload")
421
+ def service_unload(name: str = typer.Argument(..., help="Service name to stop")) -> None:
422
+ """Shutdown a specific service (unload it from the container)."""
423
+ async def _unload():
424
+ from xcore.configurations.loader import ConfigLoader
425
+ from xcore.services import ServiceContainer
426
+
427
+ cfg = ConfigLoader.load(None)
428
+ container = ServiceContainer(cfg.services)
429
+ container.load_default_providers()
430
+ await container.init()
431
+
432
+ svc = container._services.get(name) or container._services.get(f"{name}_service")
433
+ if svc is None:
434
+ available = sorted(container._services.keys())
435
+ err.print(f"[red]Service '[cyan]{name}[/cyan]' not a managed service.[/red]")
436
+ err.print(f"Available: {', '.join(available) or 'none'}")
437
+ await container.shutdown()
438
+ raise typer.Exit(1)
439
+
440
+ with console.status(f"Stopping [cyan]{name}[/cyan]..."):
441
+ await svc.shutdown()
442
+
443
+ console.print(f"[green]✓[/green] Service [cyan]{name}[/cyan] unloaded.")
444
+
445
+ # Stop remaining services cleanly
446
+ for key, other in reversed(list(container._services.items())):
447
+ if key not in (name, f"{name}_service"):
448
+ try:
449
+ await other.shutdown()
450
+ except Exception:
451
+ pass
452
+
453
+ asyncio.run(_unload())
454
+
455
+
456
+ # ── top ───────────────────────────────────────────────────────
457
+
458
+ @app.command("top")
459
+ def top(
460
+ log_lines: int = typer.Option(12, "--log-lines", "-n", help="Log lines shown in the bottom panel"),
461
+ interval: float = typer.Option(1.0, "--interval", "-i", help="Refresh interval in seconds"),
462
+ ) -> None:
463
+ """Full-screen live dashboard: plugin resources (top) + live logs (bottom)."""
464
+ try:
465
+ import psutil # noqa: F401
466
+ except ImportError:
467
+ err.print("[red]psutil required — run: uv add psutil[/red]")
468
+ raise typer.Exit(1)
469
+
470
+ log_file = _log_file()
471
+ log_ring: collections.deque[str] = collections.deque(maxlen=log_lines)
472
+
473
+ # Seed ring with last N lines from the log file
474
+ if log_file.exists():
475
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
476
+ for line in lines[-log_lines:]:
477
+ log_ring.append(line)
478
+
479
+ def _log_panel() -> Panel:
480
+ text = Text()
481
+ for line in log_ring:
482
+ if "ERROR" in line:
483
+ text.append(line + "\n", style="red")
484
+ elif "WARNING" in line:
485
+ text.append(line + "\n", style="yellow")
486
+ elif "DEBUG" in line:
487
+ text.append(line + "\n", style="dim")
488
+ else:
489
+ text.append(line + "\n")
490
+ return Panel(text, title=f"[bold]Logs[/bold] [dim]{log_file}[/dim]", border_style="blue")
491
+
492
+ def _tick_logs(fh) -> None:
493
+ while True:
494
+ line = fh.readline()
495
+ if not line:
496
+ break
497
+ log_ring.append(line.rstrip())
498
+
499
+ layout = Layout()
500
+ layout.split_column(
501
+ Layout(name="resources", ratio=3),
502
+ Layout(name="logs", ratio=2),
503
+ )
504
+
505
+ console.print("[dim]xcli manager top — Ctrl+C to quit[/dim]")
506
+
507
+ log_fh = None
508
+ try:
509
+ if log_file.exists():
510
+ log_fh = open(log_file, encoding="utf-8", errors="replace")
511
+ log_fh.seek(0, 2)
512
+
513
+ with Live(layout, console=console, refresh_per_second=int(1 / interval) + 1, screen=True):
514
+ while True:
515
+ # Resources panel
516
+ try:
517
+ layout["resources"].update(
518
+ Panel(_build_resources_table(), title="[bold]Plugin Resources[/bold]", border_style="cyan")
519
+ )
520
+ except Exception as e:
521
+ layout["resources"].update(Panel(f"[red]{escape(str(e))}[/red]", title="Resources Error"))
522
+
523
+ # Log panel — drain new lines from file
524
+ if log_fh:
525
+ _tick_logs(log_fh)
526
+ elif log_file.exists():
527
+ log_fh = open(log_file, encoding="utf-8", errors="replace")
528
+ log_fh.seek(0, 2)
529
+
530
+ layout["logs"].update(_log_panel())
531
+ time.sleep(interval)
532
+
533
+ except KeyboardInterrupt:
534
+ pass
535
+ finally:
536
+ if log_fh:
537
+ log_fh.close()
File without changes
@@ -0,0 +1,44 @@
1
+ import typer
2
+ from typer import Typer
3
+
4
+ from xcli._run import ns, run
5
+
6
+ app = Typer(help="Plugin marketplace.")
7
+
8
+
9
+ @app.command("list")
10
+ def list_plugins() -> None:
11
+ """List all plugins on the marketplace."""
12
+ from xcore.cli.marketplace_cmd import handle_marketplace
13
+ run(handle_marketplace(ns(subcommand="list")))
14
+
15
+
16
+ @app.command("trending")
17
+ def trending() -> None:
18
+ """Show trending plugins."""
19
+ from xcore.cli.marketplace_cmd import handle_marketplace
20
+ run(handle_marketplace(ns(subcommand="trending")))
21
+
22
+
23
+ @app.command("search")
24
+ def search(query: str) -> None:
25
+ """Search the marketplace."""
26
+ from xcore.cli.marketplace_cmd import handle_marketplace
27
+ run(handle_marketplace(ns(subcommand="search", query=query)))
28
+
29
+
30
+ @app.command("show")
31
+ def show(name: str) -> None:
32
+ """Show details of a marketplace plugin."""
33
+ from xcore.cli.marketplace_cmd import handle_marketplace
34
+ run(handle_marketplace(ns(subcommand="show", name=name)))
35
+
36
+
37
+ @app.command("rate")
38
+ def rate(
39
+ name: str,
40
+ score: int = typer.Option(..., min=1, max=5, help="Score 1-5"),
41
+ ) -> None:
42
+ """Rate a marketplace plugin."""
43
+ from xcore.cli.marketplace_cmd import handle_marketplace
44
+ run(handle_marketplace(ns(subcommand="rate", name=name, score=score)))
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ __all__ = ["app"]