agnes-the-ai-analyst/tests/test_cli_marketplace.py
minasarustamyan efc607f3ee
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>
2026-05-13 05:20:56 +00:00

437 lines
13 KiB
Python

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