feat(cli): agnes marketplace search/detail/add/remove + retire stale subcommands (#280)
* 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>
This commit is contained in:
parent
b4d3c576af
commit
efc607f3ee
12 changed files with 803 additions and 255 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -10,6 +10,25 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.54.1] — 2026-05-13
|
||||
|
||||
### Added
|
||||
- `agnes marketplace search` — unified search across Curated and Flea Market; RBAC-filtered server-side, supports `--source`, `--type`, `--sort`, `--query`, `--json`
|
||||
- `agnes marketplace detail <id>` — full detail view for any marketplace item (curated: `marketplace_id/plugin_name`, flea: UUID)
|
||||
- `agnes marketplace add <id>` — add a plugin/skill/agent to your stack; works for both Curated and Flea Market
|
||||
- `agnes marketplace remove <id>` — remove from stack; works for both Curated and Flea Market
|
||||
|
||||
### Removed
|
||||
- **BREAKING** `agnes my-stack toggle` — superseded by `agnes marketplace add/remove` which covers both Curated and Flea Market
|
||||
- **BREAKING** `agnes store list` — superseded by `agnes marketplace search --source flea`
|
||||
- **BREAKING** `agnes store show` — superseded by `agnes marketplace detail <uuid>`
|
||||
- **BREAKING** `agnes store install` — superseded by `agnes marketplace add <uuid>`
|
||||
- **BREAKING** `agnes store uninstall` — superseded by `agnes marketplace remove <uuid>`
|
||||
|
||||
### Changed
|
||||
- `agnes store` now covers only creator-side operations: `upload`, `update`, `delete`, `mine`
|
||||
- `agnes my-stack show` output label updated: `From Store:` → `From Flea Market:`
|
||||
|
||||
## [0.54.0] — 2026-05-12
|
||||
|
||||
Activity Center build — unified observability surface plus a recursive
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Provides:
|
||||
|
||||
* ``GET /api/my-stack`` — combined view
|
||||
* ``PUT /api/my-stack/curated/{marketplace_id}/{plugin}`` — toggle opt-out
|
||||
* ``PUT /api/my-stack/curated/{marketplace_id}/{plugin}`` — toggle subscription
|
||||
|
||||
Used by the ``agnes my-stack`` CLI subcommand. The web page that historically
|
||||
backed these endpoints (``/my-ai-stack``) was removed in favor of
|
||||
|
|
@ -113,8 +113,8 @@ async def get_my_stack(
|
|||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Combined view of admin-curated plugins (with current opt-out state)
|
||||
and Store entities the caller has installed.
|
||||
"""Combined view of curated plugins the caller can subscribe to
|
||||
and Store entities they have installed.
|
||||
"""
|
||||
granted = resolve_allowed_plugins(conn, user)
|
||||
# Model B (v28+): explicit subscriptions decide what's enabled.
|
||||
|
|
@ -199,7 +199,7 @@ async def toggle_curated(
|
|||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Toggle subscribe/unsubscribe for a single admin-granted plugin.
|
||||
"""Toggle subscribe/unsubscribe for a single curated plugin.
|
||||
|
||||
UI thinks in terms of *enabled* (default off in Model B). v28+ the
|
||||
repository stores *subscribed* rows (presence = enabled in served set);
|
||||
|
|
|
|||
191
cli/commands/marketplace.py
Normal file
191
cli/commands/marketplace.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""`agnes marketplace {search,detail,add,remove}` — unified marketplace CLI.
|
||||
|
||||
Replaces the legacy `agnes my-stack toggle` (curated only, opt-out era) and
|
||||
the consumer-facing `agnes store install/uninstall/list/show`. Both Curated
|
||||
and Flea Market items are handled through a single command surface that mirrors
|
||||
the current web marketplace.
|
||||
|
||||
ID format:
|
||||
Curated → marketplace_id/plugin_name (contains a slash)
|
||||
Flea → UUID without slash
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from cli.v2_client import V2ClientError, api_delete, api_get_json, api_post_json
|
||||
|
||||
marketplace_app = typer.Typer(help="Browse and manage your Agnes marketplace stack")
|
||||
|
||||
|
||||
def _parse_id(item_id: str) -> tuple[str, str, str]:
|
||||
"""Return (source, part1, part2).
|
||||
|
||||
Curated: "/" in ID → ("curated", marketplace_id, plugin_name)
|
||||
Flea: no slash → ("flea", entity_id, "")
|
||||
"""
|
||||
if "/" in item_id:
|
||||
parts = item_id.split("/", 1)
|
||||
return "curated", parts[0], parts[1]
|
||||
return "flea", item_id, ""
|
||||
|
||||
|
||||
@marketplace_app.command("search")
|
||||
def search(
|
||||
query: Optional[str] = typer.Option(None, "-q", "--query", help="Search text"),
|
||||
type: Optional[str] = typer.Option(None, "--type", help="skill | agent | plugin"),
|
||||
source: Optional[str] = typer.Option(None, "--source", help="curated | flea (default: both)"),
|
||||
sort: str = typer.Option("recent", "--sort", help="recent | most_used | trending"),
|
||||
limit: int = typer.Option(24, "--limit", min=1, max=100),
|
||||
json_out: bool = typer.Option(False, "--json"),
|
||||
):
|
||||
"""Search Curated and Flea Market; returns only items you have access to."""
|
||||
tabs = [source] if source else ["curated", "flea"]
|
||||
all_items: list = []
|
||||
for tab in tabs:
|
||||
params: dict = {"tab": tab, "sort": sort, "page_size": limit}
|
||||
if query:
|
||||
params["q"] = query
|
||||
if type:
|
||||
params["type"] = type
|
||||
try:
|
||||
body = api_get_json("/api/marketplace/items", **params)
|
||||
except V2ClientError as e:
|
||||
typer.echo(str(e), err=True)
|
||||
raise typer.Exit(1)
|
||||
all_items.extend(body.get("items", []))
|
||||
|
||||
if json_out:
|
||||
typer.echo(json.dumps({"items": all_items, "total": len(all_items)}, indent=2))
|
||||
return
|
||||
|
||||
if not all_items:
|
||||
typer.echo("No results.")
|
||||
return
|
||||
|
||||
label = f'"{query}"' if query else "marketplace"
|
||||
typer.echo(f"{len(all_items)} result(s) for {label}:")
|
||||
for it in all_items:
|
||||
status = "✓ in stack" if it.get("installed") else "+ add"
|
||||
typer.echo(
|
||||
f" [{it.get('type', '?'):6s}] [{it.get('source', '?'):7s}] "
|
||||
f"{it.get('name', '?'):30s} by {it.get('owner', '?'):20s} "
|
||||
f"{status:10s} id={it['id']}"
|
||||
)
|
||||
|
||||
|
||||
@marketplace_app.command("detail")
|
||||
def detail(
|
||||
item_id: str = typer.Argument(..., help="Item ID: marketplace_id/plugin_name or UUID"),
|
||||
json_out: bool = typer.Option(False, "--json"),
|
||||
):
|
||||
"""Show full details for a marketplace item (curated or flea)."""
|
||||
source, part1, part2 = _parse_id(item_id)
|
||||
try:
|
||||
if source == "curated":
|
||||
body = api_get_json(f"/api/marketplace/curated/{part1}/{part2}")
|
||||
else:
|
||||
body = api_get_json(f"/api/marketplace/flea/{part1}/detail")
|
||||
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
|
||||
|
||||
name = body.get("display_name") or body.get("plugin_name") or body.get("manifest_name") or "?"
|
||||
item_type = body.get("type", "plugin")
|
||||
version = body.get("version") or "?"
|
||||
src_label = f"curated: {body.get('marketplace_id')}" if source == "curated" else "flea"
|
||||
installed = body.get("installed", False)
|
||||
|
||||
typer.echo(f"{name} ({item_type}) v{version} [{src_label}]")
|
||||
typer.echo(f" {'✓ In your stack' if installed else '+ Not in stack'}")
|
||||
|
||||
if body.get("tagline"):
|
||||
typer.echo(f"\n {body['tagline']}")
|
||||
if body.get("description"):
|
||||
typer.echo(f"\n {body['description']}")
|
||||
|
||||
use_cases = body.get("use_cases", [])
|
||||
if use_cases:
|
||||
typer.echo("\n Use cases:")
|
||||
for uc in use_cases:
|
||||
title = uc.get("title") or uc if isinstance(uc, str) else str(uc)
|
||||
typer.echo(f" • {title}")
|
||||
|
||||
skills = body.get("skills", [])
|
||||
agents = body.get("agents", [])
|
||||
commands = body.get("commands", [])
|
||||
mcps = body.get("mcps", [])
|
||||
|
||||
if any([skills, agents, commands, mcps]):
|
||||
typer.echo("\n Contents:")
|
||||
if skills:
|
||||
typer.echo(f" Skills: {', '.join(s.get('name', '?') for s in skills)}")
|
||||
if agents:
|
||||
typer.echo(f" Agents: {', '.join(a.get('name', '?') for a in agents)}")
|
||||
if commands:
|
||||
names = [c if isinstance(c, str) else c.get("name", "?") for c in commands]
|
||||
typer.echo(f" Commands: {', '.join(names)}")
|
||||
if mcps:
|
||||
names = [m if isinstance(m, str) else m.get("name", "?") for m in mcps]
|
||||
typer.echo(f" MCP servers: {', '.join(names)}")
|
||||
|
||||
if not installed:
|
||||
typer.echo(f"\n Add to stack: agnes marketplace add {item_id}")
|
||||
|
||||
|
||||
@marketplace_app.command("add")
|
||||
def add(
|
||||
item_id: str = typer.Argument(..., help="Item ID: marketplace_id/plugin_name or UUID"),
|
||||
):
|
||||
"""Add a plugin, skill, or agent to your stack."""
|
||||
source, part1, part2 = _parse_id(item_id)
|
||||
try:
|
||||
if source == "curated":
|
||||
api_post_json(f"/api/marketplace/curated/{part1}/{part2}/install", {})
|
||||
else:
|
||||
api_post_json(f"/api/store/entities/{part1}/install", {})
|
||||
except V2ClientError as e:
|
||||
_handle_install_error(e)
|
||||
raise typer.Exit(1)
|
||||
typer.echo("Added to your stack. Run /update-agnes-plugins in Claude Code to activate.")
|
||||
|
||||
|
||||
@marketplace_app.command("remove")
|
||||
def remove(
|
||||
item_id: str = typer.Argument(..., help="Item ID: marketplace_id/plugin_name or UUID"),
|
||||
):
|
||||
"""Remove a plugin, skill, or agent from your stack."""
|
||||
source, part1, part2 = _parse_id(item_id)
|
||||
try:
|
||||
if source == "curated":
|
||||
api_delete(f"/api/marketplace/curated/{part1}/{part2}/install")
|
||||
else:
|
||||
api_delete(f"/api/store/entities/{part1}/install")
|
||||
except V2ClientError as e:
|
||||
_handle_install_error(e)
|
||||
raise typer.Exit(1)
|
||||
typer.echo("Removed from your stack. Run /update-agnes-plugins in Claude Code to apply.")
|
||||
|
||||
|
||||
def _handle_install_error(e: V2ClientError) -> None:
|
||||
if e.status_code == 409:
|
||||
body = e.body if isinstance(e.body, dict) else {}
|
||||
detail_str = body.get("detail", "")
|
||||
if "system" in detail_str:
|
||||
typer.echo("Cannot modify — this is a system plugin managed by your admin.", err=True)
|
||||
elif "approved" in detail_str:
|
||||
typer.echo("This item is not yet approved and cannot be installed.", err=True)
|
||||
else:
|
||||
typer.echo(str(e), err=True)
|
||||
elif e.status_code == 403:
|
||||
typer.echo("You do not have permission to access this plugin.", err=True)
|
||||
else:
|
||||
typer.echo(str(e), err=True)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""`agnes my-stack {show,toggle}` — per-user marketplace composition view.
|
||||
"""`agnes my-stack show` — read-only view of the user's current marketplace stack.
|
||||
|
||||
Reads ``GET /api/my-stack`` and writes
|
||||
``PUT /api/my-stack/curated/{marketplace_id}/{plugin_name}`` opt-out flips.
|
||||
Reads ``GET /api/my-stack``. To add or remove items use
|
||||
``agnes marketplace add/remove``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -11,16 +11,16 @@ from typing import Optional
|
|||
|
||||
import typer
|
||||
|
||||
from cli.v2_client import V2ClientError, api_get_json, api_put_json
|
||||
from cli.v2_client import V2ClientError, api_get_json
|
||||
|
||||
my_stack_app = typer.Typer(help="Per-user marketplace composition (curated grants + Store installs)")
|
||||
my_stack_app = typer.Typer(help="Show your current marketplace stack (use 'agnes marketplace' to add/remove)")
|
||||
|
||||
|
||||
@my_stack_app.command("show")
|
||||
def show_stack(
|
||||
json_out: bool = typer.Option(False, "--json"),
|
||||
):
|
||||
"""Show admin-granted plugins (with opt-out state) and your Store installs."""
|
||||
"""Show curated plugins available to subscribe to and your Flea Market installs."""
|
||||
try:
|
||||
body = api_get_json("/api/my-stack")
|
||||
except V2ClientError as e:
|
||||
|
|
@ -31,14 +31,14 @@ def show_stack(
|
|||
return
|
||||
curated = body.get("curated", [])
|
||||
store = body.get("store", [])
|
||||
typer.echo(f"Curated (admin-granted): {len(curated)}")
|
||||
typer.echo(f"Curated plugins: {len(curated)}")
|
||||
for p in curated:
|
||||
flag = "✓" if p["enabled"] else "✗"
|
||||
typer.echo(
|
||||
f" [{flag}] {p['marketplace_id']}/{p['plugin_name']:24s} "
|
||||
f"manifest={p['manifest_name']} v{p.get('version') or '?'}"
|
||||
)
|
||||
typer.echo(f"\nFrom Store: {len(store)}")
|
||||
typer.echo(f"\nFrom Flea Market: {len(store)}")
|
||||
for it in store:
|
||||
typer.echo(
|
||||
f" [{it['type']:6s}] {it['name']:24s} by {it['owner_username']:20s} "
|
||||
|
|
@ -46,23 +46,3 @@ def show_stack(
|
|||
)
|
||||
|
||||
|
||||
@my_stack_app.command("toggle")
|
||||
def toggle(
|
||||
marketplace_id: str = typer.Argument(...),
|
||||
plugin_name: str = typer.Argument(...),
|
||||
on: bool = typer.Option(False, "--on", help="Subscribe (add to served set)"),
|
||||
off: bool = typer.Option(False, "--off", help="Unsubscribe (remove from served set)"),
|
||||
):
|
||||
"""Subscribe or unsubscribe from a curated plugin."""
|
||||
if on == off:
|
||||
typer.echo("Pass exactly one of --on / --off.", err=True)
|
||||
raise typer.Exit(2)
|
||||
enabled = bool(on)
|
||||
path = f"/api/my-stack/curated/{marketplace_id}/{plugin_name}"
|
||||
try:
|
||||
api_put_json(path, {"enabled": enabled})
|
||||
except V2ClientError as e:
|
||||
typer.echo(str(e), err=True)
|
||||
raise typer.Exit(1)
|
||||
state = "ENABLED" if enabled else "DISABLED"
|
||||
typer.echo(f"{marketplace_id}/{plugin_name}: {state}")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"""`agnes store {list,show,install,uninstall,upload,delete}` — community
|
||||
marketplace browse/install over the REST API.
|
||||
"""`agnes store {upload,update,delete,mine}` — Flea Market creator-side ops.
|
||||
|
||||
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.
|
||||
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
|
||||
|
|
@ -18,99 +17,14 @@ 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 = typer.Typer(help="Flea Market — upload and manage your own 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(
|
||||
|
|
@ -121,7 +35,7 @@ def upload_entity(
|
|||
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."""
|
||||
"""Upload a Flea Market entity from a local ZIP file."""
|
||||
files = {
|
||||
"file": (zip_path.name, zip_path.read_bytes(), "application/zip"),
|
||||
}
|
||||
|
|
@ -150,7 +64,7 @@ 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)."""
|
||||
"""Delete a Flea Market entity (owner or admin only)."""
|
||||
if not yes:
|
||||
confirm = typer.confirm(f"Delete entity {entity_id}?")
|
||||
if not confirm:
|
||||
|
|
@ -178,7 +92,7 @@ def update_entity(
|
|||
help="Replace the plugin tree with this new ZIP",
|
||||
),
|
||||
):
|
||||
"""In-place edit a Store entity. Owner or admin only.
|
||||
"""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
|
||||
|
|
@ -237,7 +151,7 @@ def pull_my_entities(
|
|||
help="Instead of saving the ZIP, unpack it into this directory.",
|
||||
),
|
||||
):
|
||||
"""Download a bundle of every Store entity you own (created).
|
||||
"""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
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from cli.commands.snapshot import snapshot_app
|
|||
from cli.commands.disk_info import disk_info_app
|
||||
from cli.commands.store import store_app
|
||||
from cli.commands.my_stack import my_stack_app
|
||||
from cli.commands.marketplace import marketplace_app
|
||||
|
||||
|
||||
def _cli_version() -> str:
|
||||
|
|
@ -142,6 +143,7 @@ app.add_typer(snapshot_app, name="snapshot")
|
|||
app.add_typer(disk_info_app, name="disk-info")
|
||||
app.add_typer(store_app, name="store")
|
||||
app.add_typer(my_stack_app, name="my-stack")
|
||||
app.add_typer(marketplace_app, name="marketplace")
|
||||
|
||||
|
||||
def _capture_cli_exception(exc: BaseException, kind: str) -> None:
|
||||
|
|
|
|||
|
|
@ -65,20 +65,31 @@ agnes catalog
|
|||
agnes catalog --json | jq '.[] | select(.query_mode=="local")'
|
||||
|
||||
# Schema and sample
|
||||
agnes schema opportunity
|
||||
agnes describe opportunity -n 10
|
||||
agnes schema <table>
|
||||
agnes describe <table> -n 10
|
||||
|
||||
# Run a SQL query (DuckDB flavor against local parquets)
|
||||
agnes query "SELECT count(*) FROM opportunity WHERE stage='Closed Won'"
|
||||
agnes query "SELECT count(*) FROM <table> WHERE ..."
|
||||
|
||||
# Remote BigQuery query (server-side, no local materialization)
|
||||
agnes query --remote "SELECT count(*) FROM web_sessions_example"
|
||||
agnes query --remote "SELECT count(*) FROM <table>"
|
||||
|
||||
# Materialize a remote subset locally
|
||||
agnes snapshot create web_sessions_example \
|
||||
--select event_date,country_code \
|
||||
--where "event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)" \
|
||||
--as recent_sessions
|
||||
agnes snapshot create <table> \
|
||||
--select col1,col2 \
|
||||
--where "date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)" \
|
||||
--as my_snapshot
|
||||
|
||||
# Browse and manage marketplace (skills / agents / plugins)
|
||||
agnes marketplace search -q "pdf"
|
||||
agnes marketplace detail <id>
|
||||
agnes marketplace add <id>
|
||||
agnes marketplace remove <id>
|
||||
agnes my-stack show
|
||||
|
||||
# Upload your own skill/agent/plugin to the Flea Market
|
||||
agnes store upload skill ./my-skill.zip
|
||||
agnes store mine
|
||||
|
||||
# Manual data refresh (the SessionStart hook does this automatically)
|
||||
agnes pull
|
||||
|
|
|
|||
|
|
@ -82,22 +82,61 @@ and `agnes init` will overwrite local edits — put personal notes into
|
|||
`null` for `remote` rows. When `null`, treat the table as potentially large
|
||||
and use `agnes snapshot create --estimate` to size-check before fetching.
|
||||
|
||||
{% if marketplaces -%}
|
||||
## Agnes Marketplace — plugins available to you
|
||||
## Agnes Marketplace — discovering and managing skills, agents, plugins
|
||||
|
||||
These plugins reach Claude Code through the per-user **`agnes`** marketplace
|
||||
served by this server (an aggregated, RBAC-filtered view of the upstream
|
||||
marketplaces below). When you install or invoke one of these plugins inside
|
||||
Claude Code, address it as `<plugin>@agnes` regardless of which upstream it
|
||||
came from — e.g. `claude plugin install <plugin>@agnes`. The
|
||||
`agnes refresh-marketplace` command (run by the SessionStart hook every
|
||||
session) keeps the local clone in sync.
|
||||
The marketplace has two sections:
|
||||
|
||||
Upstream marketplaces folded into your `agnes` view:
|
||||
{% for mp in marketplaces -%}
|
||||
- **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
- **Curated** — vetted by your org; admins control RBAC visibility
|
||||
- **Flea Market** — open community uploads anyone can publish (admin-approved)
|
||||
|
||||
When the user asks "is there a skill/agent for X?" or "what's available for Y?",
|
||||
**always check the marketplace** before assuming nothing exists — new items are
|
||||
published continuously and your training data does not cover them. Never
|
||||
enumerate from memory; the CLI is the source of truth for what exists and
|
||||
what's in your stack.
|
||||
|
||||
### Discovery
|
||||
|
||||
```
|
||||
# Search across Curated + Flea Market (RBAC-filtered server-side)
|
||||
agnes marketplace search -q "pdf"
|
||||
agnes marketplace search --type skill # restrict by type
|
||||
agnes marketplace search --source curated # restrict by source
|
||||
agnes marketplace search --json # machine-readable for scripts
|
||||
|
||||
# Full details — use cases, contents, install command, examples
|
||||
agnes marketplace detail <id>
|
||||
agnes marketplace detail --json <id> # raw payload
|
||||
```
|
||||
|
||||
ID format:
|
||||
- Curated → `marketplace_id/plugin_name` (contains `/`)
|
||||
- Flea Market → UUID (no `/`)
|
||||
|
||||
### Managing your stack
|
||||
|
||||
```
|
||||
# What's currently in my stack? (authoritative — read live, do not cache)
|
||||
agnes my-stack show
|
||||
|
||||
# Add or remove (works for both Curated and Flea Market by ID)
|
||||
agnes marketplace add <id>
|
||||
agnes marketplace remove <id>
|
||||
```
|
||||
|
||||
After `add` / `remove`, the user must run `/update-agnes-plugins` inside
|
||||
Claude Code to apply the change to the running session — no restart needed.
|
||||
|
||||
### Notes on behaviour
|
||||
|
||||
- System plugins (admin-pinned) cannot be removed — the API returns 409.
|
||||
- Flea Market items must be admin-approved before they're installable.
|
||||
- Skills and agents from the Flea Market are bundled into a synthetic plugin
|
||||
when served to Claude Code; you invoke them the same way as plugin-shipped
|
||||
skills.
|
||||
- Plugins served to Claude Code are addressed as `<plugin>@agnes`
|
||||
(e.g. `claude plugin install <plugin>@agnes`). The SessionStart hook runs
|
||||
`agnes refresh-marketplace --check` to detect upstream changes.
|
||||
|
||||
## Remote Queries (BigQuery) — when data isn't on the laptop
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,53 @@
|
|||
# Customising your skills
|
||||
|
||||
Agnes serves a curated set of Claude Code skills (plugins) through your instance's marketplace. You can extend your personal stack in two ways: installing from the curated/flea tabs, and uploading your own.
|
||||
Agnes serves a curated set of Claude Code skills, agents, and plugins through your instance's marketplace. You can extend your personal stack in two ways: adding items from the Curated and Flea Market tabs, and uploading your own.
|
||||
|
||||
---
|
||||
|
||||
## Surface 1: Installing from the marketplace
|
||||
## Surface 1: Discovering and adding from the marketplace
|
||||
|
||||
You have two paths: the web UI at `/marketplace`, or the `agnes marketplace` CLI from any workspace.
|
||||
|
||||
### Curated tab (admin-managed)
|
||||
|
||||
Your admin has registered one or more git repos as marketplaces. The plugins from those repos appear on the **Curated** tab at `/marketplace`.
|
||||
Your admin has registered one or more git repos as marketplaces. The plugins from those repos appear on the **Curated** tab at `/marketplace` (only those visible to your RBAC groups).
|
||||
|
||||
- Browse and install plugins from the curated tab.
|
||||
- After installing, sync your local Claude Code marketplace:
|
||||
```bash
|
||||
agnes refresh-marketplace --quiet
|
||||
```
|
||||
This is also wired to run automatically at `SessionStart` (via the hook `agnes init` installs).
|
||||
- To see what's in your current stack:
|
||||
```bash
|
||||
agnes my-stack
|
||||
```
|
||||
- The **Most Popular** section (top of `/marketplace`) shows the 8 most-invoked plugins over the last 30 days — a quick signal for what your teammates find useful.
|
||||
- The **Most Popular** section (top of `/marketplace`) shows the 8 most-invoked plugins over the last 30 days.
|
||||
- Sort options: **Recent** (default), **Most used (30d)**, **Trending (week-over-week)**.
|
||||
|
||||
### Flea tab (community uploads)
|
||||
|
||||
The **Flea** tab shows plugins uploaded by any analyst on the instance, after admin approval. Browse and install the same way as curated plugins.
|
||||
The **Flea Market** tab shows skills, agents, and plugins uploaded by any analyst on the instance, after admin approval.
|
||||
|
||||
### Using the CLI
|
||||
|
||||
```bash
|
||||
# Search across Curated + Flea Market
|
||||
agnes marketplace search -q "pdf"
|
||||
agnes marketplace search --type skill --source curated
|
||||
|
||||
# Full detail — use cases, contents, examples
|
||||
agnes marketplace detail <id>
|
||||
|
||||
# Add to / remove from your stack
|
||||
agnes marketplace add <id>
|
||||
agnes marketplace remove <id>
|
||||
|
||||
# What's currently in my stack?
|
||||
agnes my-stack show
|
||||
```
|
||||
|
||||
ID format: curated items are `marketplace_id/plugin_name`, Flea items are UUIDs.
|
||||
|
||||
### Applying changes to Claude Code
|
||||
|
||||
After `add` / `remove`, run inside Claude Code:
|
||||
|
||||
```
|
||||
/update-agnes-plugins
|
||||
```
|
||||
|
||||
That installs/updates/removes the corresponding plugins in your local Claude Code session. The `SessionStart` hook detects pending updates automatically and surfaces a hint, so you can wait for the next session if you prefer.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -68,22 +90,28 @@ Once approved, the plugin appears on the Flea tab and becomes installable by oth
|
|||
### After approval
|
||||
|
||||
```bash
|
||||
# Sync your local marketplace to pick up the new plugin
|
||||
agnes refresh-marketplace
|
||||
# Add it to your stack (or do it from the Flea tab on the web)
|
||||
agnes marketplace add <entity-id>
|
||||
|
||||
# Verify it's in your stack
|
||||
agnes my-stack
|
||||
agnes my-stack show
|
||||
```
|
||||
|
||||
Then run `/update-agnes-plugins` inside Claude Code to install/activate the bundle.
|
||||
|
||||
---
|
||||
|
||||
## Opt-outs
|
||||
## Removing items from your stack
|
||||
|
||||
If a plugin appears in your marketplace feed (curated or flea) but you don't want it:
|
||||
- Uninstall from the `/marketplace` UI — this records a `user_plugin_optouts` row.
|
||||
- The opted-out plugin will no longer appear in your served ZIP or git feed.
|
||||
If you no longer want a plugin/skill/agent in your stack:
|
||||
|
||||
This is per-user, per-plugin. Your admin's grants are unaffected.
|
||||
```bash
|
||||
agnes marketplace remove <id>
|
||||
```
|
||||
|
||||
Or click "Remove from stack" on the marketplace detail page in the web UI.
|
||||
|
||||
System plugins (admin-pinned for the org) cannot be removed — the API returns 409. Your admin's RBAC grants are unaffected by your own add/remove choices.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "agnes-the-ai-analyst"
|
||||
version = "0.54.0"
|
||||
version = "0.54.1"
|
||||
description = "Agnes — AI Data Analyst platform for AI analytical systems"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
license = "MIT"
|
||||
|
|
|
|||
437
tests/test_cli_marketplace.py
Normal file
437
tests/test_cli_marketplace.py
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
"""Tests for `agnes marketplace` Typer wrapper.
|
||||
|
||||
Smoke + happy-path. Network calls are mocked so tests don't depend on a
|
||||
running server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cli.commands.marketplace import marketplace_app
|
||||
|
||||
runner = CliRunner()
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return _ANSI_RE.sub("", s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help smoke tests — guard against accidental command renames.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_marketplace_help_lists_subcommands():
|
||||
r = runner.invoke(marketplace_app, ["--help"])
|
||||
assert r.exit_code == 0
|
||||
out = _clean(r.output)
|
||||
for cmd in ("search", "detail", "add", "remove"):
|
||||
assert cmd in out, f"missing subcommand {cmd!r} in help"
|
||||
|
||||
|
||||
def test_marketplace_search_help():
|
||||
r = runner.invoke(marketplace_app, ["search", "--help"])
|
||||
assert r.exit_code == 0
|
||||
out = _clean(r.output)
|
||||
for opt in ("--query", "--type", "--source", "--sort", "--limit", "--json"):
|
||||
assert opt in out, f"missing option {opt!r}"
|
||||
|
||||
|
||||
def test_marketplace_detail_help():
|
||||
r = runner.invoke(marketplace_app, ["detail", "--help"])
|
||||
assert r.exit_code == 0
|
||||
assert "--json" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_add_help():
|
||||
r = runner.invoke(marketplace_app, ["add", "--help"])
|
||||
assert r.exit_code == 0
|
||||
|
||||
|
||||
def test_marketplace_remove_help():
|
||||
r = runner.invoke(marketplace_app, ["remove", "--help"])
|
||||
assert r.exit_code == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CURATED_ITEMS = [
|
||||
{
|
||||
"id": "foundry-ai/pdf-generator",
|
||||
"source": "curated",
|
||||
"type": "skill",
|
||||
"name": "pdf-generator",
|
||||
"owner": "c-marustamyan",
|
||||
"installed": True,
|
||||
}
|
||||
]
|
||||
|
||||
_FLEA_ITEMS = [
|
||||
{
|
||||
"id": "abc123def456abc1",
|
||||
"source": "flea",
|
||||
"type": "agent",
|
||||
"name": "pdf-extractor",
|
||||
"owner": "someone",
|
||||
"installed": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _make_search_mock(curated=None, flea=None):
|
||||
"""Returns a mock api_get_json that returns curated/flea data by tab param."""
|
||||
curated = curated if curated is not None else _CURATED_ITEMS
|
||||
flea = flea if flea is not None else _FLEA_ITEMS
|
||||
|
||||
def _mock(*args, **kwargs):
|
||||
tab = kwargs.get("tab", "curated")
|
||||
if tab == "curated":
|
||||
return {"items": curated, "total": len(curated)}
|
||||
return {"items": flea, "total": len(flea)}
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
def test_marketplace_search_no_source_queries_both(monkeypatch):
|
||||
calls: list = []
|
||||
|
||||
def _mock(*args, **kwargs):
|
||||
calls.append(kwargs.get("tab"))
|
||||
return _make_search_mock()(*args, **kwargs)
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["search"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert "curated" in calls
|
||||
assert "flea" in calls
|
||||
out = _clean(r.output)
|
||||
assert "pdf-generator" in out
|
||||
assert "pdf-extractor" in out
|
||||
|
||||
|
||||
def test_marketplace_search_source_curated(monkeypatch):
|
||||
calls: list = []
|
||||
|
||||
def _mock(*args, **kwargs):
|
||||
calls.append(kwargs.get("tab"))
|
||||
return {"items": _CURATED_ITEMS, "total": 1}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["search", "--source", "curated"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert calls == ["curated"]
|
||||
assert "pdf-generator" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_search_source_flea(monkeypatch):
|
||||
calls: list = []
|
||||
|
||||
def _mock(*args, **kwargs):
|
||||
calls.append(kwargs.get("tab"))
|
||||
return {"items": _FLEA_ITEMS, "total": 1}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["search", "--source", "flea"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert calls == ["flea"]
|
||||
assert "pdf-extractor" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_search_json(monkeypatch):
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _make_search_mock())
|
||||
|
||||
r = runner.invoke(marketplace_app, ["search", "--json"])
|
||||
assert r.exit_code == 0, r.output
|
||||
body = json.loads(_clean(r.output))
|
||||
assert "items" in body
|
||||
assert "total" in body
|
||||
|
||||
|
||||
def test_marketplace_search_type_filter(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _mock(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["search", "--type", "skill"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured.get("type") == "skill"
|
||||
|
||||
|
||||
def test_marketplace_search_query_passed(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _mock(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
runner.invoke(marketplace_app, ["search", "-q", "pdf"])
|
||||
assert captured.get("q") == "pdf"
|
||||
|
||||
|
||||
def test_marketplace_search_no_results(monkeypatch):
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", lambda *a, **kw: {"items": [], "total": 0})
|
||||
|
||||
r = runner.invoke(marketplace_app, ["search", "-q", "nothing"])
|
||||
assert r.exit_code == 0
|
||||
assert "No results" in _clean(r.output)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CURATED_DETAIL = {
|
||||
"source": "curated",
|
||||
"marketplace_id": "foundry-ai",
|
||||
"plugin_name": "pdf-generator",
|
||||
"manifest_name": "pdf-generator",
|
||||
"display_name": "PDF Generator",
|
||||
"type": "skill",
|
||||
"version": "1.2.0",
|
||||
"tagline": "Generate PDFs from data",
|
||||
"description": "Generates PDF documents.",
|
||||
"installed": True,
|
||||
"use_cases": [{"title": "Export report"}, {"title": "Generate invoice"}],
|
||||
"skills": [{"name": "pdf-generator"}],
|
||||
"commands": ["/pdf-by-c-marustamyan"],
|
||||
"mcps": [],
|
||||
"agents": [],
|
||||
}
|
||||
|
||||
_FLEA_DETAIL = {
|
||||
"source": "flea",
|
||||
"entity_id": "abc123def456abc1",
|
||||
"plugin_name": "pdf-extractor",
|
||||
"manifest_name": "pdf-extractor",
|
||||
"type": "agent",
|
||||
"version": "0.9.0",
|
||||
"description": "Extracts text from PDFs.",
|
||||
"installed": False,
|
||||
"use_cases": [],
|
||||
"skills": [],
|
||||
"commands": [],
|
||||
"mcps": [],
|
||||
"agents": [],
|
||||
}
|
||||
|
||||
|
||||
def test_marketplace_detail_curated(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _mock(path, **kw):
|
||||
captured["path"] = path
|
||||
return _CURATED_DETAIL
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["detail", "foundry-ai/pdf-generator"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/marketplace/curated/foundry-ai/pdf-generator"
|
||||
out = _clean(r.output)
|
||||
assert "PDF Generator" in out
|
||||
assert "In your stack" in out
|
||||
assert "Export report" in out
|
||||
|
||||
|
||||
def test_marketplace_detail_flea(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _mock(path, **kw):
|
||||
captured["path"] = path
|
||||
return _FLEA_DETAIL
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["detail", "abc123def456abc1"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/marketplace/flea/abc123def456abc1/detail"
|
||||
out = _clean(r.output)
|
||||
assert "pdf-extractor" in out
|
||||
assert "Not in stack" in out
|
||||
assert "agnes marketplace add abc123def456abc1" in out
|
||||
|
||||
|
||||
def test_marketplace_detail_json(monkeypatch):
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_get_json", lambda *a, **kw: _CURATED_DETAIL)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["detail", "--json", "foundry-ai/pdf-generator"])
|
||||
assert r.exit_code == 0, r.output
|
||||
body = json.loads(_clean(r.output))
|
||||
assert body["plugin_name"] == "pdf-generator"
|
||||
|
||||
|
||||
def test_marketplace_detail_not_found(monkeypatch):
|
||||
from cli.v2_client import V2ClientError
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(
|
||||
mp_mod, "api_get_json",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(V2ClientError(404, {"detail": "not_found"})),
|
||||
)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["detail", "foundry-ai/missing"])
|
||||
assert r.exit_code == 1
|
||||
|
||||
|
||||
def test_marketplace_detail_forbidden(monkeypatch):
|
||||
from cli.v2_client import V2ClientError
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(
|
||||
mp_mod, "api_get_json",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(V2ClientError(403, {"detail": "forbidden"})),
|
||||
)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["detail", "foundry-ai/secret"])
|
||||
assert r.exit_code == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_marketplace_add_curated(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _post(path, payload):
|
||||
captured["path"] = path
|
||||
return {"installed": True}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_post_json", _post)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["add", "foundry-ai/pdf-generator"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/marketplace/curated/foundry-ai/pdf-generator/install"
|
||||
assert "Added" in _clean(r.output)
|
||||
assert "update-agnes-plugins" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_add_flea(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _post(path, payload):
|
||||
captured["path"] = path
|
||||
return {"entity_id": "abc123def456abc1", "installed": True}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_post_json", _post)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["add", "abc123def456abc1"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/store/entities/abc123def456abc1/install"
|
||||
assert "Added" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_add_system_plugin_409(monkeypatch):
|
||||
from cli.v2_client import V2ClientError
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(
|
||||
mp_mod, "api_post_json",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
V2ClientError(409, {"detail": "cannot_unsubscribe_system_plugin"})
|
||||
),
|
||||
)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["add", "foundry-ai/core"])
|
||||
assert r.exit_code == 1
|
||||
assert "system plugin" in _clean(r.stderr or r.output)
|
||||
|
||||
|
||||
def test_marketplace_add_not_approved_409(monkeypatch):
|
||||
from cli.v2_client import V2ClientError
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(
|
||||
mp_mod, "api_post_json",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
V2ClientError(409, {"detail": "entity_not_approved"})
|
||||
),
|
||||
)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["add", "abc123def456abc1"])
|
||||
assert r.exit_code == 1
|
||||
assert "approved" in _clean(r.stderr or r.output)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_marketplace_remove_curated(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _delete(path):
|
||||
captured["path"] = path
|
||||
return {"installed": False}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_delete", _delete)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["remove", "foundry-ai/pdf-generator"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/marketplace/curated/foundry-ai/pdf-generator/install"
|
||||
assert "Removed" in _clean(r.output)
|
||||
assert "update-agnes-plugins" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_remove_flea(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _delete(path):
|
||||
captured["path"] = path
|
||||
return {"entity_id": "abc123def456abc1", "installed": False}
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(mp_mod, "api_delete", _delete)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["remove", "abc123def456abc1"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/store/entities/abc123def456abc1/install"
|
||||
assert "Removed" in _clean(r.output)
|
||||
|
||||
|
||||
def test_marketplace_remove_system_plugin_409(monkeypatch):
|
||||
from cli.v2_client import V2ClientError
|
||||
|
||||
import cli.commands.marketplace as mp_mod
|
||||
monkeypatch.setattr(
|
||||
mp_mod, "api_delete",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
V2ClientError(409, {"detail": "cannot_unsubscribe_system_plugin"})
|
||||
),
|
||||
)
|
||||
|
||||
r = runner.invoke(marketplace_app, ["remove", "foundry-ai/core"])
|
||||
assert r.exit_code == 1
|
||||
assert "system plugin" in _clean(r.stderr or r.output)
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""Tests for `agnes store` and `agnes my-stack` Typer wrappers.
|
||||
"""Tests for `agnes store` (creator-side) and `agnes my-stack` Typer wrappers.
|
||||
|
||||
Smoke + happy-path. Network calls are mocked so tests don't depend on a
|
||||
running server.
|
||||
running server. Consumer-side browse/install ops (list, show, install,
|
||||
uninstall) moved to `agnes marketplace` — see test_cli_marketplace.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -30,7 +31,7 @@ def test_store_help_lists_subcommands():
|
|||
r = runner.invoke(store_app, ["--help"])
|
||||
assert r.exit_code == 0
|
||||
out = _clean(r.output)
|
||||
for cmd in ("list", "show", "install", "uninstall", "upload", "update", "delete", "mine"):
|
||||
for cmd in ("upload", "update", "delete", "mine"):
|
||||
assert cmd in out, f"missing subcommand {cmd!r} in help"
|
||||
|
||||
|
||||
|
|
@ -47,16 +48,8 @@ def test_my_stack_help_lists_subcommands():
|
|||
r = runner.invoke(my_stack_app, ["--help"])
|
||||
assert r.exit_code == 0
|
||||
out = _clean(r.output)
|
||||
for cmd in ("show", "toggle"):
|
||||
assert cmd in out
|
||||
|
||||
|
||||
def test_store_list_default_help():
|
||||
r = runner.invoke(store_app, ["list", "--help"])
|
||||
assert r.exit_code == 0
|
||||
out = _clean(r.output)
|
||||
for opt in ("--type", "--category", "--search", "--owner", "--limit", "--skip", "--json"):
|
||||
assert opt in out
|
||||
assert "show" in out
|
||||
assert "toggle" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -64,49 +57,6 @@ def test_store_list_default_help():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_list_renders_table(monkeypatch):
|
||||
sample = {
|
||||
"items": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"type": "skill",
|
||||
"name": "code-review",
|
||||
"owner_username": "alice",
|
||||
"install_count": 5,
|
||||
"version": "deadbeef00000000",
|
||||
},
|
||||
],
|
||||
"total": 1,
|
||||
"skip": 0,
|
||||
"limit": 24,
|
||||
}
|
||||
import cli.commands.store as store_mod
|
||||
monkeypatch.setattr(store_mod, "api_get_json", lambda *a, **kw: sample)
|
||||
|
||||
r = runner.invoke(store_app, ["list"])
|
||||
assert r.exit_code == 0, r.output
|
||||
out = _clean(r.output)
|
||||
assert "1 entit" in out
|
||||
assert "code-review" in out
|
||||
assert "alice" in out
|
||||
|
||||
|
||||
def test_store_install_calls_api(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _post(path, payload):
|
||||
captured["path"] = path
|
||||
captured["payload"] = payload
|
||||
return {"entity_id": "xyz", "installed": True}
|
||||
|
||||
import cli.commands.store as store_mod
|
||||
monkeypatch.setattr(store_mod, "api_post_json", _post)
|
||||
|
||||
r = runner.invoke(store_app, ["install", "xyz"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/store/entities/xyz/install"
|
||||
assert "Installed" in _clean(r.output)
|
||||
|
||||
|
||||
def test_store_upload_sends_multipart(monkeypatch, tmp_path):
|
||||
captured: dict = {}
|
||||
|
|
@ -167,36 +117,13 @@ def test_my_stack_show_renders(monkeypatch):
|
|||
import cli.commands.my_stack as ms_mod
|
||||
monkeypatch.setattr(ms_mod, "api_get_json", lambda *a, **kw: sample)
|
||||
|
||||
r = runner.invoke(my_stack_app, ["show"])
|
||||
r = runner.invoke(my_stack_app, [])
|
||||
assert r.exit_code == 0, r.output
|
||||
out = _clean(r.output)
|
||||
assert "Curated" in out and "alpha" in out
|
||||
assert "From Store" in out and "code-review-by-alice" in out
|
||||
assert "From Flea Market" in out and "code-review-by-alice" in out
|
||||
|
||||
|
||||
def test_my_stack_toggle_requires_on_or_off():
|
||||
r = runner.invoke(my_stack_app, ["toggle", "official", "alpha"])
|
||||
assert r.exit_code == 2
|
||||
assert "exactly one" in _clean(r.output) or "exactly one" in _clean(r.stderr or "")
|
||||
|
||||
|
||||
def test_my_stack_toggle_writes_put(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def _put(path, payload):
|
||||
captured["path"] = path
|
||||
captured["payload"] = payload
|
||||
return {"ok": True}
|
||||
|
||||
import cli.commands.my_stack as ms_mod
|
||||
monkeypatch.setattr(ms_mod, "api_put_json", _put)
|
||||
|
||||
r = runner.invoke(my_stack_app, ["toggle", "official", "alpha", "--off"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert captured["path"] == "/api/my-stack/curated/official/alpha"
|
||||
assert captured["payload"] == {"enabled": False}
|
||||
assert "DISABLED" in _clean(r.output)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `agnes store update`
|
||||
|
|
|
|||
Loading…
Reference in a new issue