* 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>
437 lines
13 KiB
Python
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)
|