* feat(cli): agnes marketplace search/detail/add/remove + retire stale subcommands
Unified CLI surface for the v28+ marketplace: search across Curated and
Flea Market (RBAC-filtered server-side), drill into a single item's
detail, add/remove from your stack. Replaces opt-out era commands that
no longer reflect how users compose their stack.
CLI changes:
- Added: agnes marketplace {search,detail,add,remove}
- Removed: agnes my-stack toggle (opt-out semantics, curated-only)
- Removed: agnes store {list,show,install,uninstall} (consumer-side ops
moved under marketplace; store now covers only creator-side upload,
update, delete, mine)
ID format unifies curated and flea: marketplace_id/plugin_name (slash)
routes to /api/marketplace/curated/..., bare UUID routes to
/api/store/entities/... (flea bundles skills/agents into a synthetic
plugin server-side, so the analyst sees a single add/remove surface).
Templates:
- claude_md_template.txt: rewritten marketplace section as operational
guidance for Claude Code (discovery, stack management, behaviour
notes). Dropped the static {% if marketplaces %} listing — the CLI is
the source of truth for what's in the stack at any moment, so a
snapshot rendered at init time would lie the moment the user runs
agnes marketplace add/remove. Same discipline already applied to
tables and metrics.
- agnes_workspace_template.txt: cheat sheet adds 5 marketplace
one-liners; keeps the file's reference-doc tone (the original
commit's intent: 'what is this thing, how does it work, how do I
uninstall it').
Docs: HOWTO/05-customizing-skills.md rewritten around the new CLI flow;
the opt-out section is replaced by 'Removing items from your stack'.
Tests: new test_cli_marketplace.py covers all four subcommands incl.
RBAC/409 paths (system plugin guard, not-approved flea entity);
test_cli_store.py trimmed to the retained creator-side commands.
* release: 0.54.1 — agnes marketplace CLI redesign + retire stale subcommands
Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.0 →
0.54.1) bundling the BREAKING removals of `agnes my-stack toggle` and
`agnes store {list,show,install,uninstall}` plus the new unified
`agnes marketplace {search,detail,add,remove}` surface.
No DB migration; no operator-facing config change. Operators on
floating tags (`:stable`) auto-upgrade transparently. Analyst CLI
upgrade prompt fires on next `agnes pull`; users invoking the
retired commands get "No such command" with the new `agnes
marketplace` substitution called out in the BREAKING bullets.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
189 lines
6.8 KiB
Python
189 lines
6.8 KiB
Python
"""`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}")
|