"""`agnes store {upload,update,delete,mine}` — Flea Market creator-side ops. For browsing and installing marketplace items use ``agnes marketplace``. These commands cover the creator workflow: publish, update, remove, and download your own entries. All commands authenticate via the configured PAT (see ``cli auth``); 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_stream, api_post_multipart, api_put_multipart, ) store_app = typer.Typer(help="Flea Market — upload and manage your own skills/agents/plugins") @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 Flea Market 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 Flea Market 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 Flea Market 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']}" ) # --------------------------------------------------------------------------- # `agnes store mine` — bundle of the caller's OWN entities (creator scope). # # Whole-Store bulk reads (`pull` / `info`) live under `agnes admin store` # because operationally they're backup tooling for operators. This stays # in user namespace because every authenticated user is allowed to grab # a backup of their own creations (offline archive, leaving the org, # moving to another instance). # --------------------------------------------------------------------------- @store_app.command("mine") def pull_my_entities( out: Path = typer.Option( Path("my-store-entities.zip"), "-o", "--out", help="Where to save the ZIP (default: ./my-store-entities.zip)", ), unpack: Optional[Path] = typer.Option( None, "--unpack", help="Instead of saving the ZIP, unpack it into this directory.", ), ): """Download a bundle of every Flea Market entity you own (created). Server-side this is the same ``GET /api/store/bundle.zip`` endpoint that `agnes admin store pull` uses, scoped to the caller via ``?owner=me`` (the server resolves the magic value to your user_id). """ import shutil as _shutil import tempfile as _tempfile import zipfile as _zipfile if unpack: scratch = Path(_tempfile.mkdtemp(prefix="agnes_store_mine_")) zip_path = scratch / "bundle.zip" try: try: api_get_stream("/api/store/bundle.zip", str(zip_path), owner="me") 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 your Store entities → {unpack}") return out.parent.mkdir(parents=True, exist_ok=True) try: size = api_get_stream("/api/store/bundle.zip", str(out), owner="me") except V2ClientError as e: typer.echo(str(e), err=True) raise typer.Exit(1) typer.echo(f"Wrote {size:,} bytes → {out}")