Adds whole-Store backup/restore primitives so an external CI/CD job can
mirror the Store to a git repo (and restore back from one).
REST:
- GET /api/store/bundle.zip — deterministic ZIP of all (filtered) Store
entities. Layout: manifest.json + entities/<id>/{plugin,assets}/.
Manifest carries owner_email for cross-instance restore. Auth: any
authenticated user (Store is community-open).
- POST /api/store/import-bundle — admin-only restore. Modes
merge|replace|skip; owner resolution by email with stub-disabled-user
fallback when the email is unknown on the target instance.
CLI:
- agnes store update <id> [--description X] [--zip PATH] ... — in-place
edit (server PUT permits owner OR admin per F4). Closes the missing
edit affordance for analysts who want to fix a typo or push a new
ZIP without losing install_count.
- agnes store pull [-o store.zip] [--unpack DIR] — download the bundle.
--unpack streams + extracts so an external git-backup workflow can
drop the tree straight into a repo and `git add .`.
- agnes store info [--json] — counts + size summary.
- agnes admin store push <zip-or-dir> [--mode ...] — admin-only restore.
Auto-zips a directory client-side so a working-tree → server
round-trip is one command.
cli/v2_client.py gains api_get_stream helper for binary downloads.
Tests: 5 new server tests (bundle shape + filters + round-trip + stub
user creation + skip mode + admin-only gate) + 11 new CLI tests
(update, pull/unpack, info, admin push). 66/66 store-related tests
green locally.
342 lines
12 KiB
Python
342 lines
12 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_get_stream,
|
|
api_post_json,
|
|
api_post_multipart,
|
|
api_put_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}")
|
|
|
|
|
|
@store_app.command("update")
|
|
def update_entity(
|
|
entity_id: str = typer.Argument(...),
|
|
description: Optional[str] = typer.Option(None, "--description"),
|
|
category: Optional[str] = typer.Option(None, "--category"),
|
|
video_url: Optional[str] = typer.Option(None, "--video-url"),
|
|
photo: Optional[Path] = typer.Option(
|
|
None, "--photo", exists=True, dir_okay=False, readable=True,
|
|
help="Replace the entity's photo with this image file",
|
|
),
|
|
zip_path: Optional[Path] = typer.Option(
|
|
None, "--zip", exists=True, dir_okay=False, readable=True,
|
|
help="Replace the plugin tree with this new ZIP",
|
|
),
|
|
):
|
|
"""In-place edit a Store entity. Owner or admin only.
|
|
|
|
Server-side authorization (PUT /api/store/entities/{id}) admits the
|
|
owner OR any member of the Admin group; CLI doesn't enforce, the
|
|
server does. Pass any combination of --description / --category /
|
|
--video-url / --photo / --zip; omitted fields are left untouched
|
|
(note: an empty string clears nothing — there's no API affordance to
|
|
clear a field back to NULL via PUT today).
|
|
"""
|
|
files: dict = {}
|
|
data: dict = {}
|
|
if zip_path:
|
|
files["file"] = (zip_path.name, zip_path.read_bytes(), "application/zip")
|
|
if photo:
|
|
files["photo"] = (photo.name, photo.read_bytes(), f"image/{photo.suffix.lstrip('.')}")
|
|
if description is not None:
|
|
data["description"] = description
|
|
if category is not None:
|
|
data["category"] = category
|
|
if video_url is not None:
|
|
data["video_url"] = video_url
|
|
if not files and not data:
|
|
typer.echo("Nothing to update — pass at least one of --description / --category / --video-url / --photo / --zip.", err=True)
|
|
raise typer.Exit(2)
|
|
try:
|
|
body = api_put_multipart(
|
|
f"/api/store/entities/{entity_id}",
|
|
files=files or None, data=data,
|
|
)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(
|
|
f"Updated: id={body['id']} version={body['version']}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bundle: pull + info (read paths, any authenticated user).
|
|
# Bulk restore (push) lives under `agnes admin store push` because the
|
|
# server-side endpoint is admin-only.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@store_app.command("pull")
|
|
def pull_bundle(
|
|
type: Optional[str] = typer.Option(None, "--type", help="skill | agent | plugin"),
|
|
category: Optional[str] = typer.Option(None, "--category"),
|
|
owner: Optional[str] = typer.Option(None, "--owner", help="Filter by owner user_id"),
|
|
search: Optional[str] = typer.Option(None, "--search", "-q"),
|
|
out: Path = typer.Option(
|
|
Path("agnes-store-bundle.zip"), "-o", "--out",
|
|
help="Where to save the ZIP (default: ./agnes-store-bundle.zip)",
|
|
),
|
|
unpack: Optional[Path] = typer.Option(
|
|
None, "--unpack",
|
|
help="Instead of saving the ZIP, unpack it into this directory. "
|
|
"Useful for committing a snapshot to a backup git repo: "
|
|
"`agnes store pull --unpack ./backup/ && cd backup && git add .`",
|
|
),
|
|
):
|
|
"""Download the whole Store as a deterministic ZIP.
|
|
|
|
With ``--unpack DIR`` the ZIP is streamed and immediately extracted
|
|
into ``DIR`` (the directory is wiped first so re-runs leave a clean
|
|
diff). The bundle layout::
|
|
|
|
manifest.json
|
|
entities/<entity_id>/
|
|
├── plugin/...
|
|
└── assets/...
|
|
|
|
Every entity matching the given filters is included; no filters =
|
|
everything in the Store.
|
|
"""
|
|
import shutil as _shutil
|
|
import tempfile as _tempfile
|
|
import zipfile as _zipfile
|
|
|
|
params: dict = {}
|
|
if type:
|
|
params["type"] = type
|
|
if category:
|
|
params["category"] = category
|
|
if owner:
|
|
params["owner"] = owner
|
|
if search:
|
|
params["search"] = search
|
|
|
|
if unpack:
|
|
# Stream into a temp file, then unpack into `unpack` (wiped first).
|
|
scratch = Path(_tempfile.mkdtemp(prefix="agnes_store_pull_"))
|
|
zip_path = scratch / "bundle.zip"
|
|
try:
|
|
try:
|
|
api_get_stream("/api/store/bundle.zip", str(zip_path), **params)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
if unpack.exists():
|
|
_shutil.rmtree(unpack)
|
|
unpack.mkdir(parents=True, exist_ok=True)
|
|
with _zipfile.ZipFile(zip_path, "r") as zf:
|
|
zf.extractall(unpack)
|
|
finally:
|
|
_shutil.rmtree(scratch, ignore_errors=True)
|
|
typer.echo(f"Unpacked Store bundle → {unpack}")
|
|
return
|
|
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
size = api_get_stream("/api/store/bundle.zip", str(out), **params)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(f"Wrote {size:,} bytes → {out}")
|
|
|
|
|
|
@store_app.command("info")
|
|
def store_info(
|
|
json_out: bool = typer.Option(False, "--json"),
|
|
):
|
|
"""Summary of the Store: total entities, breakdown by type, total size.
|
|
|
|
No new endpoint — assembled client-side from a paginated /entities
|
|
sweep so it stays in sync with what `pull` would emit.
|
|
"""
|
|
skip = 0
|
|
page = 100
|
|
by_type: dict = {}
|
|
total_entities = 0
|
|
total_size = 0
|
|
while True:
|
|
try:
|
|
body = api_get_json(
|
|
"/api/store/entities", limit=page, skip=skip,
|
|
)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
items = body.get("items", [])
|
|
if not items:
|
|
break
|
|
for it in items:
|
|
total_entities += 1
|
|
total_size += int(it.get("file_size") or 0)
|
|
by_type[it["type"]] = by_type.get(it["type"], 0) + 1
|
|
if len(items) < page:
|
|
break
|
|
skip += page
|
|
|
|
summary = {
|
|
"total_entities": total_entities,
|
|
"total_file_size_bytes": total_size,
|
|
"by_type": by_type,
|
|
}
|
|
if json_out:
|
|
typer.echo(json.dumps(summary, indent=2))
|
|
return
|
|
typer.echo(f"Store: {total_entities} entit, {total_size:,} bytes total")
|
|
for t in sorted(by_type):
|
|
typer.echo(f" {t:8s} {by_type[t]}")
|