mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
feat(extensions): add management CLI
This commit is contained in:
parent
83b54212ae
commit
55e497be14
@ -72,6 +72,7 @@ from nanobot.bus.outbound_events import ( # noqa: E402
|
|||||||
StreamEndEvent,
|
StreamEndEvent,
|
||||||
outbound_event_from_message,
|
outbound_event_from_message,
|
||||||
)
|
)
|
||||||
|
from nanobot.cli.extensions import create_extensions_app # noqa: E402
|
||||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
||||||
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
||||||
@ -2535,6 +2536,8 @@ def agent(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
app.add_typer(create_extensions_app(console=console), name="extensions")
|
||||||
|
|
||||||
channels_app = typer.Typer(help="Manage channels")
|
channels_app = typer.Typer(help="Manage channels")
|
||||||
app.add_typer(channels_app, name="channels")
|
app.add_typer(channels_app, name="channels")
|
||||||
|
|
||||||
|
|||||||
213
nanobot/cli/extensions.py
Normal file
213
nanobot/cli/extensions.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Typer commands for installing and governing extensions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from nanobot.extensions import ExtensionService
|
||||||
|
|
||||||
|
ServiceFactory = Callable[[], ExtensionService]
|
||||||
|
|
||||||
|
|
||||||
|
def create_extensions_app(
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
service_factory: ServiceFactory = ExtensionService,
|
||||||
|
) -> typer.Typer:
|
||||||
|
"""Build the extension command group around the transport-neutral service."""
|
||||||
|
app = typer.Typer(help="Discover, install, inspect, and govern extensions.")
|
||||||
|
|
||||||
|
def service() -> ExtensionService:
|
||||||
|
return service_factory()
|
||||||
|
|
||||||
|
def run(awaitable: Any) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return asyncio.run(awaitable)
|
||||||
|
except (KeyError, RuntimeError, ValueError) as exc:
|
||||||
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
@app.command("list")
|
||||||
|
def list_extensions() -> None:
|
||||||
|
"""List installed extensions and their activation policy."""
|
||||||
|
payload = run(service().status())
|
||||||
|
table = Table(show_header=True, header_style="bold")
|
||||||
|
table.add_column("Extension")
|
||||||
|
table.add_column("Runtime")
|
||||||
|
table.add_column("State")
|
||||||
|
table.add_column("Trust")
|
||||||
|
table.add_column("Version")
|
||||||
|
for item in payload["extensions"]:
|
||||||
|
state = "active" if item["active"] else ("enabled" if item["enabled"] else "disabled")
|
||||||
|
table.add_row(
|
||||||
|
item["name"],
|
||||||
|
item["runtime"],
|
||||||
|
state,
|
||||||
|
"trusted" if item["trusted"] else "untrusted",
|
||||||
|
item["version"],
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
if not payload["extensions"]:
|
||||||
|
console.print("[dim]No extensions installed.[/dim]")
|
||||||
|
if payload["diagnostics"]:
|
||||||
|
console.print(f"[yellow]{len(payload['diagnostics'])} diagnostic(s)[/yellow]")
|
||||||
|
|
||||||
|
@app.command("inspect")
|
||||||
|
def inspect_extension(extension_id: str = typer.Argument(..., help="Extension ID")) -> None:
|
||||||
|
"""Show manifest, dependencies, permissions, and diagnostics."""
|
||||||
|
payload = run(service().status())
|
||||||
|
item = next(
|
||||||
|
(candidate for candidate in payload["extensions"] if candidate["id"] == extension_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
console.print(f"[red]Extension not found: {extension_id}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
console.print(f"[bold]{item['name']}[/bold] [dim]{item['version']}[/dim]")
|
||||||
|
console.print(item["description"] or "[dim]No description.[/dim]")
|
||||||
|
console.print(f"Runtime: {item['runtime']} Scope: {item['scope']}")
|
||||||
|
console.print(
|
||||||
|
f"State: {'active' if item['active'] else 'inactive'} "
|
||||||
|
f"Trust: {'trusted' if item['trusted'] else 'untrusted'}"
|
||||||
|
)
|
||||||
|
_print_named_rows(console, "Contributions", item["contributions"], "kind", "name")
|
||||||
|
_print_named_rows(console, "Dependencies", item["dependencies"], "kind", "name")
|
||||||
|
_print_permissions(console, item["permissions"], set(item["granted_permissions"]))
|
||||||
|
diagnostics = [
|
||||||
|
diagnostic
|
||||||
|
for diagnostic in payload["diagnostics"]
|
||||||
|
if diagnostic["extension_id"] == extension_id
|
||||||
|
]
|
||||||
|
if diagnostics:
|
||||||
|
console.print("\n[bold]Diagnostics[/bold]")
|
||||||
|
for diagnostic in diagnostics:
|
||||||
|
console.print(
|
||||||
|
f" [yellow]{diagnostic['code']}[/yellow] {diagnostic['message']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.command("search")
|
||||||
|
def search_extensions(
|
||||||
|
query: str = typer.Argument("", help="Package name or keyword"),
|
||||||
|
ecosystem: str = typer.Option(
|
||||||
|
"all",
|
||||||
|
"--ecosystem",
|
||||||
|
"-e",
|
||||||
|
help="all, nanobot, pi, or openclaw",
|
||||||
|
),
|
||||||
|
limit: int = typer.Option(20, "--limit", min=1, max=100),
|
||||||
|
) -> None:
|
||||||
|
"""Search compatible extension packages on npm."""
|
||||||
|
payload = run(service().search(query, ecosystem=ecosystem, limit=limit))
|
||||||
|
table = Table(show_header=True, header_style="bold")
|
||||||
|
table.add_column("Package")
|
||||||
|
table.add_column("Ecosystem")
|
||||||
|
table.add_column("Version")
|
||||||
|
table.add_column("Description")
|
||||||
|
for package in payload["packages"]:
|
||||||
|
table.add_row(
|
||||||
|
package["name"],
|
||||||
|
package["ecosystem"],
|
||||||
|
package["version"],
|
||||||
|
package["description"],
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
if not payload["packages"]:
|
||||||
|
console.print("[dim]No compatible packages found.[/dim]")
|
||||||
|
|
||||||
|
@app.command("install")
|
||||||
|
def install_extension(
|
||||||
|
source: str = typer.Argument(..., help="npm spec, Git URL, or local path"),
|
||||||
|
kind: str = typer.Option("npm", "--kind", help="npm, git, or local"),
|
||||||
|
ref: str = typer.Option("", "--ref", help="Git branch, tag, or commit"),
|
||||||
|
) -> None:
|
||||||
|
"""Install an extension without granting trust or permissions."""
|
||||||
|
payload = run(service().install(source, kind=kind, ref=ref, trusted=False))
|
||||||
|
record = payload["record"]
|
||||||
|
console.print(
|
||||||
|
f"[green]Installed {record['id']} {record['version']}[/green] "
|
||||||
|
"[yellow](untrusted)[/yellow]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"Review with [bold]nanobot extensions inspect {record['id']}[/bold], "
|
||||||
|
"then grant permissions and trust it explicitly."
|
||||||
|
)
|
||||||
|
|
||||||
|
def policy_command(name: str, value: bool, label: str, help_text: str) -> None:
|
||||||
|
@app.command(name, help=help_text)
|
||||||
|
def update(extension_id: str = typer.Argument(..., help="Extension ID")) -> None:
|
||||||
|
payload = run(
|
||||||
|
service().set_enabled(extension_id, value)
|
||||||
|
if name in {"enable", "disable"}
|
||||||
|
else service().set_trusted(extension_id, value)
|
||||||
|
)
|
||||||
|
console.print(f"[green]{label}: {payload['record']['id']}[/green]")
|
||||||
|
|
||||||
|
policy_command("enable", True, "Enabled", "Allow an installed extension to activate.")
|
||||||
|
policy_command("disable", False, "Disabled", "Prevent an installed extension from activating.")
|
||||||
|
policy_command("trust", True, "Trusted", "Trust an installed extension's executable code.")
|
||||||
|
policy_command("untrust", False, "Trust revoked", "Revoke trust and stop extension activation.")
|
||||||
|
|
||||||
|
@app.command("permissions")
|
||||||
|
def set_permissions(
|
||||||
|
extension_id: str = typer.Argument(..., help="Extension ID"),
|
||||||
|
permissions: list[str] = typer.Argument(
|
||||||
|
None,
|
||||||
|
help="Exact permissions to grant; omit all to revoke every grant",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Replace the extension's granted host permissions."""
|
||||||
|
payload = run(service().set_permissions(extension_id, set(permissions or [])))
|
||||||
|
granted = payload["record"]["granted_permissions"]
|
||||||
|
console.print(
|
||||||
|
f"[green]Updated permissions for {extension_id}:[/green] "
|
||||||
|
+ (", ".join(granted) if granted else "none")
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.command("uninstall")
|
||||||
|
def uninstall_extension(
|
||||||
|
extension_id: str = typer.Argument(..., help="Extension ID"),
|
||||||
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
||||||
|
) -> None:
|
||||||
|
"""Remove an installed extension."""
|
||||||
|
if not yes and not typer.confirm(f"Uninstall extension '{extension_id}'?"):
|
||||||
|
raise typer.Abort()
|
||||||
|
run(service().uninstall(extension_id))
|
||||||
|
console.print(f"[green]Uninstalled {extension_id}[/green]")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _print_named_rows(
|
||||||
|
console: Console,
|
||||||
|
title: str,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
category_key: str,
|
||||||
|
name_key: str,
|
||||||
|
) -> None:
|
||||||
|
console.print(f"\n[bold]{title}[/bold]")
|
||||||
|
if not rows:
|
||||||
|
console.print(" [dim]None[/dim]")
|
||||||
|
return
|
||||||
|
for row in rows:
|
||||||
|
console.print(f" {row[category_key]}: {row[name_key]}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_permissions(
|
||||||
|
console: Console,
|
||||||
|
permissions: list[dict[str, str]],
|
||||||
|
granted: set[str],
|
||||||
|
) -> None:
|
||||||
|
console.print("\n[bold]Permissions[/bold]")
|
||||||
|
if not permissions:
|
||||||
|
console.print(" [dim]None requested[/dim]")
|
||||||
|
return
|
||||||
|
for permission in permissions:
|
||||||
|
status = "[green]granted[/green]" if permission["name"] in granted else "[yellow]pending[/yellow]"
|
||||||
|
reason = f" — {permission['reason']}" if permission["reason"] else ""
|
||||||
|
console.print(f" {permission['name']} ({status}){reason}")
|
||||||
118
tests/cli/test_extensions.py
Normal file
118
tests/cli/test_extensions.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from nanobot.cli.extensions import create_extensions_app
|
||||||
|
|
||||||
|
|
||||||
|
class _Service:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, object]] = []
|
||||||
|
|
||||||
|
async def status(self):
|
||||||
|
self.calls.append(("status", None))
|
||||||
|
return {
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"id": "sample",
|
||||||
|
"name": "Sample",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"runtime": "pi",
|
||||||
|
"scope": "user",
|
||||||
|
"description": "Example extension",
|
||||||
|
"enabled": True,
|
||||||
|
"trusted": False,
|
||||||
|
"active": False,
|
||||||
|
"contributions": [],
|
||||||
|
"dependencies": [],
|
||||||
|
"permissions": [{"name": "network", "reason": "Fetch data"}],
|
||||||
|
"granted_permissions": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diagnostics": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search(self, query, *, ecosystem, limit):
|
||||||
|
self.calls.append(("search", (query, ecosystem, limit)))
|
||||||
|
return {"packages": []}
|
||||||
|
|
||||||
|
async def install(self, source, *, kind, ref, trusted):
|
||||||
|
self.calls.append(("install", (source, kind, ref, trusted)))
|
||||||
|
return {
|
||||||
|
"record": {
|
||||||
|
"id": "sample",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"trusted": trusted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def set_enabled(self, extension_id, enabled):
|
||||||
|
self.calls.append(("enabled", (extension_id, enabled)))
|
||||||
|
return {"record": {"id": extension_id}}
|
||||||
|
|
||||||
|
async def set_trusted(self, extension_id, trusted):
|
||||||
|
self.calls.append(("trusted", (extension_id, trusted)))
|
||||||
|
return {"record": {"id": extension_id}}
|
||||||
|
|
||||||
|
async def set_permissions(self, extension_id, permissions):
|
||||||
|
self.calls.append(("permissions", (extension_id, permissions)))
|
||||||
|
return {
|
||||||
|
"record": {
|
||||||
|
"id": extension_id,
|
||||||
|
"granted_permissions": sorted(permissions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def uninstall(self, extension_id):
|
||||||
|
self.calls.append(("uninstall", extension_id))
|
||||||
|
return {"removed": extension_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _runner(service: _Service):
|
||||||
|
output = StringIO()
|
||||||
|
app = create_extensions_app(
|
||||||
|
console=Console(file=output, force_terminal=False),
|
||||||
|
service_factory=lambda: service,
|
||||||
|
)
|
||||||
|
return CliRunner(), app, output
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_cli_inspects_and_searches() -> None:
|
||||||
|
service = _Service()
|
||||||
|
runner, app, output = _runner(service)
|
||||||
|
|
||||||
|
inspected = runner.invoke(app, ["inspect", "sample"])
|
||||||
|
searched = runner.invoke(app, ["search", "web", "--ecosystem", "pi", "--limit", "7"])
|
||||||
|
|
||||||
|
assert inspected.exit_code == 0
|
||||||
|
assert searched.exit_code == 0
|
||||||
|
assert "Sample" in output.getvalue()
|
||||||
|
assert service.calls == [
|
||||||
|
("status", None),
|
||||||
|
("search", ("web", "pi", 7)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_cli_install_and_policy_commands() -> None:
|
||||||
|
service = _Service()
|
||||||
|
runner, app, _output = _runner(service)
|
||||||
|
|
||||||
|
assert runner.invoke(app, ["install", "pi-example"]).exit_code == 0
|
||||||
|
assert runner.invoke(app, ["trust", "sample"]).exit_code == 0
|
||||||
|
assert runner.invoke(app, ["disable", "sample"]).exit_code == 0
|
||||||
|
assert runner.invoke(
|
||||||
|
app,
|
||||||
|
["permissions", "sample", "network", "filesystem.read"],
|
||||||
|
).exit_code == 0
|
||||||
|
assert runner.invoke(app, ["uninstall", "sample", "--yes"]).exit_code == 0
|
||||||
|
|
||||||
|
assert service.calls == [
|
||||||
|
("install", ("pi-example", "npm", "", False)),
|
||||||
|
("trusted", ("sample", True)),
|
||||||
|
("enabled", ("sample", False)),
|
||||||
|
("permissions", ("sample", {"network", "filesystem.read"})),
|
||||||
|
("uninstall", "sample"),
|
||||||
|
]
|
||||||
Loading…
x
Reference in New Issue
Block a user