* System plugin tier with mark/unmark fanout (schema v39)
Adds a mandatory plugin tier so admins can pin a small set of curated
plugins into every user's stack from day one. Marking a plugin via the
new toggle on /admin/marketplaces materializes resource_grants for every
group and user_plugin_optouts subscriptions for every user, so the
existing resolver pulls the plugin into every served set without a new
filter layer. Hooks on user-create (Google OAuth, magic-link, admin
POST, scheduler) and group-create propagate the same materialization to
new principals. UI locks: /admin/access disables the checkbox with a
SYSTEM pill; /marketplace cards swap the "In stack" green pill for an
amber "Required" badge with shield icon; the plugin detail install
button reads "Required by your org"; /my-ai-stack toggle is disabled.
Bypass paths return 409 (DELETE /api/admin/grants for system grants,
PUT /api/my-stack/curated/.../{enabled:false}, DELETE
/api/marketplace/curated/.../install). Unmark only flips the flag —
materialized rows persist so admins curate cleanup at their leisure
through the now-unlocked /admin/access checkboxes.
* Marketplace UX polish + drop legacy /store and /my-ai-stack pages
Two-part cleanup post-v39:
(1) Page deletion. /store and /my-ai-stack were already replaced by
/marketplace?tab=flea and /marketplace?tab=my respectively, but the
standalone routes lingered. Hard delete in dev mode — no redirects,
stale bookmarks 404. The /store/new upload wizard, the flea
detail/edit pages, the admin queue, and all /api/store/* +
/api/my-stack endpoints (CLI consumers) stay. Internal hardcoded
hrefs in the upload wizard's Cancel button and the advanced-setup
page repointed to the marketplace tabs.
(2) Detail-page install button rework. The single button that morphed
between "+ Add to my stack" and "✓ In your stack" did not
communicate uninstall affordance. The installed state now renders an
inline white status label *before* a separate red-bordered
"✕ Remove from stack" button on the same row, both at identical
height to avoid layout shift. System plugins keep their locked amber
"✓ Required by your org" pill (no Remove button — API refuses 409).
The post-action hint panel now fires on remove too with the title
flipped to "✓ Removed from your stack" — Claude Code needs the same
/update-agnes-plugins refresh either way.
Also: /admin/marketplaces Details modal "Mark as system" toggle
redesigned. The button was near-invisible (matched neutral row
metadata). It's now a balanced amber-toned chip with shield icon
and a structured confirm modal replacing the native confirm() dialog
that summarizes fanout consequences before commit.
* Move stack-hint inside hero with glass-on-gradient styling
The post-action hint card ("✓ Added to your stack" with the
/update-agnes-plugins recipe) used to live below the hero in
panel-what (gray card on white page body). Clicking add/remove
inserted/removed it between the hero and content, shifting the
panels below — a noticeable scroll jump.
The hint is now anchored inside the hero's top-right corner alongside
the install/remove buttons, both as flex children of an absolutely
positioned .actions container. The card uses a translucent
white-on-glass treatment that adopts the hero's kind color (blue for
plugin, green for skill, purple for agent) without per-kind branching.
Hero is always tall enough (160px photo) to contain the action+hint
stack without overflow, so toggling the hint visibility doesn't grow
the hero or shift body content.
The hero-head grid reserves a third 300px column for the absolute
actions overlay so meta gets the proper 1fr free space instead of
being squeezed by a padding-right hack. Responsive breakpoint at
1100px reflows the actions stack below hero-head when the viewport
isn't wide enough to keep meta + actions side-by-side comfortably.
* Add optional -DataPath bind mount to run-local-dev.ps1
When the operator wants to inspect DuckDB files (system.duckdb, extracts,
marketplaces, store/, …) directly from Windows Explorer, the named volume
inside the Docker Desktop WSL VM isn't reachable. The new -DataPath param
generates a transient compose override that rebinds /data on app, scheduler,
extract (and Caddy's /srv:ro mirror) to a Windows host folder.
Fully additive — when -DataPath is omitted everything behaves exactly as
before: no override file is generated, $composeFiles array is unchanged,
finally cleanup is a no-op. Existing positional invocations
(.\run-local-dev.ps1 up | down | logs) keep binding to $Action because
$DataPath is a named-only parameter with no Position attribute.
The override is written via [System.IO.File]::WriteAllText so the YAML is
BOM-less across PS 5.1 / 7+ — Compose rejects BOM-prefixed YAML on Windows.
The override file is unique per PID and removed in the script's finally
block so concurrent invocations and crashes don't leak files.
* factor mark_system fanout into UserCuratedSubscriptionsRepository
The endpoint imported UserCuratedSubscriptionsRepository, ignored it
(noqa: F841), then duplicated the user-side fanout SQL inline. Adds
fanout_system_for_plugin() symmetric to the existing
fanout_system_for_user() and routes mark_plugin_system through it —
removes the dead import + 14 lines of inline SQL, returns the same
`affected_users` delta count, no behavior change.
* drop customer-specific path from .ps1 example
Per CLAUDE.md vendor-agnostic OSS rule: replaced
C:\\Business\\Groupon\\Agnes\\agnes-data with the generic
C:\\Users\\<you>\\agnes-data placeholder so the docstring
example reads cleanly on any reviewer's box.
* release: 0.48.0 + parallelize Release-workflow pytest
Cuts the release shipped via #228 #230 #231 #232 #233 #234 #236 #237 #238
#239 #240 plus this PR (#241). Major changes:
- System plugin tier (schema v39) — admins mark a plugin mandatory; fans
out RBAC grants + subscriptions to every existing user/group plus
hooks for new principals
- BREAKING: removed standalone /store + /my-ai-stack page routes
(replaced by /marketplace?tab=flea + /marketplace?tab=my)
- Setup-prompt + bootstrap recovery fixes (#240)
- DuckDB CHECKPOINT-on-shutdown + 60s compose grace (#235)
- Marketplace + flea-market UX polish, agnes-metadata.json enrichment
Bonus: switch release.yml test step to `-n auto` (matches ci.yml).
Single-threaded was 15-20 min and frequently the bottleneck on PR
mergeability — now ~6 min.
---------
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 opt-out
|
|
|
|
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 admin-curated plugins (with current opt-out state)
|
|
and Store entities the caller has 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 admin-granted 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()
|