agnes-the-ai-analyst/app/api/my_stack.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

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