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/plugin/cli.py ADDED
@@ -0,0 +1,538 @@
1
+ import re
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ import yaml
8
+ from rich.console import Console
9
+ from rich.prompt import Confirm, Prompt
10
+ from rich.tree import Tree
11
+ from typer import Typer
12
+
13
+ from xcli._run import ns, run
14
+ from .scaffold import scaffold
15
+
16
+ _CTX = {"help_option_names": ["-h", "--help"]}
17
+ app = Typer(help="Plugin management.", context_settings=_CTX)
18
+ console = Console()
19
+
20
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
21
+ _CONFIG_CANDIDATES = [
22
+ "integration.yaml", "integration.json",
23
+ "config/integration.yaml", "config/integration.json",
24
+ ]
25
+
26
+
27
+ def _plugins_dir() -> Path:
28
+ for candidate in _CONFIG_CANDIDATES:
29
+ path = Path(candidate)
30
+ if not path.exists():
31
+ continue
32
+ try:
33
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
34
+ d = raw.get("plugins", {}).get("directory")
35
+ if d:
36
+ return Path(d)
37
+ except Exception:
38
+ pass
39
+ console.print("[yellow]⚠[/yellow] No integration.yaml — run [cyan]xcli init[/cyan] first.")
40
+ sys.exit(1)
41
+
42
+
43
+ def _print_tree(root: Path, created: list[Path]) -> None:
44
+ tree = Tree(f"[bold green]{root.name}/[/bold green]")
45
+ dirs: dict = {}
46
+ for path in sorted(created):
47
+ rel = path.relative_to(root)
48
+ parts = rel.parts
49
+ current = tree
50
+ for i, part in enumerate(parts[:-1]):
51
+ key = "/".join(parts[: i + 1])
52
+ if key not in dirs:
53
+ dirs[key] = current.add(f"[cyan]{part}/[/cyan]")
54
+ current = dirs[key]
55
+ current.add(f"[white]{parts[-1]}[/white]")
56
+ console.print(tree)
57
+
58
+
59
+ # ── new ───────────────────────────────────────────────────────
60
+
61
+ @app.command("new")
62
+ def new() -> None:
63
+ """Scaffold a new plugin in the directory from integration.yaml."""
64
+ console.print("\n[bold]xcli plugin new[/bold]\n")
65
+ plugins_root = _plugins_dir()
66
+
67
+ while True:
68
+ name = Prompt.ask("Plugin name [dim](lowercase, underscores)[/dim]")
69
+ if _NAME_RE.match(name):
70
+ break
71
+ console.print("[red]Lowercase letters, digits and underscores only.[/red]")
72
+
73
+ mode = Prompt.ask(
74
+ "Execution mode", choices=["trusted", "sandboxed", "legacy"], default="trusted"
75
+ )
76
+ target = plugins_root / name
77
+
78
+ if target.exists() and not Confirm.ask(f"[yellow]{target}[/yellow] exists. Continue?", default=False):
79
+ sys.exit(0)
80
+
81
+ created = scaffold({"name": name, "execution_mode": mode}, target)
82
+ console.print()
83
+ _print_tree(target, created)
84
+ console.print(f"\n[green]✓[/green] [cyan]{target}[/cyan] ready.")
85
+
86
+
87
+ # ── list ──────────────────────────────────────────────────────
88
+
89
+ @app.command("list")
90
+ def list_plugins() -> None:
91
+ """List installed plugins."""
92
+ from xcore.cli.plugin_cmd import handle_plugin
93
+ run(handle_plugin(ns(subcommand="list")))
94
+
95
+
96
+ # ── health ────────────────────────────────────────────────────
97
+
98
+ @app.command("health")
99
+ def health() -> None:
100
+ """Health-check all installed plugins."""
101
+ from xcore.cli.plugin_cmd import handle_plugin
102
+ run(handle_plugin(ns(subcommand="health")))
103
+
104
+
105
+ # ── install ───────────────────────────────────────────────────
106
+
107
+ def _resolve_marketplace_version(name: str, version: str) -> tuple[str, str]:
108
+ """
109
+ Returns (download_url, source_type) for the requested version.
110
+ Talks to the marketplace configured in integration.yaml.
111
+ """
112
+ import asyncio
113
+ from xcore.configurations.loader import ConfigLoader
114
+ from xcore.marketplace import MarketplaceClient
115
+
116
+ cfg = ConfigLoader.load(None)
117
+ client = MarketplaceClient(cfg)
118
+
119
+ async def _fetch():
120
+ if version == "latest":
121
+ data = await client.get_plugin(name)
122
+ if not data:
123
+ console.print(f"[red]Plugin '[cyan]{name}[/cyan]' not found on marketplace.[/red]")
124
+ raise typer.Exit(1)
125
+ return data.get("download_url"), data.get("source_type", "zip")
126
+
127
+ versions = await client.get_versions(name)
128
+ match = next((v for v in versions if v.get("version") == version), None)
129
+ if not match:
130
+ available = ", ".join(v.get("version", "?") for v in versions) or "none"
131
+ console.print(f"[red]Version [cyan]{version}[/cyan] not found for [cyan]{name}[/cyan].[/red]")
132
+ console.print(f"Available: {available}")
133
+ console.print(f"Tip: [dim]xcli plugin versions {name}[/dim]")
134
+ raise typer.Exit(1)
135
+ return match.get("download_url"), match.get("source_type", "zip")
136
+
137
+ return asyncio.run(_fetch())
138
+
139
+
140
+ @app.command("install")
141
+ def install(
142
+ plugin_spec: str = typer.Argument(..., help="Plugin to install: name, name@latest, or name@1.2.3"),
143
+ source: str = typer.Option("marketplace", "--source", "-s", help="marketplace | git | zip"),
144
+ url: Optional[str] = typer.Option(None, "--url", "-u", help="URL for git/zip source"),
145
+ ) -> None:
146
+ """Install a plugin — supports name@version syntax for marketplace."""
147
+ from xcore.cli.plugin_cmd import handle_plugin
148
+
149
+ # Parse name@version
150
+ if "@" in plugin_spec:
151
+ name, version = plugin_spec.split("@", 1)
152
+ else:
153
+ name, version = plugin_spec, None
154
+
155
+ if version and source == "marketplace":
156
+ console.print(f"Resolving [cyan]{name}@{version}[/cyan] on marketplace...")
157
+ resolved_url, resolved_source = _resolve_marketplace_version(name, version)
158
+ if not resolved_url:
159
+ console.print(f"[red]No download URL found for {name}@{version}.[/red]")
160
+ raise typer.Exit(1)
161
+ console.print(f" → [dim]{resolved_url}[/dim]")
162
+ run(handle_plugin(ns(subcommand="install", name=name, source=resolved_source, url=resolved_url)))
163
+ else:
164
+ run(handle_plugin(ns(subcommand="install", name=name, source=source, url=url)))
165
+
166
+
167
+ # ── versions ──────────────────────────────────────────────────
168
+
169
+ @app.command("versions")
170
+ def versions(name: str) -> None:
171
+ """List available versions of a marketplace plugin."""
172
+ async def _run():
173
+ from rich.table import Table
174
+ from xcore.configurations.loader import ConfigLoader
175
+ from xcore.marketplace import MarketplaceClient
176
+
177
+ cfg = ConfigLoader.load(None)
178
+ client = MarketplaceClient(cfg)
179
+
180
+ with console.status(f"Fetching versions for [cyan]{name}[/cyan]..."):
181
+ data = await client.get_versions(name)
182
+
183
+ if not data:
184
+ console.print(f"[yellow]No versions found for '[cyan]{name}[/cyan]'.[/yellow]")
185
+ return
186
+
187
+ table = Table(title=f"Versions — {name}")
188
+ table.add_column("Version", style="cyan")
189
+ table.add_column("Released", style="dim")
190
+ table.add_column("Source", style="green")
191
+ table.add_column("Download URL", style="dim", no_wrap=False)
192
+ for v in data:
193
+ table.add_row(
194
+ v.get("version", "?"),
195
+ v.get("released_at", "—"),
196
+ v.get("source_type", "zip"),
197
+ v.get("download_url", "—"),
198
+ )
199
+ console.print(table)
200
+ console.print(f"\n[dim]Install: xcli plugin install {name}@<version>[/dim]")
201
+
202
+ run(_run())
203
+
204
+
205
+ # ── update / update-all ───────────────────────────────────────
206
+
207
+ def _installed_version(name: str) -> str | None:
208
+ """Return the version string from the installed plugin.yaml, or None."""
209
+ plugin_dir = _plugins_dir() / name
210
+ manifest_path = plugin_dir / "plugin.yaml"
211
+ if not manifest_path.exists():
212
+ return None
213
+ try:
214
+ raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
215
+ return str(raw.get("version", "?"))
216
+ except Exception:
217
+ return None
218
+
219
+
220
+ def _do_update(name: str, target_version: str | None) -> None:
221
+ """Core update logic: resolve version, remove, reinstall."""
222
+ import asyncio
223
+ import shutil
224
+ from xcore.cli.plugin_cmd import handle_plugin
225
+ from xcore.configurations.loader import ConfigLoader
226
+ from xcore.marketplace import MarketplaceClient
227
+
228
+ current = _installed_version(name)
229
+ cfg = ConfigLoader.load(None)
230
+ client = MarketplaceClient(cfg)
231
+
232
+ async def _resolve():
233
+ if target_version and target_version != "latest":
234
+ versions = await client.get_versions(name)
235
+ match = next((v for v in versions if v.get("version") == target_version), None)
236
+ if not match:
237
+ available = ", ".join(v.get("version", "?") for v in versions)
238
+ console.print(f"[red]Version [cyan]{target_version}[/cyan] not found.[/red]")
239
+ console.print(f"Available: {available}")
240
+ raise typer.Exit(1)
241
+ return match.get("download_url"), match.get("source_type", "zip"), target_version
242
+ else:
243
+ data = await client.get_plugin(name)
244
+ if not data:
245
+ console.print(f"[red]Plugin '[cyan]{name}[/cyan]' not found on marketplace.[/red]")
246
+ raise typer.Exit(1)
247
+ return data.get("download_url"), data.get("source_type", "zip"), data.get("version", "latest")
248
+
249
+ url, source_type, new_version = asyncio.run(_resolve())
250
+
251
+ if current and current == new_version:
252
+ console.print(f"[green]✓[/green] [cyan]{name}[/cyan] is already at version [magenta]{current}[/magenta].")
253
+ return
254
+
255
+ console.print(f"Updating [cyan]{name}[/cyan]: [dim]{current or '?'}[/dim] → [magenta]{new_version}[/magenta]")
256
+ if not url:
257
+ console.print(f"[red]No download URL for {name}@{new_version}.[/red]")
258
+ raise typer.Exit(1)
259
+
260
+ plugin_path = _plugins_dir() / name
261
+ if plugin_path.exists():
262
+ shutil.rmtree(plugin_path)
263
+
264
+ run(handle_plugin(ns(subcommand="install", name=name, source=source_type, url=url)))
265
+ console.print(f"[green]✓[/green] [cyan]{name}[/cyan] updated to [magenta]{new_version}[/magenta].")
266
+
267
+
268
+ @app.command("update")
269
+ def update(
270
+ plugin_spec: str = typer.Argument(..., help="Plugin to update: name or name@version"),
271
+ ) -> None:
272
+ """Update an installed plugin to a newer version from the marketplace."""
273
+ if "@" in plugin_spec:
274
+ name, version = plugin_spec.split("@", 1)
275
+ else:
276
+ name, version = plugin_spec, None
277
+ _do_update(name, version)
278
+
279
+
280
+ @app.command("update-all")
281
+ def update_all(
282
+ check: bool = typer.Option(False, "--check", "-c", help="Only show available updates, do not install"),
283
+ ) -> None:
284
+ """Check for and apply updates for all installed plugins."""
285
+ import asyncio
286
+ from rich.table import Table
287
+ from xcore.configurations.loader import ConfigLoader
288
+ from xcore.marketplace import MarketplaceClient
289
+
290
+ names = [p.name for p in _plugins_dir().iterdir() if p.is_dir() and not p.name.startswith("_")]
291
+ if not names:
292
+ console.print("[yellow]No installed plugins found.[/yellow]")
293
+ return
294
+
295
+ cfg = ConfigLoader.load(None)
296
+ client = MarketplaceClient(cfg)
297
+
298
+ async def _fetch_all():
299
+ results = []
300
+ for name in names:
301
+ current = _installed_version(name)
302
+ try:
303
+ data = await client.get_plugin(name)
304
+ latest = data.get("version", "?") if data else None
305
+ url = data.get("download_url") if data else None
306
+ src = data.get("source_type", "zip") if data else None
307
+ except Exception:
308
+ latest = url = src = None
309
+ results.append((name, current, latest, url, src))
310
+ return results
311
+
312
+ with console.status("Checking marketplace for updates..."):
313
+ results = asyncio.run(_fetch_all())
314
+
315
+ table = Table(title="Plugin Update Status")
316
+ table.add_column("Plugin", style="cyan")
317
+ table.add_column("Installed", style="dim")
318
+ table.add_column("Latest", style="magenta")
319
+ table.add_column("Status", justify="center")
320
+
321
+ upgradable = []
322
+ for name, current, latest, url, src in results:
323
+ if latest is None:
324
+ status = "[dim]not on marketplace[/dim]"
325
+ elif current == latest:
326
+ status = "[green]up to date[/green]"
327
+ else:
328
+ status = "[yellow]update available[/yellow]"
329
+ upgradable.append((name, current, latest, url, src))
330
+ table.add_row(name, current or "?", latest or "—", status)
331
+
332
+ console.print(table)
333
+
334
+ if not upgradable:
335
+ console.print("\n[green]All plugins are up to date.[/green]")
336
+ return
337
+
338
+ if check:
339
+ console.print(f"\n[yellow]{len(upgradable)} update(s) available.[/yellow] Run without [dim]--check[/dim] to apply.")
340
+ return
341
+
342
+ import shutil
343
+ from rich.prompt import Confirm
344
+ from xcore.cli.plugin_cmd import handle_plugin
345
+
346
+ names_str = ", ".join(f"[cyan]{n}[/cyan]" for n, *_ in upgradable)
347
+ if not Confirm.ask(f"\nUpdate {names_str}?", default=True):
348
+ return
349
+
350
+ for name, current, latest, url, src in upgradable:
351
+ console.print(f"\nUpdating [cyan]{name}[/cyan]: [dim]{current}[/dim] → [magenta]{latest}[/magenta]")
352
+ plugin_path = _plugins_dir() / name
353
+ if plugin_path.exists():
354
+ shutil.rmtree(plugin_path)
355
+ run(handle_plugin(ns(subcommand="install", name=name, source=src, url=url)))
356
+ console.print(f"[green]✓[/green] {name} updated.")
357
+
358
+
359
+ # ── remove ────────────────────────────────────────────────────
360
+
361
+ @app.command("remove")
362
+ def remove(name: str) -> None:
363
+ """Remove an installed plugin."""
364
+ from xcore.cli.plugin_cmd import handle_plugin
365
+ run(handle_plugin(ns(subcommand="remove", name=name)))
366
+
367
+
368
+ # ── info ──────────────────────────────────────────────────────
369
+
370
+ @app.command("info")
371
+ def info(name: str) -> None:
372
+ """Show details of an installed plugin."""
373
+ from xcore.cli.plugin_cmd import handle_plugin
374
+ run(handle_plugin(ns(subcommand="info", name=name)))
375
+
376
+
377
+ # ── load / reload / unload / status / call ────────────────────
378
+
379
+ @app.command("load")
380
+ def load(name: str) -> None:
381
+ """Load a plugin directly (boots xcore standalone)."""
382
+ async def _run():
383
+ from xcli._xcore import boot
384
+ xcore = await boot()
385
+ try:
386
+ await xcore.plugins.load(name)
387
+ console.print(f"[green]✓[/green] Plugin [cyan]{name}[/cyan] loaded.")
388
+ finally:
389
+ await xcore.plugins.shutdown()
390
+ run(_run())
391
+
392
+
393
+ @app.command("reload")
394
+ def reload(name: str) -> None:
395
+ """Reload a plugin directly (boots xcore standalone)."""
396
+ async def _run():
397
+ from xcli._xcore import boot
398
+ xcore = await boot()
399
+ try:
400
+ await xcore.plugins.reload(name)
401
+ console.print(f"[green]✓[/green] Plugin [cyan]{name}[/cyan] reloaded.")
402
+ finally:
403
+ await xcore.plugins.shutdown()
404
+ run(_run())
405
+
406
+
407
+ @app.command("unload")
408
+ def unload(name: str) -> None:
409
+ """Unload a plugin directly (boots xcore standalone)."""
410
+ async def _run():
411
+ from xcli._xcore import boot
412
+ xcore = await boot()
413
+ try:
414
+ await xcore.plugins.unload(name)
415
+ console.print(f"[green]✓[/green] Plugin [cyan]{name}[/cyan] unloaded.")
416
+ finally:
417
+ await xcore.plugins.shutdown()
418
+ run(_run())
419
+
420
+
421
+ @app.command("status")
422
+ def status() -> None:
423
+ """Show runtime status of all loaded plugins."""
424
+ async def _run():
425
+ from xcli._xcore import boot
426
+ from rich.table import Table
427
+ xcore = await boot()
428
+ try:
429
+ data = xcore.plugins.status()
430
+ table = Table(title=f"Plugin Runtime Status ({data['count']} plugins)")
431
+ table.add_column("Name", style="cyan")
432
+ table.add_column("State", justify="center")
433
+ table.add_column("Mode", style="green")
434
+ for p in data["plugins"]:
435
+ state = p.get("state", "?")
436
+ color = "green" if state == "running" else "yellow"
437
+ table.add_row(
438
+ p.get("name", "?"),
439
+ f"[{color}]{state}[/]",
440
+ p.get("mode", "?"),
441
+ )
442
+ console.print(table)
443
+ finally:
444
+ await xcore.plugins.shutdown()
445
+ run(_run())
446
+
447
+
448
+ @app.command("call")
449
+ def call(
450
+ name: str,
451
+ action: str,
452
+ payload: str = typer.Option("{}", "--payload", "-p", help="JSON payload"),
453
+ ) -> None:
454
+ """Call a plugin action directly (boots xcore standalone)."""
455
+ import json
456
+ async def _run():
457
+ from xcli._xcore import boot
458
+ try:
459
+ data = json.loads(payload)
460
+ except json.JSONDecodeError as e:
461
+ console.print(f"[red]Invalid JSON payload:[/] {e}")
462
+ raise typer.Exit(1)
463
+ xcore = await boot()
464
+ try:
465
+ result = await xcore.plugins.call(name, action, data)
466
+ console.print(result)
467
+ finally:
468
+ await xcore.plugins.shutdown()
469
+ run(_run())
470
+
471
+
472
+ # ── marketplace ───────────────────────────────────────────────
473
+
474
+ @app.command("browse")
475
+ def browse() -> None:
476
+ """List all plugins available on the marketplace."""
477
+ from xcore.cli.marketplace_cmd import handle_marketplace
478
+ run(handle_marketplace(ns(subcommand="list")))
479
+
480
+
481
+ @app.command("trending")
482
+ def trending() -> None:
483
+ """Show trending plugins on the marketplace."""
484
+ from xcore.cli.marketplace_cmd import handle_marketplace
485
+ run(handle_marketplace(ns(subcommand="trending")))
486
+
487
+
488
+ @app.command("search")
489
+ def search(query: str) -> None:
490
+ """Search the marketplace for a plugin."""
491
+ from xcore.cli.marketplace_cmd import handle_marketplace
492
+ run(handle_marketplace(ns(subcommand="search", query=query)))
493
+
494
+
495
+ @app.command("show")
496
+ def show(name: str) -> None:
497
+ """Show marketplace details for a plugin (use info for installed plugins)."""
498
+ from xcore.cli.marketplace_cmd import handle_marketplace
499
+ run(handle_marketplace(ns(subcommand="show", name=name)))
500
+
501
+
502
+ @app.command("rate")
503
+ def rate(
504
+ name: str,
505
+ score: int = typer.Option(..., min=1, max=5, help="Score 1–5"),
506
+ ) -> None:
507
+ """Rate a marketplace plugin."""
508
+ from xcore.cli.marketplace_cmd import handle_marketplace
509
+ run(handle_marketplace(ns(subcommand="rate", name=name, score=score)))
510
+
511
+
512
+ # ── sign / verify / validate ──────────────────────────────────
513
+
514
+ @app.command("sign")
515
+ def sign(
516
+ path: str,
517
+ key: Optional[str] = typer.Option(None, "--key", "-k", help="HMAC signing key (reads from config if omitted)"),
518
+ ) -> None:
519
+ """Sign a plugin with its HMAC key."""
520
+ from xcore.cli.plugin_cmd import handle_plugin
521
+ run(handle_plugin(ns(subcommand="sign", path=path, key=key)))
522
+
523
+
524
+ @app.command("verify")
525
+ def verify(
526
+ path: str,
527
+ key: Optional[str] = typer.Option(None, "--key", "-k", help="HMAC signing key"),
528
+ ) -> None:
529
+ """Verify a plugin signature."""
530
+ from xcore.cli.plugin_cmd import handle_plugin
531
+ run(handle_plugin(ns(subcommand="verify", path=path, key=key)))
532
+
533
+
534
+ @app.command("validate")
535
+ def validate(path: str) -> None:
536
+ """Validate a plugin manifest."""
537
+ from xcore.cli.plugin_cmd import handle_plugin
538
+ run(handle_plugin(ns(subcommand="validate", path=path)))