agnes-the-ai-analyst/tests/test_cli_store.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

359 lines
12 KiB
Python

"""Tests for `agnes store` (creator-side) and `agnes my-stack` Typer wrappers.
Smoke + happy-path. Network calls are mocked so tests don't depend on a
running server. Consumer-side browse/install ops (list, show, install,
uninstall) moved to `agnes marketplace` — see test_cli_marketplace.py.
"""
from __future__ import annotations
import re
from typer.testing import CliRunner
from cli.commands.my_stack import my_stack_app
from cli.commands.store import store_app
runner = CliRunner()
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _clean(s: str) -> str:
return _ANSI_RE.sub("", s)
# ---------------------------------------------------------------------------
# Help-text smoke tests — guard against accidental command renames.
# ---------------------------------------------------------------------------
def test_store_help_lists_subcommands():
r = runner.invoke(store_app, ["--help"])
assert r.exit_code == 0
out = _clean(r.output)
for cmd in ("upload", "update", "delete", "mine"):
assert cmd in out, f"missing subcommand {cmd!r} in help"
def test_admin_store_help_lists_subcommands():
from cli.commands.admin_store import admin_store_app
r = runner.invoke(admin_store_app, ["--help"])
assert r.exit_code == 0
out = _clean(r.output)
for cmd in ("pull", "push", "info"):
assert cmd in out
def test_my_stack_help_lists_subcommands():
r = runner.invoke(my_stack_app, ["--help"])
assert r.exit_code == 0
out = _clean(r.output)
assert "show" in out
assert "toggle" not in out
# ---------------------------------------------------------------------------
# Happy-path mocked tests.
# ---------------------------------------------------------------------------
def test_store_upload_sends_multipart(monkeypatch, tmp_path):
captured: dict = {}
def _multipart(path, *, files, data):
captured["path"] = path
captured["data"] = data
captured["files_keys"] = list(files.keys())
return {
"id": "new-id",
"name": data.get("name", "fallback"),
"invocation_name": "fallback-by-someone",
"version": "abcd1234",
}
import cli.commands.store as store_mod
monkeypatch.setattr(store_mod, "api_post_multipart", _multipart)
zip_path = tmp_path / "skill.zip"
zip_path.write_bytes(b"PK\x03\x04fake-zip-content")
r = runner.invoke(
store_app,
["upload", "skill", str(zip_path), "--name", "my-skill", "--description", "d"],
)
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/entities"
assert captured["data"]["type"] == "skill"
assert captured["data"]["name"] == "my-skill"
assert captured["data"]["description"] == "d"
assert captured["files_keys"] == ["file"]
def test_my_stack_show_renders(monkeypatch):
sample = {
"curated": [
{
"marketplace_id": "official",
"marketplace_slug": "official",
"plugin_name": "alpha",
"manifest_name": "alpha",
"version": "1.0",
"enabled": True,
},
],
"store": [
{
"entity_id": "e1",
"type": "skill",
"name": "code-review",
"owner_username": "alice",
"version": "abcd",
"invocation_name": "code-review-by-alice",
"install_count": 1,
},
],
}
import cli.commands.my_stack as ms_mod
monkeypatch.setattr(ms_mod, "api_get_json", lambda *a, **kw: sample)
r = runner.invoke(my_stack_app, [])
assert r.exit_code == 0, r.output
out = _clean(r.output)
assert "Curated" in out and "alpha" in out
assert "From Flea Market" in out and "code-review-by-alice" in out
# ---------------------------------------------------------------------------
# `agnes store update`
# ---------------------------------------------------------------------------
def test_store_update_help_lists_options():
r = runner.invoke(store_app, ["update", "--help"])
assert r.exit_code == 0
out = _clean(r.output)
for opt in ("--description", "--category", "--video-url", "--photo", "--zip"):
assert opt in out
def test_store_update_no_fields_exit_2():
r = runner.invoke(store_app, ["update", "abc123"])
assert r.exit_code == 2
assert "Nothing to update" in _clean(r.output)
def test_store_update_sends_put_multipart(monkeypatch):
captured: dict = {}
def _put(path, *, files, data):
captured["path"] = path
captured["files"] = files
captured["data"] = data
return {"id": "abc", "version": "newhash01234567"}
import cli.commands.store as store_mod
monkeypatch.setattr(store_mod, "api_put_multipart", _put)
r = runner.invoke(store_app, ["update", "abc", "--description", "new desc"])
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/entities/abc"
assert captured["data"] == {"description": "new desc"}
assert captured["files"] is None
assert "Updated" in _clean(r.output)
# ---------------------------------------------------------------------------
# `agnes store pull` / `agnes store info`
# ---------------------------------------------------------------------------
def test_admin_store_pull_writes_zip(monkeypatch, tmp_path):
"""Bulk pull of all Store entities lives under `agnes admin store pull`."""
from cli.commands.admin import admin_app
from cli.commands import admin_store as admin_store_mod
captured: dict = {}
def _stream(path, dest, **params):
captured["path"] = path
captured["params"] = params
with open(dest, "wb") as f:
f.write(b"PK\x03\x04fakezip")
return 9
monkeypatch.setattr(admin_store_mod, "api_get_stream", _stream)
out = tmp_path / "store.zip"
r = runner.invoke(admin_app, ["store", "pull", "-o", str(out)])
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/bundle.zip"
# `mine` uses owner=me; bulk pull does NOT.
assert "owner" not in captured["params"]
assert "Wrote 9 bytes" in _clean(r.output)
assert out.exists()
def test_admin_store_pull_unpack(monkeypatch, tmp_path):
"""`agnes admin store pull --unpack DIR` streams + extracts."""
import zipfile
from cli.commands.admin import admin_app
from cli.commands import admin_store as admin_store_mod
fake_zip_path = tmp_path / "_fake.zip"
with zipfile.ZipFile(fake_zip_path, "w") as zf:
zf.writestr("manifest.json", '{"format":1,"entries":[]}')
zf.writestr("entities/abc/plugin/.claude-plugin/plugin.json", '{}')
def _stream(path, dest, **params):
from pathlib import Path as _P
with open(dest, "wb") as fh:
fh.write(_P(fake_zip_path).read_bytes())
return _P(dest).stat().st_size
monkeypatch.setattr(admin_store_mod, "api_get_stream", _stream)
target = tmp_path / "unpacked"
r = runner.invoke(admin_app, ["store", "pull", "--unpack", str(target)])
assert r.exit_code == 0, r.output
assert (target / "manifest.json").is_file()
assert (target / "entities/abc/plugin/.claude-plugin/plugin.json").is_file()
def test_store_mine_uses_owner_me_param(monkeypatch, tmp_path):
"""`agnes store mine` is the user-facing variant — same endpoint with
`?owner=me` so server can scope to caller's own entities."""
captured: dict = {}
def _stream(path, dest, **params):
captured["path"] = path
captured["params"] = params
with open(dest, "wb") as f:
f.write(b"PK\x03\x04mine")
return 7
import cli.commands.store as store_mod
monkeypatch.setattr(store_mod, "api_get_stream", _stream)
out = tmp_path / "mine.zip"
r = runner.invoke(store_app, ["mine", "-o", str(out)])
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/bundle.zip"
assert captured["params"] == {"owner": "me"}
assert out.exists()
def test_admin_store_info_summarizes(monkeypatch):
from cli.commands.admin import admin_app
from cli.commands import admin_store as admin_store_mod
page1 = {
"items": [
{"type": "skill", "file_size": 1024},
{"type": "skill", "file_size": 512},
{"type": "agent", "file_size": 256},
],
"total": 3, "skip": 0, "limit": 100,
}
empty = {"items": [], "total": 3, "skip": 100, "limit": 100}
pages = [page1, empty]
monkeypatch.setattr(admin_store_mod, "api_get_json", lambda *a, **kw: pages.pop(0))
r = runner.invoke(admin_app, ["store", "info"])
assert r.exit_code == 0, r.output
out = _clean(r.output)
assert "3 entit" in out
assert "skill" in out and "2" in out
assert "agent" in out and "1" in out
def test_admin_store_info_json(monkeypatch):
from cli.commands.admin import admin_app
from cli.commands import admin_store as admin_store_mod
one = {
"items": [{"type": "plugin", "file_size": 999}],
"total": 1, "skip": 0, "limit": 100,
}
pages = [one, {"items": [], "total": 1, "skip": 100, "limit": 100}]
monkeypatch.setattr(admin_store_mod, "api_get_json", lambda *a, **kw: pages.pop(0))
r = runner.invoke(admin_app, ["store", "info", "--json"])
assert r.exit_code == 0, r.output
import json as _json
body = _json.loads(_clean(r.output))
assert body["total_entities"] == 1
assert body["by_type"] == {"plugin": 1}
# ---------------------------------------------------------------------------
# `agnes admin store push`
# ---------------------------------------------------------------------------
def test_admin_store_push_help():
from cli.commands.admin_store import admin_store_app
r = runner.invoke(admin_store_app, ["--help"])
assert r.exit_code == 0
assert "push" in _clean(r.output)
def test_admin_store_push_invalid_mode_exit_2(tmp_path):
"""Single-command Typer app — invoke via parent so the `push` token
actually routes to the subcommand (otherwise Typer collapses the lone
command and treats `push` as the SOURCE positional)."""
from cli.commands.admin import admin_app
bundle = tmp_path / "x.zip"
bundle.write_bytes(b"PK\x03\x04")
r = runner.invoke(admin_app, ["store", "push", str(bundle), "--mode", "wat"])
assert r.exit_code == 2
assert "merge|replace|skip" in _clean(r.output)
def test_admin_store_push_zips_directory(monkeypatch, tmp_path):
"""When source is a directory, CLI must zip it client-side and POST."""
import zipfile as _zf
captured: dict = {}
def _post(path, *, files, data):
captured["path"] = path
captured["data"] = data
zip_bytes = files["file"][1]
with _zf.ZipFile(__import__("io").BytesIO(zip_bytes)) as zf:
captured["names"] = sorted(zf.namelist())
return {
"imported": 1, "replaced": 0, "skipped": 0,
"stub_users_created": 0, "errors": [],
}
from cli.commands import admin_store as admin_store_mod
from cli.commands.admin import admin_app
monkeypatch.setattr(admin_store_mod, "api_post_multipart", _post)
bundle_dir = tmp_path / "bundle"
(bundle_dir / "entities" / "abc" / "plugin").mkdir(parents=True)
(bundle_dir / "manifest.json").write_text('{"format":1,"entries":[]}')
(bundle_dir / "entities" / "abc" / "plugin" / "marker.txt").write_text("x")
r = runner.invoke(
admin_app, ["store", "push", str(bundle_dir), "--mode", "merge", "--yes"],
)
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/import-bundle"
assert captured["data"] == {"mode": "merge"}
assert "manifest.json" in captured["names"]
assert "entities/abc/plugin/marker.txt" in captured["names"]
assert "imported=1" in _clean(r.output)
def test_admin_store_push_directory_without_manifest_exit_2(tmp_path):
from cli.commands.admin import admin_app
empty_dir = tmp_path / "no_manifest"
empty_dir.mkdir()
r = runner.invoke(
admin_app, ["store", "push", str(empty_dir), "--yes"],
)
assert r.exit_code == 2
assert "manifest.json" in _clean(r.output)