* 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>
252 lines
8.8 KiB
Python
252 lines
8.8 KiB
Python
"""Per-user composition view of the served Claude Code marketplace.
|
|
|
|
Provides:
|
|
|
|
* ``GET /api/my-stack`` — combined view
|
|
* ``PUT /api/my-stack/curated/{marketplace_id}/{plugin}`` — toggle subscription
|
|
|
|
Used by the ``agnes my-stack`` CLI subcommand. The web page that historically
|
|
backed these endpoints (``/my-ai-stack``) was removed in favor of
|
|
``/marketplace?tab=my``, but the API stays as the public CLI surface. Both
|
|
endpoints touch the same caches as the Store endpoints (ETag invalidation) so
|
|
any change here propagates to ``/marketplace.zip`` + ``/marketplace.git/`` on
|
|
the next request.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, List, Optional
|
|
|
|
import duckdb
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app.auth.dependencies import _get_db, get_current_user
|
|
from src.marketplace_filter import resolve_allowed_plugins
|
|
from src.repositories.audit import AuditRepository
|
|
from src.repositories.store_entities import StoreEntitiesRepository
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
from src.store_naming import suffixed_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/my-stack", tags=["my-stack"])
|
|
|
|
|
|
class CuratedPlugin(BaseModel):
|
|
marketplace_id: str
|
|
marketplace_slug: str
|
|
plugin_name: str
|
|
manifest_name: str
|
|
description: Optional[str] = None
|
|
version: Optional[str] = None
|
|
enabled: bool
|
|
# v39: when TRUE, the user cannot unsubscribe (UI disables the
|
|
# toggle, API guard returns 409). Pre-subscribed by mark_system +
|
|
# creation hooks so ``enabled`` is always TRUE here.
|
|
is_system: bool = False
|
|
|
|
|
|
class StoreInstallEntry(BaseModel):
|
|
entity_id: str
|
|
type: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
version: str
|
|
owner_user_id: str
|
|
owner_username: str
|
|
invocation_name: str
|
|
install_count: int
|
|
photo_url: Optional[str] = None
|
|
installed_at: Optional[str] = None
|
|
# v35: surface visibility so my_ai_stack.html can render an
|
|
# "Archived by owner" badge on cards whose owner soft-deleted the
|
|
# entity. Bundle still serves to existing installs (per
|
|
# UserStoreInstallsRepository.list_for_user filter).
|
|
visibility_status: Optional[str] = None
|
|
|
|
|
|
class MyStackResponse(BaseModel):
|
|
curated: List[CuratedPlugin]
|
|
store: List[StoreInstallEntry]
|
|
|
|
|
|
class ToggleRequest(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class OkResponse(BaseModel):
|
|
ok: bool = True
|
|
|
|
|
|
def _to_iso(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
return str(value)
|
|
|
|
|
|
def _audit(
|
|
conn: duckdb.DuckDBPyConnection,
|
|
actor_id: str,
|
|
action: str,
|
|
target: str,
|
|
params: Optional[dict] = None,
|
|
) -> None:
|
|
try:
|
|
AuditRepository(conn).log(
|
|
user_id=actor_id, action=action, resource=target, params=params
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.get("", response_model=MyStackResponse)
|
|
async def get_my_stack(
|
|
user: dict = Depends(get_current_user),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Combined view of curated plugins the caller can subscribe to
|
|
and Store entities they have installed.
|
|
"""
|
|
granted = resolve_allowed_plugins(conn, user)
|
|
# Model B (v28+): explicit subscriptions decide what's enabled.
|
|
# `enabled` mirrors the legacy "not opted_out" UX so the existing toggle
|
|
# remains semantically intuitive in the my-stack view.
|
|
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
|
|
|
|
# v39: surface is_system flag so the template can lock the toggle.
|
|
# One round trip — set membership intersection in Python is cheaper
|
|
# than joining marketplace_plugins per-row inside resolve_allowed_plugins
|
|
# (which is also called from the marketplace_filter / packager hot path).
|
|
sys_rows = conn.execute(
|
|
"SELECT marketplace_id, name FROM marketplace_plugins "
|
|
"WHERE is_system = TRUE",
|
|
).fetchall()
|
|
system_plugins: set[tuple[str, str]] = {(r[0], r[1]) for r in sys_rows}
|
|
|
|
curated: List[CuratedPlugin] = []
|
|
for p in granted:
|
|
key = (p["marketplace_id"], p["original_name"])
|
|
is_subscribed = key in subs
|
|
is_system = key in system_plugins
|
|
curated.append(
|
|
CuratedPlugin(
|
|
marketplace_id=p["marketplace_id"],
|
|
marketplace_slug=p["marketplace_slug"],
|
|
plugin_name=p["original_name"],
|
|
manifest_name=p["manifest_name"],
|
|
description=p["raw"].get("description"),
|
|
version=p.get("version"),
|
|
enabled=is_subscribed,
|
|
is_system=is_system,
|
|
)
|
|
)
|
|
|
|
installs = UserStoreInstallsRepository(conn).list_for_user(user["id"])
|
|
store_items: List[StoreInstallEntry] = []
|
|
from src.store_naming import strip_archive_suffix
|
|
for row in installs:
|
|
photo_url = (
|
|
f"/api/store/entities/{row['id']}/photo" if row.get("photo_path") else None
|
|
)
|
|
# Display name strips the archive-rename suffix so the user
|
|
# sees their installed plugin's original label even after the
|
|
# owner archived (and renamed) it. The served ``invocation_name``
|
|
# carries the renamed slug since that's what Claude Code's
|
|
# `/plugin` lookup will resolve to after the next sync — this
|
|
# is the consumer-side rename described in the rename-on-
|
|
# archive plan; the My AI Stack card surfaces it via the
|
|
# "Archived by owner" badge already.
|
|
raw_name = row["name"]
|
|
display_name = strip_archive_suffix(raw_name)
|
|
store_items.append(
|
|
StoreInstallEntry(
|
|
entity_id=row["id"],
|
|
type=row["type"],
|
|
name=display_name,
|
|
description=row.get("description"),
|
|
category=row.get("category"),
|
|
version=row["version"],
|
|
owner_user_id=row["owner_user_id"],
|
|
owner_username=row["owner_username"],
|
|
invocation_name=suffixed_name(raw_name, row["owner_username"]),
|
|
install_count=int(row.get("install_count") or 0),
|
|
photo_url=photo_url,
|
|
installed_at=_to_iso(row.get("installed_at")),
|
|
visibility_status=row.get("visibility_status") or "approved",
|
|
)
|
|
)
|
|
|
|
return MyStackResponse(curated=curated, store=store_items)
|
|
|
|
|
|
@router.put(
|
|
"/curated/{marketplace_id}/{plugin_name}",
|
|
response_model=OkResponse,
|
|
)
|
|
async def toggle_curated(
|
|
marketplace_id: str,
|
|
plugin_name: str,
|
|
body: ToggleRequest,
|
|
user: dict = Depends(get_current_user),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Toggle subscribe/unsubscribe for a single curated plugin.
|
|
|
|
UI thinks in terms of *enabled* (default off in Model B). v28+ the
|
|
repository stores *subscribed* rows (presence = enabled in served set);
|
|
``enabled=true`` writes a row, ``enabled=false`` removes it.
|
|
"""
|
|
# Sanity: caller must actually have the plugin granted (otherwise the
|
|
# toggle is meaningless and would just leak rows for ungranted plugins).
|
|
granted = resolve_allowed_plugins(conn, user)
|
|
has_grant = any(
|
|
p["marketplace_id"] == marketplace_id and p["original_name"] == plugin_name
|
|
for p in granted
|
|
)
|
|
if not has_grant:
|
|
raise HTTPException(status_code=404, detail="grant_not_found")
|
|
|
|
# v39: system plugins are pinned in every user's stack — refuse the
|
|
# unsubscribe path. Subscribe is still allowed (no-op on the
|
|
# already-materialized row).
|
|
if not body.enabled:
|
|
sys_row = conn.execute(
|
|
"SELECT is_system FROM marketplace_plugins "
|
|
"WHERE marketplace_id = ? AND name = ?",
|
|
[marketplace_id, plugin_name],
|
|
).fetchone()
|
|
if sys_row and bool(sys_row[0]):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="cannot_unsubscribe_system_plugin",
|
|
)
|
|
|
|
repo = UserCuratedSubscriptionsRepository(conn)
|
|
if body.enabled:
|
|
repo.subscribe(user["id"], marketplace_id, plugin_name)
|
|
else:
|
|
repo.unsubscribe(user["id"], marketplace_id, plugin_name)
|
|
_audit(
|
|
conn,
|
|
user["id"],
|
|
"my_stack.curated.toggle",
|
|
f"plugin:{marketplace_id}/{plugin_name}",
|
|
{"enabled": body.enabled},
|
|
)
|
|
|
|
try:
|
|
from app.marketplace_server import packager
|
|
packager.invalidate_etag_cache()
|
|
except Exception:
|
|
logger.exception("failed to invalidate marketplace etag cache")
|
|
|
|
return OkResponse()
|