Adds CLI coverage for the new REST surface introduced in this PR: agnes store list / show / install / uninstall / upload / delete agnes my-stack show / toggle Covers 11 of the 15 new endpoints — listing, detail, install/uninstall, upload (multipart), delete, my-stack get + curated toggle. Photo / docs download endpoints intentionally skipped; analyst-side automation rarely needs raw bytes back, and the web UI already covers them. cli/v2_client.py: api_post_multipart + api_put_multipart helpers (httpx files= passthrough). api_delete + api_put_json fillers were already needed for non-multipart writes; added together. Tests: tests/test_cli_store.py — help-text smoke tests + happy-path mocked tests for list, install, upload, my-stack show, my-stack toggle. 12 new tests, all green.
161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
"""`agnes store {list,show,install,uninstall,upload,delete}` — community
|
|
marketplace browse/install over the REST API.
|
|
|
|
Mirrors the /store web UI for analyst CLI workflows. Listing + filters are
|
|
the read paths; install/uninstall/upload/delete are the write paths. All
|
|
commands authenticate via the configured PAT (see ``cli auth``); the
|
|
endpoints are gated by ``get_current_user`` server-side.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from cli.v2_client import (
|
|
V2ClientError,
|
|
api_delete,
|
|
api_get_json,
|
|
api_post_json,
|
|
api_post_multipart,
|
|
)
|
|
|
|
store_app = typer.Typer(help="Community Store — browse, install, upload skills/agents/plugins")
|
|
|
|
|
|
@store_app.command("list")
|
|
def list_entities(
|
|
type: Optional[str] = typer.Option(None, "--type", help="skill | agent | plugin"),
|
|
category: Optional[str] = typer.Option(None, "--category"),
|
|
search: Optional[str] = typer.Option(None, "--search", "-q"),
|
|
owner: Optional[str] = typer.Option(None, "--owner", help="Filter by owner user_id"),
|
|
limit: int = typer.Option(24, "--limit", min=1, max=100),
|
|
skip: int = typer.Option(0, "--skip", min=0),
|
|
json_out: bool = typer.Option(False, "--json", help="Emit raw JSON instead of a table"),
|
|
):
|
|
"""List Store entities with optional filters."""
|
|
params: dict = {"limit": limit, "skip": skip}
|
|
if type:
|
|
params["type"] = type
|
|
if category:
|
|
params["category"] = category
|
|
if search:
|
|
params["search"] = search
|
|
if owner:
|
|
params["owner"] = owner
|
|
try:
|
|
body = api_get_json("/api/store/entities", **params)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
if json_out:
|
|
typer.echo(json.dumps(body, indent=2))
|
|
return
|
|
items = body.get("items", [])
|
|
total = body.get("total", 0)
|
|
typer.echo(f"{total} entit(y) total — showing {len(items)} (skip={skip}):")
|
|
for it in items:
|
|
typer.echo(
|
|
f" [{it['type']:6s}] {it['name']:24s} by {it['owner_username']:20s} "
|
|
f"installs={it['install_count']:<4d} v{it['version']} id={it['id']}"
|
|
)
|
|
|
|
|
|
@store_app.command("show")
|
|
def show_entity(
|
|
entity_id: str = typer.Argument(...),
|
|
json_out: bool = typer.Option(False, "--json"),
|
|
):
|
|
"""Show a Store entity's full metadata."""
|
|
try:
|
|
body = api_get_json(f"/api/store/entities/{entity_id}")
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
if json_out:
|
|
typer.echo(json.dumps(body, indent=2))
|
|
return
|
|
typer.echo(f"{body['name']} ({body['type']}) v{body['version']}")
|
|
typer.echo(f" by {body['owner_username']} ({body.get('owner_display_name') or '?'})")
|
|
typer.echo(f" invocation: {body['invocation_name']}")
|
|
if body.get("description"):
|
|
typer.echo(f" description: {body['description']}")
|
|
typer.echo(f" installs: {body['install_count']}, size: {body['file_size']} bytes")
|
|
if body.get("video_url"):
|
|
typer.echo(f" video: {body['video_url']}")
|
|
|
|
|
|
@store_app.command("install")
|
|
def install_entity(entity_id: str = typer.Argument(...)):
|
|
"""Install a Store entity into your `/marketplace.zip` view."""
|
|
try:
|
|
body = api_post_json(f"/api/store/entities/{entity_id}/install", {})
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(f"Installed: entity_id={body['entity_id']}")
|
|
|
|
|
|
@store_app.command("uninstall")
|
|
def uninstall_entity(entity_id: str = typer.Argument(...)):
|
|
"""Uninstall a Store entity from your view."""
|
|
try:
|
|
body = api_delete(f"/api/store/entities/{entity_id}/install")
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(f"Uninstalled: entity_id={body.get('entity_id', entity_id)}")
|
|
|
|
|
|
@store_app.command("upload")
|
|
def upload_entity(
|
|
type: str = typer.Argument(..., help="skill | agent | plugin"),
|
|
zip_path: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True),
|
|
name: Optional[str] = typer.Option(None, "--name"),
|
|
description: Optional[str] = typer.Option(None, "--description"),
|
|
category: Optional[str] = typer.Option(None, "--category"),
|
|
video_url: Optional[str] = typer.Option(None, "--video-url"),
|
|
):
|
|
"""Upload a Store entity from a local ZIP file."""
|
|
files = {
|
|
"file": (zip_path.name, zip_path.read_bytes(), "application/zip"),
|
|
}
|
|
data: dict = {"type": type}
|
|
if name:
|
|
data["name"] = name
|
|
if description:
|
|
data["description"] = description
|
|
if category:
|
|
data["category"] = category
|
|
if video_url:
|
|
data["video_url"] = video_url
|
|
try:
|
|
body = api_post_multipart("/api/store/entities", files=files, data=data)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(
|
|
f"Uploaded: id={body['id']} name={body['name']} "
|
|
f"invocation={body['invocation_name']} version={body['version']}"
|
|
)
|
|
|
|
|
|
@store_app.command("delete")
|
|
def delete_entity(
|
|
entity_id: str = typer.Argument(...),
|
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
):
|
|
"""Delete a Store entity (owner or admin only)."""
|
|
if not yes:
|
|
confirm = typer.confirm(f"Delete entity {entity_id}?")
|
|
if not confirm:
|
|
raise typer.Abort()
|
|
try:
|
|
api_delete(f"/api/store/entities/{entity_id}")
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(f"Deleted: {entity_id}")
|