agnes-the-ai-analyst/cli/commands/store.py
ZdenekSrotyr 16373d6b0b feat(cli): agnes store + agnes my-stack commands
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.
2026-05-05 08:18:12 +02:00

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}")