From efc607f3eef28adaa0866b07c2eee4c392290ba8 Mon Sep 17 00:00:00 2001 From: minasarustamyan <156230623+minasarustamyan@users.noreply.github.com> Date: Wed, 13 May 2026 07:20:56 +0200 Subject: [PATCH] feat(cli): agnes marketplace search/detail/add/remove + retire stale subcommands (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: ZdenekSrotyr --- CHANGELOG.md | 19 ++ app/api/my_stack.py | 8 +- cli/commands/marketplace.py | 191 ++++++++++++ cli/commands/my_stack.py | 36 +-- cli/commands/store.py | 106 +------ cli/main.py | 2 + config/agnes_workspace_template.txt | 27 +- config/claude_md_template.txt | 67 ++++- docs/HOWTO/05-customizing-skills.md | 74 +++-- pyproject.toml | 2 +- tests/test_cli_marketplace.py | 437 ++++++++++++++++++++++++++++ tests/test_cli_store.py | 89 +----- 12 files changed, 803 insertions(+), 255 deletions(-) create mode 100644 cli/commands/marketplace.py create mode 100644 tests/test_cli_marketplace.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db93084..68784ed 100644 --- a/CHANGELOG.md +++ b/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 ` — full detail view for any marketplace item (curated: `marketplace_id/plugin_name`, flea: UUID) +- `agnes marketplace add ` — add a plugin/skill/agent to your stack; works for both Curated and Flea Market +- `agnes marketplace remove ` — 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 ` +- **BREAKING** `agnes store install` — superseded by `agnes marketplace add ` +- **BREAKING** `agnes store uninstall` — superseded by `agnes marketplace remove ` + +### 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 diff --git a/app/api/my_stack.py b/app/api/my_stack.py index b1efa83..dc8823f 100644 --- a/app/api/my_stack.py +++ b/app/api/my_stack.py @@ -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); diff --git a/cli/commands/marketplace.py b/cli/commands/marketplace.py new file mode 100644 index 0000000..6271836 --- /dev/null +++ b/cli/commands/marketplace.py @@ -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) diff --git a/cli/commands/my_stack.py b/cli/commands/my_stack.py index 8400a33..cd36503 100644 --- a/cli/commands/my_stack.py +++ b/cli/commands/my_stack.py @@ -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}") diff --git a/cli/commands/store.py b/cli/commands/store.py index 8f35974..3a79968 100644 --- a/cli/commands/store.py +++ b/cli/commands/store.py @@ -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 diff --git a/cli/main.py b/cli/main.py index c0b9152..81c7314 100644 --- a/cli/main.py +++ b/cli/main.py @@ -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: diff --git a/config/agnes_workspace_template.txt b/config/agnes_workspace_template.txt index 060754a..f0b09f2 100644 --- a/config/agnes_workspace_template.txt +++ b/config/agnes_workspace_template.txt @@ -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 +agnes describe
-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
WHERE ..." # Remote BigQuery query (server-side, no local materialization) -agnes query --remote "SELECT count(*) FROM web_sessions_example" +agnes query --remote "SELECT count(*) FROM
" # 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
\ + --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 +agnes marketplace add +agnes marketplace remove +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 diff --git a/config/claude_md_template.txt b/config/claude_md_template.txt index 81ad6e2..bddeffb 100644 --- a/config/claude_md_template.txt +++ b/config/claude_md_template.txt @@ -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 `@agnes` regardless of which upstream it -came from — e.g. `claude plugin install @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 +agnes marketplace detail --json # 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 +agnes marketplace remove +``` + +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 `@agnes` + (e.g. `claude plugin install @agnes`). The SessionStart hook runs + `agnes refresh-marketplace --check` to detect upstream changes. ## Remote Queries (BigQuery) — when data isn't on the laptop diff --git a/docs/HOWTO/05-customizing-skills.md b/docs/HOWTO/05-customizing-skills.md index 47ae190..7e30836 100644 --- a/docs/HOWTO/05-customizing-skills.md +++ b/docs/HOWTO/05-customizing-skills.md @@ -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 + +# Add to / remove from your stack +agnes marketplace add +agnes marketplace remove + +# 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 # 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 +``` + +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. --- diff --git a/pyproject.toml b/pyproject.toml index c51189f..12a95ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_cli_marketplace.py b/tests/test_cli_marketplace.py new file mode 100644 index 0000000..ad1ce2d --- /dev/null +++ b/tests/test_cli_marketplace.py @@ -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) diff --git a/tests/test_cli_store.py b/tests/test_cli_store.py index b763de2..c6207ee 100644 --- a/tests/test_cli_store.py +++ b/tests/test_cli_store.py @@ -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`