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:
minasarustamyan 2026-05-13 07:20:56 +02:00 committed by GitHub
parent b4d3c576af
commit efc607f3ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 803 additions and 255 deletions

View file

@ -10,6 +10,25 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [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 ## [0.54.0] — 2026-05-12
Activity Center build — unified observability surface plus a recursive Activity Center build — unified observability surface plus a recursive

View file

@ -3,7 +3,7 @@
Provides: Provides:
* ``GET /api/my-stack`` combined view * ``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 Used by the ``agnes my-stack`` CLI subcommand. The web page that historically
backed these endpoints (``/my-ai-stack``) was removed in favor of 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), user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db), conn: duckdb.DuckDBPyConnection = Depends(_get_db),
): ):
"""Combined view of admin-curated plugins (with current opt-out state) """Combined view of curated plugins the caller can subscribe to
and Store entities the caller has installed. and Store entities they have installed.
""" """
granted = resolve_allowed_plugins(conn, user) granted = resolve_allowed_plugins(conn, user)
# Model B (v28+): explicit subscriptions decide what's enabled. # Model B (v28+): explicit subscriptions decide what's enabled.
@ -199,7 +199,7 @@ async def toggle_curated(
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db), 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 UI thinks in terms of *enabled* (default off in Model B). v28+ the
repository stores *subscribed* rows (presence = enabled in served set); repository stores *subscribed* rows (presence = enabled in served set);

191
cli/commands/marketplace.py Normal file
View 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)

View file

@ -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 Reads ``GET /api/my-stack``. To add or remove items use
``PUT /api/my-stack/curated/{marketplace_id}/{plugin_name}`` opt-out flips. ``agnes marketplace add/remove``.
""" """
from __future__ import annotations from __future__ import annotations
@ -11,16 +11,16 @@ from typing import Optional
import typer 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") @my_stack_app.command("show")
def show_stack( def show_stack(
json_out: bool = typer.Option(False, "--json"), 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: try:
body = api_get_json("/api/my-stack") body = api_get_json("/api/my-stack")
except V2ClientError as e: except V2ClientError as e:
@ -31,14 +31,14 @@ def show_stack(
return return
curated = body.get("curated", []) curated = body.get("curated", [])
store = body.get("store", []) store = body.get("store", [])
typer.echo(f"Curated (admin-granted): {len(curated)}") typer.echo(f"Curated plugins: {len(curated)}")
for p in curated: for p in curated:
flag = "" if p["enabled"] else "" flag = "" if p["enabled"] else ""
typer.echo( typer.echo(
f" [{flag}] {p['marketplace_id']}/{p['plugin_name']:24s} " f" [{flag}] {p['marketplace_id']}/{p['plugin_name']:24s} "
f"manifest={p['manifest_name']} v{p.get('version') or '?'}" 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: for it in store:
typer.echo( typer.echo(
f" [{it['type']:6s}] {it['name']:24s} by {it['owner_username']:20s} " 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}")

View file

@ -1,10 +1,9 @@
"""`agnes store {list,show,install,uninstall,upload,delete}` — community """`agnes store {upload,update,delete,mine}` — Flea Market creator-side ops.
marketplace browse/install over the REST API.
Mirrors the /store web UI for analyst CLI workflows. Listing + filters are For browsing and installing marketplace items use ``agnes marketplace``.
the read paths; install/uninstall/upload/delete are the write paths. All These commands cover the creator workflow: publish, update, remove, and
commands authenticate via the configured PAT (see ``cli auth``); the download your own entries. All commands authenticate via the configured PAT
endpoints are gated by ``get_current_user`` server-side. (see ``cli auth``); endpoints are gated by ``get_current_user`` server-side.
""" """
from __future__ import annotations from __future__ import annotations
@ -18,99 +17,14 @@ import typer
from cli.v2_client import ( from cli.v2_client import (
V2ClientError, V2ClientError,
api_delete, api_delete,
api_get_json,
api_get_stream, api_get_stream,
api_post_json,
api_post_multipart, api_post_multipart,
api_put_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") @store_app.command("upload")
def upload_entity( def upload_entity(
@ -121,7 +35,7 @@ def upload_entity(
category: Optional[str] = typer.Option(None, "--category"), category: Optional[str] = typer.Option(None, "--category"),
video_url: Optional[str] = typer.Option(None, "--video-url"), 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 = { files = {
"file": (zip_path.name, zip_path.read_bytes(), "application/zip"), "file": (zip_path.name, zip_path.read_bytes(), "application/zip"),
} }
@ -150,7 +64,7 @@ def delete_entity(
entity_id: str = typer.Argument(...), entity_id: str = typer.Argument(...),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), 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: if not yes:
confirm = typer.confirm(f"Delete entity {entity_id}?") confirm = typer.confirm(f"Delete entity {entity_id}?")
if not confirm: if not confirm:
@ -178,7 +92,7 @@ def update_entity(
help="Replace the plugin tree with this new ZIP", 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 Server-side authorization (PUT /api/store/entities/{id}) admits the
owner OR any member of the Admin group; CLI doesn't enforce, 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.", 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 Server-side this is the same ``GET /api/store/bundle.zip`` endpoint
that `agnes admin store pull` uses, scoped to the caller via that `agnes admin store pull` uses, scoped to the caller via

View file

@ -50,6 +50,7 @@ from cli.commands.snapshot import snapshot_app
from cli.commands.disk_info import disk_info_app from cli.commands.disk_info import disk_info_app
from cli.commands.store import store_app from cli.commands.store import store_app
from cli.commands.my_stack import my_stack_app from cli.commands.my_stack import my_stack_app
from cli.commands.marketplace import marketplace_app
def _cli_version() -> str: 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(disk_info_app, name="disk-info")
app.add_typer(store_app, name="store") app.add_typer(store_app, name="store")
app.add_typer(my_stack_app, name="my-stack") 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: def _capture_cli_exception(exc: BaseException, kind: str) -> None:

View file

@ -65,20 +65,31 @@ agnes catalog
agnes catalog --json | jq '.[] | select(.query_mode=="local")' agnes catalog --json | jq '.[] | select(.query_mode=="local")'
# Schema and sample # Schema and sample
agnes schema opportunity agnes schema <table>
agnes describe opportunity -n 10 agnes describe <table> -n 10
# Run a SQL query (DuckDB flavor against local parquets) # 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) # 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 # Materialize a remote subset locally
agnes snapshot create web_sessions_example \ agnes snapshot create <table> \
--select event_date,country_code \ --select col1,col2 \
--where "event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)" \ --where "date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)" \
--as recent_sessions --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) # Manual data refresh (the SessionStart hook does this automatically)
agnes pull agnes pull

View file

@ -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 `null` for `remote` rows. When `null`, treat the table as potentially large
and use `agnes snapshot create --estimate` to size-check before fetching. and use `agnes snapshot create --estimate` to size-check before fetching.
{% if marketplaces -%} ## Agnes Marketplace — discovering and managing skills, agents, plugins
## Agnes Marketplace — plugins available to you
These plugins reach Claude Code through the per-user **`agnes`** marketplace The marketplace has two sections:
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.
Upstream marketplaces folded into your `agnes` view: - **Curated** — vetted by your org; admins control RBAC visibility
{% for mp in marketplaces -%} - **Flea Market** — open community uploads anyone can publish (admin-approved)
- **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }}
{% endfor %} When the user asks "is there a skill/agent for X?" or "what's available for Y?",
{% endif -%} **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 ## Remote Queries (BigQuery) — when data isn't on the laptop

View file

@ -1,31 +1,53 @@
# Customising your skills # 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) ### 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. - The **Most Popular** section (top of `/marketplace`) shows the 8 most-invoked plugins over the last 30 days.
- 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.
- Sort options: **Recent** (default), **Most used (30d)**, **Trending (week-over-week)**. - Sort options: **Recent** (default), **Most used (30d)**, **Trending (week-over-week)**.
### Flea tab (community uploads) ### 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 ### After approval
```bash ```bash
# Sync your local marketplace to pick up the new plugin # Add it to your stack (or do it from the Flea tab on the web)
agnes refresh-marketplace agnes marketplace add <entity-id>
# Verify it's in your stack # 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: If you no longer want a plugin/skill/agent in your stack:
- 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.
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.
--- ---

View file

@ -1,6 +1,6 @@
[project] [project]
name = "agnes-the-ai-analyst" name = "agnes-the-ai-analyst"
version = "0.54.0" version = "0.54.1"
description = "Agnes — AI Data Analyst platform for AI analytical systems" description = "Agnes — AI Data Analyst platform for AI analytical systems"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
license = "MIT" license = "MIT"

View 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)

View file

@ -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 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 from __future__ import annotations
@ -30,7 +31,7 @@ def test_store_help_lists_subcommands():
r = runner.invoke(store_app, ["--help"]) r = runner.invoke(store_app, ["--help"])
assert r.exit_code == 0 assert r.exit_code == 0
out = _clean(r.output) 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" 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"]) r = runner.invoke(my_stack_app, ["--help"])
assert r.exit_code == 0 assert r.exit_code == 0
out = _clean(r.output) out = _clean(r.output)
for cmd in ("show", "toggle"): assert "show" in out
assert cmd in out assert "toggle" not 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -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): def test_store_upload_sends_multipart(monkeypatch, tmp_path):
captured: dict = {} captured: dict = {}
@ -167,36 +117,13 @@ def test_my_stack_show_renders(monkeypatch):
import cli.commands.my_stack as ms_mod import cli.commands.my_stack as ms_mod
monkeypatch.setattr(ms_mod, "api_get_json", lambda *a, **kw: sample) 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 assert r.exit_code == 0, r.output
out = _clean(r.output) out = _clean(r.output)
assert "Curated" in out and "alpha" in out 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` # `agnes store update`