feat(cli): agnes marketplace search/detail/add/remove + retire stale subcommands (#280)
* feat(cli): agnes marketplace search/detail/add/remove + retire stale subcommands
Unified CLI surface for the v28+ marketplace: search across Curated and
Flea Market (RBAC-filtered server-side), drill into a single item's
detail, add/remove from your stack. Replaces opt-out era commands that
no longer reflect how users compose their stack.
CLI changes:
- Added: agnes marketplace {search,detail,add,remove}
- Removed: agnes my-stack toggle (opt-out semantics, curated-only)
- Removed: agnes store {list,show,install,uninstall} (consumer-side ops
moved under marketplace; store now covers only creator-side upload,
update, delete, mine)
ID format unifies curated and flea: marketplace_id/plugin_name (slash)
routes to /api/marketplace/curated/..., bare UUID routes to
/api/store/entities/... (flea bundles skills/agents into a synthetic
plugin server-side, so the analyst sees a single add/remove surface).
Templates:
- claude_md_template.txt: rewritten marketplace section as operational
guidance for Claude Code (discovery, stack management, behaviour
notes). Dropped the static {% if marketplaces %} listing — the CLI is
the source of truth for what's in the stack at any moment, so a
snapshot rendered at init time would lie the moment the user runs
agnes marketplace add/remove. Same discipline already applied to
tables and metrics.
- agnes_workspace_template.txt: cheat sheet adds 5 marketplace
one-liners; keeps the file's reference-doc tone (the original
commit's intent: 'what is this thing, how does it work, how do I
uninstall it').
Docs: HOWTO/05-customizing-skills.md rewritten around the new CLI flow;
the opt-out section is replaced by 'Removing items from your stack'.
Tests: new test_cli_marketplace.py covers all four subcommands incl.
RBAC/409 paths (system plugin guard, not-approved flea entity);
test_cli_store.py trimmed to the retained creator-side commands.
* release: 0.54.1 — agnes marketplace CLI redesign + retire stale subcommands
Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.0 →
0.54.1) bundling the BREAKING removals of `agnes my-stack toggle` and
`agnes store {list,show,install,uninstall}` plus the new unified
`agnes marketplace {search,detail,add,remove}` surface.
No DB migration; no operator-facing config change. Operators on
floating tags (`:stable`) auto-upgrade transparently. Analyst CLI
upgrade prompt fires on next `agnes pull`; users invoking the
retired commands get "No such command" with the new `agnes
marketplace` substitution called out in the BREAKING bullets.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
This commit is contained in:
parent
b4d3c576af
commit
efc607f3ee
12 changed files with 803 additions and 255 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -10,6 +10,25 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
|
||||||
|
|
@ -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
191
cli/commands/marketplace.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""`agnes marketplace {search,detail,add,remove}` — unified marketplace CLI.
|
||||||
|
|
||||||
|
Replaces the legacy `agnes my-stack toggle` (curated only, opt-out era) and
|
||||||
|
the consumer-facing `agnes store install/uninstall/list/show`. Both Curated
|
||||||
|
and Flea Market items are handled through a single command surface that mirrors
|
||||||
|
the current web marketplace.
|
||||||
|
|
||||||
|
ID format:
|
||||||
|
Curated → marketplace_id/plugin_name (contains a slash)
|
||||||
|
Flea → UUID without slash
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from cli.v2_client import V2ClientError, api_delete, api_get_json, api_post_json
|
||||||
|
|
||||||
|
marketplace_app = typer.Typer(help="Browse and manage your Agnes marketplace stack")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_id(item_id: str) -> tuple[str, str, str]:
|
||||||
|
"""Return (source, part1, part2).
|
||||||
|
|
||||||
|
Curated: "/" in ID → ("curated", marketplace_id, plugin_name)
|
||||||
|
Flea: no slash → ("flea", entity_id, "")
|
||||||
|
"""
|
||||||
|
if "/" in item_id:
|
||||||
|
parts = item_id.split("/", 1)
|
||||||
|
return "curated", parts[0], parts[1]
|
||||||
|
return "flea", item_id, ""
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_app.command("search")
|
||||||
|
def search(
|
||||||
|
query: Optional[str] = typer.Option(None, "-q", "--query", help="Search text"),
|
||||||
|
type: Optional[str] = typer.Option(None, "--type", help="skill | agent | plugin"),
|
||||||
|
source: Optional[str] = typer.Option(None, "--source", help="curated | flea (default: both)"),
|
||||||
|
sort: str = typer.Option("recent", "--sort", help="recent | most_used | trending"),
|
||||||
|
limit: int = typer.Option(24, "--limit", min=1, max=100),
|
||||||
|
json_out: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Search Curated and Flea Market; returns only items you have access to."""
|
||||||
|
tabs = [source] if source else ["curated", "flea"]
|
||||||
|
all_items: list = []
|
||||||
|
for tab in tabs:
|
||||||
|
params: dict = {"tab": tab, "sort": sort, "page_size": limit}
|
||||||
|
if query:
|
||||||
|
params["q"] = query
|
||||||
|
if type:
|
||||||
|
params["type"] = type
|
||||||
|
try:
|
||||||
|
body = api_get_json("/api/marketplace/items", **params)
|
||||||
|
except V2ClientError as e:
|
||||||
|
typer.echo(str(e), err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
all_items.extend(body.get("items", []))
|
||||||
|
|
||||||
|
if json_out:
|
||||||
|
typer.echo(json.dumps({"items": all_items, "total": len(all_items)}, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not all_items:
|
||||||
|
typer.echo("No results.")
|
||||||
|
return
|
||||||
|
|
||||||
|
label = f'"{query}"' if query else "marketplace"
|
||||||
|
typer.echo(f"{len(all_items)} result(s) for {label}:")
|
||||||
|
for it in all_items:
|
||||||
|
status = "✓ in stack" if it.get("installed") else "+ add"
|
||||||
|
typer.echo(
|
||||||
|
f" [{it.get('type', '?'):6s}] [{it.get('source', '?'):7s}] "
|
||||||
|
f"{it.get('name', '?'):30s} by {it.get('owner', '?'):20s} "
|
||||||
|
f"{status:10s} id={it['id']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_app.command("detail")
|
||||||
|
def detail(
|
||||||
|
item_id: str = typer.Argument(..., help="Item ID: marketplace_id/plugin_name or UUID"),
|
||||||
|
json_out: bool = typer.Option(False, "--json"),
|
||||||
|
):
|
||||||
|
"""Show full details for a marketplace item (curated or flea)."""
|
||||||
|
source, part1, part2 = _parse_id(item_id)
|
||||||
|
try:
|
||||||
|
if source == "curated":
|
||||||
|
body = api_get_json(f"/api/marketplace/curated/{part1}/{part2}")
|
||||||
|
else:
|
||||||
|
body = api_get_json(f"/api/marketplace/flea/{part1}/detail")
|
||||||
|
except V2ClientError as e:
|
||||||
|
typer.echo(str(e), err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if json_out:
|
||||||
|
typer.echo(json.dumps(body, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
name = body.get("display_name") or body.get("plugin_name") or body.get("manifest_name") or "?"
|
||||||
|
item_type = body.get("type", "plugin")
|
||||||
|
version = body.get("version") or "?"
|
||||||
|
src_label = f"curated: {body.get('marketplace_id')}" if source == "curated" else "flea"
|
||||||
|
installed = body.get("installed", False)
|
||||||
|
|
||||||
|
typer.echo(f"{name} ({item_type}) v{version} [{src_label}]")
|
||||||
|
typer.echo(f" {'✓ In your stack' if installed else '+ Not in stack'}")
|
||||||
|
|
||||||
|
if body.get("tagline"):
|
||||||
|
typer.echo(f"\n {body['tagline']}")
|
||||||
|
if body.get("description"):
|
||||||
|
typer.echo(f"\n {body['description']}")
|
||||||
|
|
||||||
|
use_cases = body.get("use_cases", [])
|
||||||
|
if use_cases:
|
||||||
|
typer.echo("\n Use cases:")
|
||||||
|
for uc in use_cases:
|
||||||
|
title = uc.get("title") or uc if isinstance(uc, str) else str(uc)
|
||||||
|
typer.echo(f" • {title}")
|
||||||
|
|
||||||
|
skills = body.get("skills", [])
|
||||||
|
agents = body.get("agents", [])
|
||||||
|
commands = body.get("commands", [])
|
||||||
|
mcps = body.get("mcps", [])
|
||||||
|
|
||||||
|
if any([skills, agents, commands, mcps]):
|
||||||
|
typer.echo("\n Contents:")
|
||||||
|
if skills:
|
||||||
|
typer.echo(f" Skills: {', '.join(s.get('name', '?') for s in skills)}")
|
||||||
|
if agents:
|
||||||
|
typer.echo(f" Agents: {', '.join(a.get('name', '?') for a in agents)}")
|
||||||
|
if commands:
|
||||||
|
names = [c if isinstance(c, str) else c.get("name", "?") for c in commands]
|
||||||
|
typer.echo(f" Commands: {', '.join(names)}")
|
||||||
|
if mcps:
|
||||||
|
names = [m if isinstance(m, str) else m.get("name", "?") for m in mcps]
|
||||||
|
typer.echo(f" MCP servers: {', '.join(names)}")
|
||||||
|
|
||||||
|
if not installed:
|
||||||
|
typer.echo(f"\n Add to stack: agnes marketplace add {item_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_app.command("add")
|
||||||
|
def add(
|
||||||
|
item_id: str = typer.Argument(..., help="Item ID: marketplace_id/plugin_name or UUID"),
|
||||||
|
):
|
||||||
|
"""Add a plugin, skill, or agent to your stack."""
|
||||||
|
source, part1, part2 = _parse_id(item_id)
|
||||||
|
try:
|
||||||
|
if source == "curated":
|
||||||
|
api_post_json(f"/api/marketplace/curated/{part1}/{part2}/install", {})
|
||||||
|
else:
|
||||||
|
api_post_json(f"/api/store/entities/{part1}/install", {})
|
||||||
|
except V2ClientError as e:
|
||||||
|
_handle_install_error(e)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
typer.echo("Added to your stack. Run /update-agnes-plugins in Claude Code to activate.")
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_app.command("remove")
|
||||||
|
def remove(
|
||||||
|
item_id: str = typer.Argument(..., help="Item ID: marketplace_id/plugin_name or UUID"),
|
||||||
|
):
|
||||||
|
"""Remove a plugin, skill, or agent from your stack."""
|
||||||
|
source, part1, part2 = _parse_id(item_id)
|
||||||
|
try:
|
||||||
|
if source == "curated":
|
||||||
|
api_delete(f"/api/marketplace/curated/{part1}/{part2}/install")
|
||||||
|
else:
|
||||||
|
api_delete(f"/api/store/entities/{part1}/install")
|
||||||
|
except V2ClientError as e:
|
||||||
|
_handle_install_error(e)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
typer.echo("Removed from your stack. Run /update-agnes-plugins in Claude Code to apply.")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_install_error(e: V2ClientError) -> None:
|
||||||
|
if e.status_code == 409:
|
||||||
|
body = e.body if isinstance(e.body, dict) else {}
|
||||||
|
detail_str = body.get("detail", "")
|
||||||
|
if "system" in detail_str:
|
||||||
|
typer.echo("Cannot modify — this is a system plugin managed by your admin.", err=True)
|
||||||
|
elif "approved" in detail_str:
|
||||||
|
typer.echo("This item is not yet approved and cannot be installed.", err=True)
|
||||||
|
else:
|
||||||
|
typer.echo(str(e), err=True)
|
||||||
|
elif e.status_code == 403:
|
||||||
|
typer.echo("You do not have permission to access this plugin.", err=True)
|
||||||
|
else:
|
||||||
|
typer.echo(str(e), err=True)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""`agnes my-stack {show,toggle}` — per-user marketplace composition view.
|
"""`agnes my-stack show` — read-only view of the user's current marketplace stack.
|
||||||
|
|
||||||
Reads ``GET /api/my-stack`` and writes
|
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}")
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
437
tests/test_cli_marketplace.py
Normal file
437
tests/test_cli_marketplace.py
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
"""Tests for `agnes marketplace` Typer wrapper.
|
||||||
|
|
||||||
|
Smoke + happy-path. Network calls are mocked so tests don't depend on a
|
||||||
|
running server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from cli.commands.marketplace import marketplace_app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(s: str) -> str:
|
||||||
|
return _ANSI_RE.sub("", s)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Help smoke tests — guard against accidental command renames.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_help_lists_subcommands():
|
||||||
|
r = runner.invoke(marketplace_app, ["--help"])
|
||||||
|
assert r.exit_code == 0
|
||||||
|
out = _clean(r.output)
|
||||||
|
for cmd in ("search", "detail", "add", "remove"):
|
||||||
|
assert cmd in out, f"missing subcommand {cmd!r} in help"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_help():
|
||||||
|
r = runner.invoke(marketplace_app, ["search", "--help"])
|
||||||
|
assert r.exit_code == 0
|
||||||
|
out = _clean(r.output)
|
||||||
|
for opt in ("--query", "--type", "--source", "--sort", "--limit", "--json"):
|
||||||
|
assert opt in out, f"missing option {opt!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_detail_help():
|
||||||
|
r = runner.invoke(marketplace_app, ["detail", "--help"])
|
||||||
|
assert r.exit_code == 0
|
||||||
|
assert "--json" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_add_help():
|
||||||
|
r = runner.invoke(marketplace_app, ["add", "--help"])
|
||||||
|
assert r.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_remove_help():
|
||||||
|
r = runner.invoke(marketplace_app, ["remove", "--help"])
|
||||||
|
assert r.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CURATED_ITEMS = [
|
||||||
|
{
|
||||||
|
"id": "foundry-ai/pdf-generator",
|
||||||
|
"source": "curated",
|
||||||
|
"type": "skill",
|
||||||
|
"name": "pdf-generator",
|
||||||
|
"owner": "c-marustamyan",
|
||||||
|
"installed": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
_FLEA_ITEMS = [
|
||||||
|
{
|
||||||
|
"id": "abc123def456abc1",
|
||||||
|
"source": "flea",
|
||||||
|
"type": "agent",
|
||||||
|
"name": "pdf-extractor",
|
||||||
|
"owner": "someone",
|
||||||
|
"installed": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_search_mock(curated=None, flea=None):
|
||||||
|
"""Returns a mock api_get_json that returns curated/flea data by tab param."""
|
||||||
|
curated = curated if curated is not None else _CURATED_ITEMS
|
||||||
|
flea = flea if flea is not None else _FLEA_ITEMS
|
||||||
|
|
||||||
|
def _mock(*args, **kwargs):
|
||||||
|
tab = kwargs.get("tab", "curated")
|
||||||
|
if tab == "curated":
|
||||||
|
return {"items": curated, "total": len(curated)}
|
||||||
|
return {"items": flea, "total": len(flea)}
|
||||||
|
|
||||||
|
return _mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_no_source_queries_both(monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def _mock(*args, **kwargs):
|
||||||
|
calls.append(kwargs.get("tab"))
|
||||||
|
return _make_search_mock()(*args, **kwargs)
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["search"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert "curated" in calls
|
||||||
|
assert "flea" in calls
|
||||||
|
out = _clean(r.output)
|
||||||
|
assert "pdf-generator" in out
|
||||||
|
assert "pdf-extractor" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_source_curated(monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def _mock(*args, **kwargs):
|
||||||
|
calls.append(kwargs.get("tab"))
|
||||||
|
return {"items": _CURATED_ITEMS, "total": 1}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["search", "--source", "curated"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert calls == ["curated"]
|
||||||
|
assert "pdf-generator" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_source_flea(monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def _mock(*args, **kwargs):
|
||||||
|
calls.append(kwargs.get("tab"))
|
||||||
|
return {"items": _FLEA_ITEMS, "total": 1}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["search", "--source", "flea"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert calls == ["flea"]
|
||||||
|
assert "pdf-extractor" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_json(monkeypatch):
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _make_search_mock())
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["search", "--json"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
body = json.loads(_clean(r.output))
|
||||||
|
assert "items" in body
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_type_filter(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _mock(*args, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["search", "--type", "skill"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured.get("type") == "skill"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_query_passed(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _mock(*args, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
runner.invoke(marketplace_app, ["search", "-q", "pdf"])
|
||||||
|
assert captured.get("q") == "pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_search_no_results(monkeypatch):
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", lambda *a, **kw: {"items": [], "total": 0})
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["search", "-q", "nothing"])
|
||||||
|
assert r.exit_code == 0
|
||||||
|
assert "No results" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CURATED_DETAIL = {
|
||||||
|
"source": "curated",
|
||||||
|
"marketplace_id": "foundry-ai",
|
||||||
|
"plugin_name": "pdf-generator",
|
||||||
|
"manifest_name": "pdf-generator",
|
||||||
|
"display_name": "PDF Generator",
|
||||||
|
"type": "skill",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"tagline": "Generate PDFs from data",
|
||||||
|
"description": "Generates PDF documents.",
|
||||||
|
"installed": True,
|
||||||
|
"use_cases": [{"title": "Export report"}, {"title": "Generate invoice"}],
|
||||||
|
"skills": [{"name": "pdf-generator"}],
|
||||||
|
"commands": ["/pdf-by-c-marustamyan"],
|
||||||
|
"mcps": [],
|
||||||
|
"agents": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
_FLEA_DETAIL = {
|
||||||
|
"source": "flea",
|
||||||
|
"entity_id": "abc123def456abc1",
|
||||||
|
"plugin_name": "pdf-extractor",
|
||||||
|
"manifest_name": "pdf-extractor",
|
||||||
|
"type": "agent",
|
||||||
|
"version": "0.9.0",
|
||||||
|
"description": "Extracts text from PDFs.",
|
||||||
|
"installed": False,
|
||||||
|
"use_cases": [],
|
||||||
|
"skills": [],
|
||||||
|
"commands": [],
|
||||||
|
"mcps": [],
|
||||||
|
"agents": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_detail_curated(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _mock(path, **kw):
|
||||||
|
captured["path"] = path
|
||||||
|
return _CURATED_DETAIL
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["detail", "foundry-ai/pdf-generator"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured["path"] == "/api/marketplace/curated/foundry-ai/pdf-generator"
|
||||||
|
out = _clean(r.output)
|
||||||
|
assert "PDF Generator" in out
|
||||||
|
assert "In your stack" in out
|
||||||
|
assert "Export report" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_detail_flea(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _mock(path, **kw):
|
||||||
|
captured["path"] = path
|
||||||
|
return _FLEA_DETAIL
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", _mock)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["detail", "abc123def456abc1"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured["path"] == "/api/marketplace/flea/abc123def456abc1/detail"
|
||||||
|
out = _clean(r.output)
|
||||||
|
assert "pdf-extractor" in out
|
||||||
|
assert "Not in stack" in out
|
||||||
|
assert "agnes marketplace add abc123def456abc1" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_detail_json(monkeypatch):
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_get_json", lambda *a, **kw: _CURATED_DETAIL)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["detail", "--json", "foundry-ai/pdf-generator"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
body = json.loads(_clean(r.output))
|
||||||
|
assert body["plugin_name"] == "pdf-generator"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_detail_not_found(monkeypatch):
|
||||||
|
from cli.v2_client import V2ClientError
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mp_mod, "api_get_json",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(V2ClientError(404, {"detail": "not_found"})),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["detail", "foundry-ai/missing"])
|
||||||
|
assert r.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_detail_forbidden(monkeypatch):
|
||||||
|
from cli.v2_client import V2ClientError
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mp_mod, "api_get_json",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(V2ClientError(403, {"detail": "forbidden"})),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["detail", "foundry-ai/secret"])
|
||||||
|
assert r.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# add
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_add_curated(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _post(path, payload):
|
||||||
|
captured["path"] = path
|
||||||
|
return {"installed": True}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_post_json", _post)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["add", "foundry-ai/pdf-generator"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured["path"] == "/api/marketplace/curated/foundry-ai/pdf-generator/install"
|
||||||
|
assert "Added" in _clean(r.output)
|
||||||
|
assert "update-agnes-plugins" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_add_flea(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _post(path, payload):
|
||||||
|
captured["path"] = path
|
||||||
|
return {"entity_id": "abc123def456abc1", "installed": True}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_post_json", _post)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["add", "abc123def456abc1"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured["path"] == "/api/store/entities/abc123def456abc1/install"
|
||||||
|
assert "Added" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_add_system_plugin_409(monkeypatch):
|
||||||
|
from cli.v2_client import V2ClientError
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mp_mod, "api_post_json",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(
|
||||||
|
V2ClientError(409, {"detail": "cannot_unsubscribe_system_plugin"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["add", "foundry-ai/core"])
|
||||||
|
assert r.exit_code == 1
|
||||||
|
assert "system plugin" in _clean(r.stderr or r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_add_not_approved_409(monkeypatch):
|
||||||
|
from cli.v2_client import V2ClientError
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mp_mod, "api_post_json",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(
|
||||||
|
V2ClientError(409, {"detail": "entity_not_approved"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["add", "abc123def456abc1"])
|
||||||
|
assert r.exit_code == 1
|
||||||
|
assert "approved" in _clean(r.stderr or r.output)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# remove
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_remove_curated(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _delete(path):
|
||||||
|
captured["path"] = path
|
||||||
|
return {"installed": False}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_delete", _delete)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["remove", "foundry-ai/pdf-generator"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured["path"] == "/api/marketplace/curated/foundry-ai/pdf-generator/install"
|
||||||
|
assert "Removed" in _clean(r.output)
|
||||||
|
assert "update-agnes-plugins" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_remove_flea(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _delete(path):
|
||||||
|
captured["path"] = path
|
||||||
|
return {"entity_id": "abc123def456abc1", "installed": False}
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(mp_mod, "api_delete", _delete)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["remove", "abc123def456abc1"])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
assert captured["path"] == "/api/store/entities/abc123def456abc1/install"
|
||||||
|
assert "Removed" in _clean(r.output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_remove_system_plugin_409(monkeypatch):
|
||||||
|
from cli.v2_client import V2ClientError
|
||||||
|
|
||||||
|
import cli.commands.marketplace as mp_mod
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mp_mod, "api_delete",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(
|
||||||
|
V2ClientError(409, {"detail": "cannot_unsubscribe_system_plugin"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = runner.invoke(marketplace_app, ["remove", "foundry-ai/core"])
|
||||||
|
assert r.exit_code == 1
|
||||||
|
assert "system plugin" in _clean(r.stderr or r.output)
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Tests for `agnes store` and `agnes my-stack` Typer wrappers.
|
"""Tests for `agnes store` (creator-side) and `agnes my-stack` Typer wrappers.
|
||||||
|
|
||||||
Smoke + happy-path. Network calls are mocked so tests don't depend on a
|
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`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue