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/__init__.py +0 -0
- xcli/_run.py +14 -0
- xcli/_xcore.py +56 -0
- xcli/init/__init__.py +0 -0
- xcli/init/cli.py +11 -0
- xcli/init/manager.py +141 -0
- xcli/init/upgrade.py +198 -0
- xcli/main.py +64 -0
- xcli/manager/__init__.py +0 -0
- xcli/manager/cli.py +537 -0
- xcli/marketplace/__init__.py +0 -0
- xcli/marketplace/cli.py +44 -0
- xcli/migrations/__init__.py +3 -0
- xcli/migrations/cli.py +292 -0
- xcli/migrations/runtime.py +198 -0
- xcli/plugin/__init__.py +0 -0
- xcli/plugin/cli.py +538 -0
- xcli/plugin/scaffold.py +213 -0
- xcli/sandbox/__init__.py +0 -0
- xcli/sandbox/cli.py +124 -0
- xcli/worker/__init__.py +0 -0
- xcli/worker/cli.py +569 -0
- xcli/worker/worker.py +565 -0
- xcorecli-1.0.0.dist-info/METADATA +11 -0
- xcorecli-1.0.0.dist-info/RECORD +27 -0
- xcorecli-1.0.0.dist-info/WHEEL +4 -0
- xcorecli-1.0.0.dist-info/entry_points.txt +3 -0
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
|
xcli/marketplace/cli.py
ADDED
|
@@ -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)))
|