agnes-the-ai-analyst/app/web/templates/marketplace.html
Vojtech d6ad08f107
Flea-market upload guardrails + soft delete + JOIN-based admin queue (#233)
* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue

Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.

Schema v29 → v35:
  * v29 store_submissions table + store_entities.visibility_status
  * v30 file_size, bundle_sha256, bundle_purged_at on submissions
  * v31 reshape store_submissions (drop legacy unique on entity_id)
  * v32 store_entities.archived_at/by + 'archived' visibility value
  * v33 drop store_submissions.retry_count (unused)
  * v34 ensure idx_store_submissions_entity exists post column-drop
  * v35 broaden visibility_status enum + JOIN architecture cutover

Pipeline (src/store_guardrails/):
  * Inline checks: manifest_check, static_scan, quality_check
  * LLM review configurable haiku|sonnet|opus (default haiku)
  * BackgroundTasks-driven async path with structured-output JSON
  * Per-submitter daily quota (default 50)
  * 30-day TTL purge job (POST /api/admin/run-blocked-purge)
  * Bundle SHA256 + size persisted; sha256 survives purge for forensics

Visibility model:
  * pending | approved | hidden | archived
  * _enforce_visibility returns 404 (no leak) for non-owner non-admin
  * Owner sees own non-approved entries via include_owner_id widening
  * Install refused with 409 entity_not_approved when not approved

Soft-delete (DELETE /api/store/entities/{id}):
  * Default = soft (visibility_status='archived'); existing installs
    keep getting served the bundle so users don't lose the plugin
  * ?hard=true admin-only: drops bundle + cascades user_store_installs
  * Hard-delete preserves entity_id on submission as tombstone so
    audit_log linkage survives for the activity timeline

Admin queue lifecycle (the JOIN refactor):
  * Verdict (store_submissions.status) is immutable forensic record
  * Lifecycle (store_entities.visibility_status) is live state
  * /admin/store/submissions Archived chip translates to
    `e.visibility_status='archived'` via LEFT JOIN — any path that
    flips visibility surfaces in the queue immediately
  * Detail page renders Status (verdict) and Entity lifecycle side by
    side so admins see "approved at review, now archived" at a glance

URL consolidation:
  * /store/{id} deleted (no redirect, stale bookmarks 404)
  * /marketplace/flea/{id} is the canonical detail surface
  * Three in-tree callers (upload-success, my-stack card, store
    listing card) updated to point at the new URL
  * Quarantine banner extracted to _quarantine_banner.html partial,
    self-guarded, included from both flea detail templates
  * Banner JS auto-refreshes when the verdict lands by polling
    /api/marketplace/flea/{id}/detail (visibility_status +
    submission_status — the latter is needed because blocked_llm
    keeps the entity at visibility_status='pending')

Audit log resource format:
  * runner.py emits prefixed `store_submission:{id}` (post-fix)
  * Detail-page timeline query handles three patterns: prefixed
    submission, helper-emitted `store_entity:{sub_id}`, and bare-id
    legacy rows — all surface in the activity timeline

UX fixes:
  * Owner sees Under review / Quarantined / Hidden banner with status
  * Install button gray-disabled (not blue) when non-approved
  * Owner cannot delete quarantined entries (403); admin can
  * Admin queue: filter chips, sortable columns, paging, page-size
  * Auto-refresh queue every 5s while pending rows are visible
  * Store upload page file picker no longer opens twice (label →
    input default action collided with explicit JS handler)

Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.

* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist

Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.

Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
  dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
  sentinels declared data-only by the system prompt; literal
  sentinel strings in user content are escaped so an adversarial
  README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
  declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
  .yaml/.yml/.toml skipped to avoid false positives on prose.
  AST mode for Python deferred (separate flag, FP comparison work).

Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
  alongside live, atomic-rename on success; failed checks leave
  live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
  to the (pending, hidden) review window; admin archives during
  review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
  store_entities.visibility_status. CHECK constraint enforced
  application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
  rows older than guardrails.stuck_review_grace_seconds (default
  1800) to review_error. Scheduler runs every 15 min via new
  /api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
  blocked_inline + blocked_llm + review_error so a submitter
  triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
  error='missing_risk_level' instead of silently defaulting to
  'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
  archived_by when transitioning out of 'archived' so a future
  read doesn't show stale archive forensics on an approved row.

Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
  description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
  with 400 invalid_sort_key; substring-replace footgun removed.

Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
  full pipeline; threading.Lock blocks event loop, DuckDB MVCC
  doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
  bundle docs), #14 (factory consolidation), #15-#22 (maint).

Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
  trust-boundary invariants), tests/test_store_put_atomic.py,
  tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
  risk_level, BG race), test_admin_store_submissions.py (quota
  counter widening, sort whitelist 400), test_store_repositories.py
  (un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
  unchanged (db migration tests, cli binary rename, catalog export,
  user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
2026-05-09 17:32:53 +04:00

898 lines
38 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>` : '';
// v32+ quarantine: subtle corner badge on the submitter's own
// non-approved cards. Approved cards + non-owner views omit it.
let quarantineBadge = '';
if (it.is_viewer_owner && it.visibility_status && it.visibility_status !== 'approved') {
const isPending = it.visibility_status === 'pending'
|| it.visibility_status === 'pending_inline'
|| it.visibility_status === 'pending_llm';
const label = isPending ? '⟳ Under review' : '⚠ Quarantined';
const palette = isPending
? 'background:#fef3c7;color:#92400e;border:1px solid #fde68a;'
: 'background:#fee2e2;color:#991b1b;border:1px solid #fecaca;';
quarantineBadge = `<div class="quarantine-badge"
title="Visible only to you. ${isPending ? 'Checks in progress.' : 'Failed automated/security checks — open the detail page to see why.'}"
style="position:absolute;top:8px;left:8px;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;letter-spacing:0.2px;${palette}z-index:2;">${label}</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 = `
${quarantineBadge}
${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 %}