agnes-the-ai-analyst/app/web/templates/marketplace.html
minasarustamyan 4fb2818a19
Add /marketplace browse page + Model B opt-in stack composition (#230)
* Add /marketplace browse page + Model B opt-in stack composition

New /marketplace browse surface unifies the curated marketplaces
(admin-managed git mirrors) and the community Flea Market behind
three tabs — Curated / Flea / My Stack — with per-tab category
filter, search across both sources with scope checkboxes, and
numeric pagination, all driven by URL query state. Plugin detail
at /marketplace/curated/<slug>/<plugin> and /marketplace/flea/<id>;
nested skill / agent detail at /marketplace/curated/<slug>/<plugin>/
{skill,agent}/<name> and the flea-side single-page detail.

Model B opt-in: an RBAC grant on a curated plugin is now only
*eligibility*. The user must click "Add to my stack" for it to
enter their served Claude Code marketplace. Composition flips
from (rbac ∖ opt_outs) ∪ store_installs to
(rbac ∩ subscriptions) ∪ store_installs. The legacy
user_plugin_optouts table is renamed user_curated_subscriptions
(schema v27) — same table shape, inverted semantic, repository
methods become subscribe / unsubscribe / is_subscribed.

UX vocabulary: Install → Add to my stack, Installed → In your
stack, card "Installed" badge → "In stack" (amber pill), tab
"My Subscriptions" → "My Stack". Bridges the two-step model
(server-side bookmark vs. on-laptop install) the previous label
hid. Click triggers an inline post-add hint panel under the
description with the agnes refresh-marketplace recipe + Copy
chip, dismissible per-browser via localStorage.

Per-tab info blocks above the filter row:
- Curated: trust signal — "Each plugin here has a named curator
  accountable for it." (blue accent + See-all-curators link)
- Flea: open-shelf signal — "Anyone in the company can upload
  here." (purple accent + Tips-for-sharing link)
- My Stack: personal-shelf orientation — "Your AI stack —
  everything you've added." (slate accent, no link)

Tabs carry per-tab Heroicons (shield-check / building-storefront
/ rectangle-stack) tinted to match each tab's accent; flips white
when the tab is active for contrast.

Hero illustration anchored to the right of the blue hero panel
(absolute, 47% wide, behind the search row content). Hidden
under 900px viewport.

Action-row CTAs realigned to publication intent: curated
"How to add new content" → "Submit a plugin" (links to the
guide page); flea button removed since +Upload sits next to it.
Empty-state CTAs match. /marketplace/guide/{curated,flea}
routes now host publication-flow guide pages with placeholder
ledes — full copy to be authored separately.

Categories: Heroicons-based icons mapped per category in
src/category_icons.py (zero new dependencies; SVG path strings
inlined). Marketplace cards, filter pills, and detail pages
read from the same source.

API endpoints under /api/marketplace:
- GET /items per-tab listing (curated / flea / my)
- GET /categories per-tab non-zero counts
- GET /curated/{slug}/{plugin} plugin detail
- POST/DELETE /curated/{slug}/{plugin}/install subscribe toggle
- GET /curated/{slug}/{plugin}/{skill,agent}/{name} inner item
The tab=my branch reads directly from
user_curated_subscriptions ∪ user_store_installs (not
resolve_user_marketplace, which bundles flea skills/agents into
a single store-bundle synthetic entry useful for serving the
Claude Code marketplace ZIP/git but wrong for browsing where
each item should appear as its own card).

Detail pages: plugin detail surfaces inner skills/agents as
clickable nested cards; commands/hooks/MCPs render as plain
name lists. Skill/agent detail mirrors the plugin layout with
kind-tinted accents (skill = green, agent = purple), Description
+ Details sidebar, Files + Docs sections, and the "How to call
it" copy-able invocation chip showing /<plugin>:<inner-name>
exactly as Claude Code namespaces it post-install. Curated
nested has no install button — links back to the parent plugin.

Navbar: standalone "My AI Stack" relabelled "My Stack" and
points at /marketplace?tab=my; "Store" link removed (Store
flow is reachable via the Flea Market tab's +Upload button).
The standalone /my-ai-stack and /store routes still work for
old bookmarks.

Tests cover the new browse / categories / install / RBAC paths
under tests/test_marketplace_api.py; existing marketplace and
store tests updated for Model B (explicit subscribe in fixtures).
Schema bumped v26 → v27 with idempotent migration that wipes
existing user_plugin_optouts rows on flip and adds
marketplace_plugins.created_at with registered_at backfill.

* Fix v28 migration + post-rebase test fallout

v28 ALTER TABLE marketplace_plugins ADD COLUMN created_at conflicted with
_SYSTEM_SCHEMA's earlier CREATE that already includes the column on fresh
installs (test fixtures starting at any pre-v28 version trip on it).
Switch to ADD COLUMN IF NOT EXISTS — same idiom as the upstream v27
Keboola sync-strategy migration on the same ladder.

Two test patches needed after the rebase bumped SCHEMA_VERSION 27 → 28:
- test_keboola_v27_migration.py: test_schema_version_constant_is_27 was
  pinning ==27. Loosened to >=27 (the test's purpose is to verify the
  v27 Keboola migration, not to pin the current SCHEMA_VERSION).
- test_setup_page_unified.py: was monkeypatching resolve_allowed_plugins
  but compute_default_agent_prompt now reads from resolve_user_marketplace
  (Model B-aware). Stub the right function so the test exercises the
  v28 served-set path.

* Harden curated skill/agent inner endpoints against path traversal

`_read_inner`, the `skill_dir` walk in `curated_skill_detail`, and the
`agent_path.stat` in `curated_agent_detail` joined URL path-params onto
`plugin_root` without verifying the resolved candidate stayed inside it.
Starlette's `[^/]+` on `{skill_name}` / `{agent_name}` blocks the direct
URL exploit (encoded `/` 404s before the handler), but a curator-planted
symlink inside a curated marketplace's git mirror could still dereference
outside the plugin tree on read.

Adds `_safe_join(plugin_root, *parts)` doing
`Path.resolve(strict=True)` + `relative_to(plugin_root.resolve())`, used
by all three call sites so the boundary is enforced once and consistently.
Tests cover the helper directly (normal path resolves, escaping `..`
returns None, escaping symlink returns None, missing file returns None)
plus an end-to-end check that the symlink case actually 404s on the
HTTP endpoint. Symlink tests skip on Windows where symlink creation
needs elevated permissions; they run on Linux CI.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
2026-05-08 14:22:19 +02:00

881 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Marketplace — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.mp-page {
--surface: #ffffff;
--primary-light: rgba(0, 115, 209, 0.12);
--border-light: #eceff1;
--text-primary: #202124;
--text-secondary: #5f6368;
--success-color: #10b77f;
--warn-color: #b45309;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* ── Hero with integrated search ─────────────────────────────────── */
/* Layout split: cover image is anchored absolutely to the right edge
of the hero (z-index 1) so it doesn't claim layout space; the text
content sits on top (z-index 2) and uses the hero's full width.
`overflow: hidden` clips the cover to the hero's rounded corners. */
.mp-hero {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
color: #fff;
}
.mp-hero-content {
position: relative;
z-index: 2;
}
.mp-hero-cover {
position: absolute;
right: 0;
top: 18px;
bottom: 0;
width: 46%;
object-fit: cover;
z-index: 1;
}
@media (max-width: 900px) {
.mp-hero-cover { display: none; }
}
.mp-hero .eyebrow {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.8px; color: rgba(255,255,255,0.75); margin-bottom: 8px;
}
.mp-hero h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.4px; }
.mp-hero .sub { margin: 6px 0 0; font-size: 14px; color: rgba(255,255,255,0.85); }
.mp-hero .search-row {
display: flex; align-items: stretch;
margin-top: 18px;
background: #fff;
border-radius: 10px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
max-width: 760px;
}
.mp-hero .search-wrap { flex: 1; position: relative; min-width: 0; }
.mp-hero input[type="search"] {
width: 100%;
padding: 11px 14px 11px 40px;
border: none; border-radius: 8px;
font-size: 14px; font-family: var(--font-primary);
background: transparent; color: var(--text-primary); outline: none;
}
.mp-hero input[type="search"]::placeholder { color: var(--text-secondary); opacity: 0.75; }
.mp-hero .search-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
width: 16px; height: 16px; color: var(--text-secondary); pointer-events: none;
}
.mp-hero .search-btn {
appearance: none; border: none; background: var(--primary); color: #fff;
padding: 0 24px; border-radius: 8px;
font-size: 13px; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; flex-shrink: 0;
transition: background 0.15s ease;
}
.mp-hero .search-btn:hover { background: var(--primary-dark); }
.mp-hero .search-btn:active { transform: scale(0.98); }
.mp-hero .scope {
display: flex; gap: 6px; align-items: center;
margin-top: 10px; font-size: 12px;
}
.mp-hero .scope-label {
font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px;
font-size: 11px; color: rgba(255,255,255,0.7); margin-right: 4px;
}
.mp-hero .scope label {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
user-select: none; padding: 4px 10px; border-radius: 6px; color: #fff;
}
.mp-hero .scope label:hover { background: rgba(255,255,255,0.12); }
.mp-hero .scope input[type="checkbox"] { accent-color: #fff; }
/* ── Tabs row + actions ──────────────────────────────────────────── */
.mp-tabs-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.mp-tabs {
display: flex; gap: 4px; align-items: center;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.mp-tabs button {
appearance: none; border: none; background: transparent;
color: var(--text-secondary);
padding: 8px 16px; border-radius: 7px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
display: inline-flex; align-items: center; gap: 8px;
transition: all 0.15s ease;
}
.mp-tabs button .tab-icon {
width: 16px; height: 16px; flex-shrink: 0;
}
/* Per-tab icon tint reuses the established palette: blue = curated/
vetted, purple = flea/community, amber = "in your stack" (mirrors
the In Stack badge on installed cards). The active-tab override
flips icons to white so they read against the blue active fill. */
.mp-tabs button[data-tab="curated"] .tab-icon { color: #0073D1; }
.mp-tabs button[data-tab="flea"] .tab-icon { color: #6D28D9; }
.mp-tabs button[data-tab="my"] .tab-icon { color: #F59F0A; }
.mp-tabs button.is-active .tab-icon { color: #fff; }
.mp-tabs button:hover { color: var(--text-primary); background: var(--bg); }
.mp-tabs button.is-active { background: var(--primary); color: #fff; }
.mp-tabs button .count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 22px; height: 18px; padding: 0 6px; border-radius: 9px;
background: rgba(0,0,0,0.08); color: inherit;
font-size: 11px; font-weight: 600;
}
.mp-tabs button.is-active .count { background: rgba(255,255,255,0.2); }
.mp-actions { display: flex; gap: 8px; align-items: center; }
.mp-actions .btn {
appearance: none;
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px;
border: 1px solid var(--border); background: var(--surface);
color: var(--text-primary); border-radius: 8px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary); text-decoration: none;
transition: all 0.15s ease;
}
.mp-actions .btn:hover { border-color: var(--primary); color: var(--primary); }
.mp-actions .btn.primary {
background: var(--primary); color: #fff; border-color: var(--primary);
}
.mp-actions .btn.primary:hover {
background: var(--primary-dark); border-color: var(--primary-dark); color: #fff;
}
/* ── Info block (curated trust badge + flea open-shelf signal) ───── */
.mp-curator-block {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
}
/* Flea variant — purple accent matches the flea badge color used on
/marketplace cards and detail pages so the two info blocks read as
visually distinct (blue = curated/vetted, purple = flea/community).
The same purple is applied to the trailing link so it reads as part
of the flea block, not a stray curated-blue accent. */
.mp-curator-block.is-flea { border-left-color: #6D28D9; }
.mp-curator-block.is-flea .link { color: #6D28D9; }
/* My Stack variant — neutral slate. Not a trust/community signal like
the other two; it's the user's personal shelf, so the accent stays
intentionally non-branded so the user's own content is the focus. */
.mp-curator-block.is-mystack { border-left-color: #64748B; }
.mp-curator-block .text { flex: 1; min-width: 240px; }
.mp-curator-block .title {
font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px;
}
.mp-curator-block .body { font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
.mp-curator-block .link {
font-size: 12px; color: var(--primary); font-weight: 500;
white-space: nowrap; align-self: center;
}
/* ── Filter row ──────────────────────────────────────────────────── */
.mp-filter-row {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
margin-bottom: 12px;
}
.mp-filter-row .pill {
appearance: none; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
padding: 7px 12px; border-radius: 999px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
display: inline-flex; align-items: center; gap: 6px;
transition: all 0.15s ease;
}
.mp-filter-row .pill svg { width: 14px; height: 14px; flex-shrink: 0; }
.mp-filter-row .pill:hover { border-color: var(--primary); color: var(--primary); }
.mp-filter-row .pill.is-active {
background: var(--primary-light); color: var(--primary); border-color: var(--primary);
}
.mp-filter-row .pill .count { color: var(--text-secondary); font-weight: 500; margin-left: 2px; }
.mp-filter-row .pill.is-active .count { color: var(--primary); }
.mp-type-row {
display: flex; gap: 6px; align-items: center; margin-bottom: 24px;
}
.mp-type-row .pill {
appearance: none; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
padding: 6px 12px; border-radius: 8px;
font-size: 12px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
transition: all 0.15s ease;
}
.mp-type-row .pill:hover { border-color: var(--primary); color: var(--primary); }
.mp-type-row .pill.is-active {
background: var(--primary); color: #fff; border-color: var(--primary);
}
/* ── Card grid ───────────────────────────────────────────────────── */
.mp-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) { .mp-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } }
@media (max-width: 820px) { .mp-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 540px) { .mp-grid { grid-template-columns: 1fr; } }
.mp-card {
position: relative;
display: flex; flex-direction: column;
background: var(--surface);
border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
overflow: hidden; cursor: pointer;
transition: all 0.15s ease;
/* Card is an <a> so middle-click / ctrl-click open in a new tab; reset
the link defaults so the styling matches the surrounding panel. */
text-decoration: none;
color: inherit;
}
.mp-card:hover {
border-color: var(--primary);
box-shadow: 0 6px 20px rgba(0, 115, 209, 0.12);
transform: translateY(-2px);
}
.mp-card .photo {
width: 100%; height: 120px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
color: var(--primary);
font-size: 26px; font-weight: var(--font-bold); letter-spacing: 0.5px;
}
.mp-card .photo img {
width: 100%; height: 100%; object-fit: cover;
}
.mp-card.is-installed { border-color: rgba(245, 159, 10, 0.55); }
/* Solid filled pill in amber — signals "selected / in your stack"
while staying readable on both the white card photo placeholder
and any uploaded cover image. Card border matches at low opacity
so the two installed-state cues read as a pair. */
.mp-card .installed-badge {
position: absolute; top: 10px; right: 10px;
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 999px;
background: #f59f0a; color: #fff;
font-size: 11px; font-weight: var(--font-semibold);
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.55);
}
.mp-card .body {
padding: 14px 16px 12px; flex: 1;
display: flex; flex-direction: column; gap: 6px;
}
.mp-card .badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.mp-card .type-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
background: var(--primary-light); color: var(--primary);
font-size: 10px; font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
}
.mp-card .type-badge[data-type="plugin"] { background: rgba(0, 115, 209, 0.12); color: #0056A3; }
.mp-card .type-badge[data-type="skill"] { background: rgba(16, 183, 127, 0.14); color: #0e9b6a; }
.mp-card .type-badge[data-type="agent"] { background: rgba(124, 58, 237, 0.14); color: #6d28d9; }
.mp-card .cat-badge {
font-size: 10px; color: var(--text-secondary);
border: 1px solid var(--border); border-radius: 4px;
padding: 2px 7px;
text-transform: uppercase; letter-spacing: 0.4px; font-weight: 500;
}
.mp-card .name {
font-weight: var(--font-semibold); color: var(--text-primary);
font-size: 15px; line-height: 1.3;
}
.mp-card .by {
font-size: 11px; color: var(--text-secondary); margin-top: -2px;
}
.mp-card .by .todo { color: var(--warn-color); font-style: italic; }
.mp-card .desc {
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.mp-card .footer {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; border-top: 1px solid var(--border-light);
font-size: 11px; color: var(--text-secondary);
}
.mp-card .ver { font-variant-numeric: tabular-nums; }
/* ── Pager + empty state ─────────────────────────────────────────── */
.mp-pager { display: flex; gap: 6px; justify-content: center; margin: 32px 0 0; }
.mp-pager button {
padding: 7px 14px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
border-radius: 8px; cursor: pointer; font-size: 13px;
font-family: var(--font-primary); transition: all 0.15s ease;
}
.mp-pager button:hover { border-color: var(--primary); color: var(--primary); }
.mp-pager button.is-active {
background: var(--primary); color: #fff; border-color: var(--primary);
}
.mp-pager .ellipsis {
display: inline-flex; align-items: center; padding: 0 4px;
color: var(--text-secondary); font-size: 13px;
}
.mp-empty {
text-align: center; padding: 56px 24px;
color: var(--text-secondary); font-size: 14px;
background: var(--surface);
border: 1px dashed var(--border); border-radius: 12px;
}
.mp-empty h3 {
margin: 0 0 8px; color: var(--text-primary); font-size: 16px; font-weight: 600;
}
.mp-empty .cta { margin-top: 16px; }
.mp-empty .cta a {
display: inline-block; margin: 0 6px;
color: var(--primary); font-weight: 500; text-decoration: none;
}
.mp-empty .cta a:hover { text-decoration: underline; }
[hidden] { display: none !important; }
</style>
<script id="category-icons-data" type="application/json">{{ category_icons_json | safe }}</script>
<div class="mp-page page-shell">
<!-- Hero with integrated search -->
<div class="mp-hero">
<div class="mp-hero-content">
<div class="eyebrow">Marketplace</div>
<h1>Plugin Marketplace</h1>
<p class="sub">Discover AI tools — from curated catalogs and the community.</p>
<div class="search-row">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>
</svg>
<input id="mp-search" type="search"
placeholder="Search plugins, skills, agents — by name, description, author…">
</div>
<button class="search-btn" id="mp-search-btn" type="button">Search</button>
</div>
<div class="scope">
<span class="scope-label">Search in:</span>
<label><input type="checkbox" id="mp-scope-curated"> Curated</label>
<label><input type="checkbox" id="mp-scope-flea"> Flea Market</label>
</div>
</div>
<img class="mp-hero-cover" src="/static/marketplace-cover.png"
alt="" aria-hidden="true">
</div>
<!-- Tabs + actions -->
<div class="mp-tabs-row">
<div class="mp-tabs" role="tablist">
{# Tab icons (Heroicons outline 24×24): shield-check signals trust/vetting
for curated, building-storefront signals an open shelf for flea, and
rectangle-stack signals the user's personal collection for My Stack. #}
<button class="is-active" data-tab="curated" role="tab" aria-selected="true">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
Curated Marketplace <span class="count" data-count-curated>0</span>
</button>
<button data-tab="flea" role="tab" aria-selected="false">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72L4.318 3.44A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/>
</svg>
Flea Market <span class="count" data-count-flea>0</span>
</button>
<button data-tab="my" role="tab" aria-selected="false">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3"/>
</svg>
My Stack <span class="count" data-count-my>0</span>
</button>
</div>
<div class="mp-actions">
<a class="btn" data-actions-for="curated" href="/marketplace/guide/curated">Submit a plugin</a>
<!-- Flea has a self-service +Upload button below — no second
"how to" CTA needed. Anyone uploads via the form directly. -->
<a class="btn primary" data-actions-for="flea" href="/store/new" hidden>+ Upload</a>
</div>
</div>
<!-- Curator info block (Curated tab only) — trust badge, not onboarding.
Goal: signal "someone is accountable for what's in here", without
prescribing the process (teams may or may not have a curator; we
don't want the copy to imply a structure the org doesn't follow). -->
<div class="mp-curator-block" data-show-on="curated">
<div class="text">
<div class="title">Each plugin here has a named curator accountable for it.</div>
<div class="body">Each plugin in this marketplace has a named curator and meets a baseline review bar (security, telemetry hygiene, documentation).</div>
</div>
<a class="link" href="#">See all curators →</a>
</div>
<!-- Flea Market info block — open-shelf signal, mirror structure of
the curated block. The trailing "Tips for sharing" link points at
the publication guide; the +Upload button in the actions row above
handles the direct CTA, so this link is informational, not a
second action. Purple left-border + link colour distinguishes the
block from curated. -->
<div class="mp-curator-block is-flea" data-show-on="flea">
<div class="text">
<div class="title">Anyone in the company can upload here.</div>
<div class="body">The Flea Market is the community shelf — anyone can share a skill, agent, or plugin. Browse what colleagues have built, or upload something new.</div>
</div>
<a class="link" href="/marketplace/guide/flea">Tips for sharing →</a>
</div>
<!-- My Stack info block — personal-shelf orientation. Answers two
questions a non-technical user lands here with: "what is this?"
(their own collected items from Curated + Flea) and "how does
this connect to Claude Code?" (auto-sync at next session). No
trailing link — the user is on their own tab, navigating away
would be a distraction; the cards themselves are the action. -->
<div class="mp-curator-block is-mystack" data-show-on="my">
<div class="text">
<div class="title">Your AI stack — everything youve added.</div>
<div class="body">Plugins, skills, and agents youve picked from Curated or the Flea Market all live here. They sync to Claude Code on your laptop automatically the next time you start a session.</div>
</div>
</div>
<!-- Filter row: categories -->
<div class="mp-filter-row" id="mp-cat-row"></div>
<!-- Type filter (Flea-only) -->
<div class="mp-type-row" id="mp-type-row" hidden>
<button class="pill is-active" data-type="">All</button>
<button class="pill" data-type="skill">Skills</button>
<button class="pill" data-type="agent">Agents</button>
<button class="pill" data-type="plugin">Plugins</button>
</div>
<!-- Most Popular section: hidden until telemetry exists.
<div class="mp-section-header"><h2>Most Popular</h2><div class="meta">Last 30 days</div></div>
<div class="mp-grid">…8 cards…</div>
-->
<!-- Main grid -->
<div class="mp-grid" id="mp-grid"></div>
<div class="mp-empty" id="mp-empty" hidden>
<h3 id="mp-empty-title">Nothing here yet</h3>
<p id="mp-empty-body"></p>
<div class="cta" id="mp-empty-cta"></div>
</div>
<div class="mp-pager" id="mp-pager"></div>
</div>
<script>
'use strict';
const ICON_PATHS = JSON.parse(document.getElementById('category-icons-data').textContent);
const PAGE_SIZE = 24;
const state = {
tab: 'curated',
q: '',
category: null,
type: null,
page: 1,
scope: { curated: true, flea: true },
};
// ── URL state ────────────────────────────────────────────────────────────
function loadFromURL() {
const p = new URLSearchParams(window.location.search);
const tab = p.get('tab');
state.tab = (tab === 'flea' || tab === 'my') ? tab : 'curated';
state.q = p.get('q') || '';
state.category = p.get('category') || null;
state.type = p.get('type') || null;
state.page = Math.max(1, parseInt(p.get('page') || '1', 10));
}
function syncURL() {
const u = new URL(window.location.href);
u.searchParams.set('tab', state.tab);
['q','category','type'].forEach(k => {
if (state[k]) u.searchParams.set(k, state[k]);
else u.searchParams.delete(k);
});
if (state.page > 1) u.searchParams.set('page', String(state.page));
else u.searchParams.delete('page');
window.history.replaceState({}, '', u);
}
// ── DOM helpers ──────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]
));
}
function typeIcon(t) {
return t === 'skill' ? 'SK' : t === 'agent' ? 'AG' : t === 'plugin' ? 'PL' : '?';
}
function categorySVG(category) {
const path = ICON_PATHS[category || 'Other'] || ICON_PATHS['Other'] || '';
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">${path}</svg>`;
}
// ── Category filter ──────────────────────────────────────────────────────
function renderCategories(items) {
const row = document.getElementById('mp-cat-row');
row.innerHTML = '';
const allBtn = document.createElement('button');
allBtn.className = 'pill' + (!state.category ? ' is-active' : '');
allBtn.dataset.cat = '';
allBtn.textContent = 'All';
allBtn.addEventListener('click', () => {
state.category = null; state.page = 1; syncURL();
renderCategories(items); loadItems();
});
row.appendChild(allBtn);
for (const c of items) {
const btn = document.createElement('button');
btn.className = 'pill' + (state.category === c.name ? ' is-active' : '');
btn.dataset.cat = c.name;
btn.innerHTML = `${categorySVG(c.name)} ${esc(c.name)} <span class="count">${c.count}</span>`;
btn.addEventListener('click', () => {
state.category = c.name; state.page = 1; syncURL();
renderCategories(items); loadItems();
});
row.appendChild(btn);
}
}
async function loadCategories() {
const params = new URLSearchParams({ tab: state.tab });
if (state.tab === 'flea' && state.type) params.set('type', state.type);
try {
const res = await fetch('/api/marketplace/categories?' + params);
if (!res.ok) { renderCategories([]); return; }
const data = await res.json();
renderCategories(data.items || []);
} catch (e) {
renderCategories([]);
}
}
// ── Items grid ───────────────────────────────────────────────────────────
function renderGrid(items) {
const grid = document.getElementById('mp-grid');
const empty = document.getElementById('mp-empty');
grid.innerHTML = '';
if (!items || !items.length) {
grid.hidden = true;
empty.hidden = false;
fillEmptyState();
return;
}
grid.hidden = false; empty.hidden = true;
for (const it of items) {
const card = document.createElement('a');
card.className = 'mp-card' + (it.installed ? ' is-installed' : '');
card.href = it.detail_url;
const photoMarkup = it.photo_url
? `<div class="photo"><img src="${esc(it.photo_url)}" alt=""></div>`
: `<div class="photo">${esc(typeIcon(it.type))}</div>`;
const installedBadge = it.installed
? `<div class="installed-badge" title="In your stack">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
In stack
</div>` : '';
let ownerLine;
if (it.source === 'curated') {
const ownerVal = it.owner === 'owner_todo'
? `<span class="todo">owner_todo</span>`
: `<span>${esc(it.owner || '')}</span>`;
const mp = it.marketplace_name ? `via <strong>${esc(it.marketplace_name)}</strong> · ` : '';
ownerLine = `${mp}${ownerVal}`;
} else {
ownerLine = `by <strong>${esc(it.owner || '')}</strong>`;
}
const addedFmt = it.added ? new Date(it.added).toISOString().slice(0,10) : '';
const verFmt = it.version ? `v${esc(it.version)}` : '';
const catBadge = it.category ? `<span class="cat-badge">${esc(it.category)}</span>` : '';
card.innerHTML = `
${photoMarkup}
${installedBadge}
<div class="body">
<div class="badges">
<span class="type-badge" data-type="${esc(it.type)}">${esc(it.type)}</span>
${catBadge}
</div>
<div class="name">${esc(it.name)}</div>
<div class="by">${ownerLine}</div>
<div class="desc">${esc(it.description || '')}</div>
</div>
<div class="footer">
<span class="added">${addedFmt ? 'Added ' + addedFmt : ''}</span>
<span class="ver">${verFmt}</span>
</div>`;
grid.appendChild(card);
}
}
function fillEmptyState() {
const t = document.getElementById('mp-empty-title');
const b = document.getElementById('mp-empty-body');
const c = document.getElementById('mp-empty-cta');
if (state.q) {
t.textContent = `Nothing matches "${state.q}"`;
b.textContent = '';
c.innerHTML = '';
return;
}
if (state.tab === 'curated') {
t.textContent = 'No curated plugins available.';
b.textContent = 'Ask your admin to grant your group access.';
c.innerHTML = `<a href="/marketplace/guide/curated">Submit a plugin →</a>`;
} else if (state.tab === 'flea') {
t.textContent = 'No community entities yet.';
b.textContent = 'Be the first to share something.';
c.innerHTML = `<a href="/store/new">+ Upload</a>`;
} else {
t.textContent = 'My Stack';
b.textContent = "Plugins you've added to your stack will show up here. Browse the Curated Marketplace or Flea Market and click “Add to my stack”.";
c.innerHTML = `<a href="/marketplace?tab=curated">Browse Curated →</a><a href="/marketplace?tab=flea">Browse Flea Market →</a>`;
}
}
// ── Pager ────────────────────────────────────────────────────────────────
function renderPager(total) {
const pager = document.getElementById('mp-pager');
pager.innerHTML = '';
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (totalPages <= 1) return;
const mk = (label, page, active, isEll) => {
if (isEll) {
const s = document.createElement('span');
s.className = 'ellipsis'; s.textContent = '…'; return s;
}
const b = document.createElement('button');
b.textContent = label;
if (active) b.classList.add('is-active');
b.addEventListener('click', () => {
state.page = page; syncURL(); loadItems();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
return b;
};
if (state.page > 1) pager.appendChild(mk('', state.page - 1, false));
// Compact pagination: 1 … (cur-1) cur (cur+1) … last
const pages = new Set([1, totalPages, state.page, state.page-1, state.page+1, 2, totalPages-1]);
const sortedPages = [...pages].filter(p => p >= 1 && p <= totalPages).sort((a,b) => a-b);
let prev = 0;
for (const p of sortedPages) {
if (p - prev > 1) pager.appendChild(mk('', 0, false, true));
pager.appendChild(mk(String(p), p, p === state.page));
prev = p;
}
if (state.page < totalPages) pager.appendChild(mk('', state.page + 1, false));
}
// ── Tab counts (separate cheap fetches) ──────────────────────────────────
async function loadTabCounts() {
const fetchCount = async (tab) => {
try {
const res = await fetch('/api/marketplace/items?tab=' + tab + '&page_size=1');
if (!res.ok) return 0;
const data = await res.json();
return data.total || 0;
} catch { return 0; }
};
const [c, f, m] = await Promise.all([
fetchCount('curated'), fetchCount('flea'), fetchCount('my'),
]);
document.querySelector('[data-count-curated]').textContent = c;
document.querySelector('[data-count-flea]').textContent = f;
document.querySelector('[data-count-my]').textContent = m;
}
// ── Items loader ─────────────────────────────────────────────────────────
async function loadItems() {
// Search-results mode: when user is searching with both scopes checked,
// we fan out two requests (one per scope) and merge client-side. With
// exactly one scope checked we just hit the matching tab.
if (state.q) {
const wantC = document.getElementById('mp-scope-curated').checked;
const wantF = document.getElementById('mp-scope-flea').checked;
if (!wantC && !wantF) {
document.getElementById('mp-scope-curated').checked = true;
state.scope.curated = true;
}
const fetches = [];
if (wantC) fetches.push(fetchOne('curated'));
if (wantF) fetches.push(fetchOne('flea'));
const results = await Promise.all(fetches);
const merged = [];
let total = 0;
for (const r of results) {
merged.push(...(r.items || []));
total += r.total || 0;
}
// Client-side paginate the merged set: backend already paginated each
// half so this is an approximate combined view, sufficient for search.
renderGrid(merged);
renderPager(total);
return;
}
const r = await fetchOne(state.tab);
renderGrid(r.items || []);
renderPager(r.total || 0);
}
async function fetchOne(tab) {
const params = new URLSearchParams({
tab,
page: String(state.page),
page_size: String(PAGE_SIZE),
});
if (state.q) params.set('q', state.q);
if (state.category) params.set('category', state.category);
if (tab === 'flea' && state.type) params.set('type', state.type);
try {
const res = await fetch('/api/marketplace/items?' + params);
if (!res.ok) return { items: [], total: 0 };
return await res.json();
} catch { return { items: [], total: 0 }; }
}
// ── Tab switching + filter pills ─────────────────────────────────────────
function setTab(tab) {
state.tab = tab;
state.category = null;
state.type = null;
state.page = 1;
// Sync scope checkboxes with the active tab (user can manually flip back).
document.getElementById('mp-scope-curated').checked = (tab === 'curated');
document.getElementById('mp-scope-flea').checked = (tab === 'flea');
document.querySelectorAll('.mp-tabs button').forEach(b => {
const active = b.dataset.tab === tab;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('[data-actions-for]').forEach(a => {
a.hidden = a.dataset.actionsFor !== tab;
});
document.querySelectorAll('[data-show-on]').forEach(el => {
el.hidden = el.dataset.showOn !== tab;
});
document.getElementById('mp-type-row').hidden = (tab !== 'flea');
document.getElementById('mp-cat-row').hidden = (tab === 'my');
// Type filter pills always reset to 'All' on tab switch.
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.classList.toggle('is-active', !p.dataset.type);
});
syncURL();
Promise.all([loadCategories(), loadItems()]);
}
// ── Boot ─────────────────────────────────────────────────────────────────
document.querySelectorAll('.mp-tabs button').forEach(b => {
b.addEventListener('click', () => setTab(b.dataset.tab));
});
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.addEventListener('click', () => {
document.querySelectorAll('#mp-type-row .pill').forEach(x => x.classList.remove('is-active'));
p.classList.add('is-active');
state.type = p.dataset.type || null;
state.page = 1; syncURL();
Promise.all([loadCategories(), loadItems()]);
});
});
const searchInput = document.getElementById('mp-search');
const searchBtn = document.getElementById('mp-search-btn');
let searchTimer;
function runSearch() {
state.q = searchInput.value.trim();
state.page = 1;
syncURL();
loadItems();
}
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(searchTimer); runSearch(); }
});
searchBtn.addEventListener('click', () => {
clearTimeout(searchTimer); runSearch();
});
document.getElementById('mp-scope-curated').addEventListener('change', () => {
if (state.q) loadItems();
});
document.getElementById('mp-scope-flea').addEventListener('change', () => {
if (state.q) loadItems();
});
(async function init() {
loadFromURL();
// Restore search input from URL state
if (state.q) document.getElementById('mp-search').value = state.q;
// Sync scope checkboxes with the active tab on first load — only the
// active tab's source is searched by default. User can flip the other
// checkbox manually to widen the scope.
document.getElementById('mp-scope-curated').checked = (state.tab === 'curated');
document.getElementById('mp-scope-flea').checked = (state.tab === 'flea');
// For tab='my' fall back to Curated checked so a search query still has
// a non-empty scope to match against.
if (state.tab === 'my') {
document.getElementById('mp-scope-curated').checked = true;
}
// Apply tab to UI
document.querySelectorAll('.mp-tabs button').forEach(b => {
const active = b.dataset.tab === state.tab;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('[data-actions-for]').forEach(a => {
a.hidden = a.dataset.actionsFor !== state.tab;
});
document.querySelectorAll('[data-show-on]').forEach(el => {
el.hidden = el.dataset.showOn !== state.tab;
});
document.getElementById('mp-type-row').hidden = (state.tab !== 'flea');
document.getElementById('mp-cat-row').hidden = (state.tab === 'my');
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.classList.toggle('is-active', (p.dataset.type || null) === state.type);
});
await Promise.all([
loadTabCounts(),
loadCategories(),
loadItems(),
]);
})();
</script>
{% endblock %}