agnes-the-ai-analyst/app/web/templates/marketplace_plugin_detail.html
minasarustamyan dc5e0e0d11
Marketplace UX overhaul: rich plugin/skill/agent detail + filename rename (#251)
* Rename agnes-metadata.json to marketplace-metadata.json

Curated marketplace enrichment file (.claude-plugin/agnes-metadata.json)
becomes marketplace-metadata.json. Clean cut, no fallback — curators of
upstream marketplace repos must rename the file on their side.

Python API renames mirror the file rename: read_agnes_metadata →
read_marketplace_metadata, AGNES_METADATA_REL → MARKETPLACE_METADATA_REL,
AGNES_METADATA_MAX_BYTES → MARKETPLACE_METADATA_MAX_BYTES. Synth Claude
Code marketplace strip rule (.agnes/** + the metadata file) follows the
new filename.

* Marketplace detail polish: window cover + 715:310 aspect + helper alignment

- Plugin & item (skill/agent) detail hero: 160x160 square cover replaced
  with a macOS-style window frame (3 traffic-light dots + titlebar label
  showing the entity name). Body is constrained to 715:310 so curator-
  uploaded covers no longer crop to a square. Window is 380px wide; meta
  column and absolutely-positioned top-right install/remove actions stay
  put. Fallback when no cover_photo_url (translucent gradient + PL/SK/AG
  initials) is unchanged, just inside the window body.

- Inner skill/agent cards in the plugin detail's Internal structure
  section adopt the same 715:310 aspect (was fixed 78px tall). No window
  chrome on inner cards — just the matching proportions so covers read
  consistently across hero, grid tiles, and listing cards.

- Curated nested item helper text ("This skill is part of ... — add the
  bundle to your stack to use it") now stacks UNDER the "Open parent
  plugin" button instead of being a side-by-side flex sibling in the
  actions-row. Added align-self: flex-end so the 260px helper box
  anchors at the right edge of the 300px actions column, matching the
  button's right edge.

* Marketplace My tab: surface the same category + type filters as Flea

- Frontend: mp-cat-row and mp-type-row now show on tab=my (previously
  hidden — type was flea-only, category was flea/curated-only). Curated
  browse stays plugin-only and continues to hide the type pills.
  fetchOne() sends the `type` param for tab=my too, so the items
  endpoint's existing my-branch filter actually receives it.

- Backend categories endpoint, tab=my branch: when the type filter is
  set to skill/agent, skip counting curated subscriptions. Curated
  plugins are always type='plugin', so they wouldn't survive the items
  endpoint's type filter; including them in the category counts made
  the pill numbers overstate what users could actually see in the
  grid. type=None or type='plugin' keeps the previous behaviour.

- CHANGELOG entry under [Unreleased].

* Marketplace plugin detail: render rich content from marketplace-metadata.json

Adds five optional plugin-level fields to marketplace-metadata.json and
renders them on the curated plugin detail page + listing card:

* display_name — friendly h1 / listing-card name / mac-window titlebar
  label (overrides the technical plugin id)
* tagline — punchy 1-line value prop for the hero subtitle and the
  listing card description (replacing the verbose marketplace.json
  description on cards)
* description — multi-paragraph markdown body, server-side rendered
  through markdown-it-py and sanitized through nh3 with a
  description-scoped allowlist (no iframes / no raw HTML / no
  javascript: links). Powers the "What it does" panel.
* use_cases[] — {title, description, prompt} entries that render as a
  3-column "When to use it" card grid; each card shows the literal
  prompt as a code chip so users can copy-paste into Claude Code.
* sample_interaction — {user, assistant} dialog rendered in a Claude
  Code-style dark Catppuccin Mocha transcript panel: monospace user
  row with a green ">" prompt indicator + sans-serif assistant body
  with markdown formatting (peach bold, yellow italic, pink inline
  code, mantle-dark fenced code blocks).

All five fields are optional; UI sections only render when populated,
so plugins without enrichment look identical to before. Fields are
read on-demand from the working tree (cached by mtime per marketplace
slug) so curator edits land at the next request without waiting for
a sync cycle — same pattern as the existing inner-skill/agent
enrichment path. No DB schema bump.

Skill / agent rich-content rendering is deferred to a later phase
(needs a source-of-truth decision: extend plugin.yml? LLM-generate
from SKILL.md / agent.md?). The schema accepts the same fields at
skill/agent level today for forward compatibility but the UI ignores
them for now.

Also: stripped a stale `background-color: var(--bg)` from the global
`code` rule in style.css (was making inline code visually disappear
on the page background).

* Skill / agent detail: render rich content from marketplace-metadata.json

Brings the skill/agent detail pages to parity with the plugin detail
page. Same rich-content schema (display_name, tagline, description as
markdown, use_cases[], sample_interaction) plus two per-item additions:

* invocation — curator-provided literal command string. When set,
  overrides the computed "<manifest_name>:<inner_name>" chip and
  cleanly supports both "/" skill prefix and "@" agent prefix (the
  hardcoded "/" in the chip markup is hidden when the curator provides
  the invocation, so /grpn-eng:query <q> and @grpn-eng:cto-architect
  both render correctly).
* when_to_use — markdown disambiguation block ("Use this for X. For
  similar Y, see /other-skill") rendered into a new "When to use this"
  panel below the Example section.

Skill / agent category is now per-item overridable in
marketplace-metadata.json. When absent, the API keeps the parent
plugin's category as the badge so existing items don't lose their
category until curators opt in to per-item categorization.

The new "Example" Q&A panel uses the same Claude Code-style dark
Catppuccin Mocha transcript treatment as the plugin detail —
monospace user row with a green ">" prompt indicator + sans-serif
assistant body with markdown formatting.

All new fields are optional and read on-demand from the working tree.
Skills / agents whose marketplace-metadata.json doesn't carry rich
content render exactly the same way they did before (frontmatter
description + computed slash command + cover from existing v32
enrichment). No DB schema bump.

* Fix TypeError in skill / agent detail when curator sets per-item category

`curated_skill_detail` and `curated_agent_detail` were passing both
`**parent` (from `_curated_inner_parent_fields`, which returns the
parent plugin's category as a fallback) and `**enrichment` (from
`_curated_inner_enrichment`, which returns the per-item category
override when the curator set one) into `InnerDetailResponse(...)`.

Python function-call kwargs unpacking with overlapping keys raises
`TypeError: got multiple values for keyword argument 'category'`
— it doesn't merge like a literal dict does. The bug only surfaced
when the marketplace-metadata.json carried a `category` field at
skill / agent level (curator opting into per-item categorization);
items without that override hit the endpoint cleanly because only
parent provided the key.

Fix: build `merged = {**parent, **enrichment}` first (literal-dict
syntax DOES merge, with the right-hand-side winning) and unpack the
merged dict. Curator override still wins via the merge order, and
the same pattern is future-proof for any other field that lands in
both layers later.

Plus a regression test in test_marketplace_metadata.py asserting
that the inner-resolver carries `category` for downstream merging.

* Marketplace detail: tolerate partial curator JSON

Server constructed UseCase / SampleInteraction via raw dict indexing
(uc["title"], sample["assistant"]), so a curator commit missing any
required Pydantic field crashed the whole plugin / skill / agent detail
endpoint with a 500. Route both constructions through _safe_use_case /
_safe_sample_interaction helpers — partial input silently drops the
malformed card / section instead of breaking the page.

Regression test in test_marketplace_api.py covers the three shapes:
use_case missing a key, use_case with an empty string, and
sample_interaction with only user (no assistant). Sibling rich fields
still render.

* Address PR-251 review (must-fixes + S2/S3 polish) + release-cut 0.50.0

Five must-fixes from the review pass (3 from @cvrysanek's two-stage
review, 2 from my independent pass), plus the 0.50.0 release-cut as the
last commit on this PR per CLAUDE.md (CLAUDE.md "Release-cut belongs
to the PR" rule added in v0.49.1).

Must-fixes
----------

1. Cache eviction: bounded LRU instead of per-marketplace predicate.
   The previous predicate (`k[0] == marketplace_id and k[1] != mtime_ns`)
   only swept stale entries for the CURRENT marketplace; with N>100
   distinct marketplaces each holding one mtime key, the cap silently
   failed and memory grew linearly. Replaced with OrderedDict-backed
   bounded LRU at cap=256, drop oldest insert on overflow.
   Cache stress test pinned in test_marketplace_metadata.py.

2. Render CPU cap: per-field byte cap on description / when_to_use /
   sample_interaction.assistant via MARKETPLACE_METADATA_FIELD_MAX_BYTES
   (= 64 KiB). Without this, a 1 MiB curator markdown body × QPS =
   curator-controlled CPU burn through pure-Python markdown-it-py.
   Truncation respects UTF-8 boundaries and logs a warning so the
   curator sees the cap fire on the next sync. Test for cap +
   UTF-8-boundary preservation.

3. Inner-detail bypassed the metadata cache. _curated_inner_enrichment,
   _curated_inner_cover, and curated_detail all called
   read_marketplace_metadata directly, defeating the mtime cache the
   plugin listing already shared. Routed all three through
   _read_metadata_cached so skill/agent detail hits are O(1) re-parses
   per marketplace per mtime instead of O(QPS).

4. Truthy-vs-presence trap in plugin/inner enrichment merge. API-layer
   writers used `if resolved.get(k):` which silently dropped any
   future falsy-but-valid resolver field (bool featured=False, int
   priority=0, str category=''). Switched to presence check
   (`if k in resolved`) so the resolver is the authority on field
   presence; `{**parent, **enrichment}` merge respects whatever the
   resolver decided to ship.

5. Vendor-agnostic OSS cleanup. Removed operator-specific token
   references (/grpn-eng:, @grpn-eng:, .foundryai/) from
   src/marketplace_metadata.py docstring, app/web/templates/
   marketplace_item_detail.html JS comment, docs/curated-marketplace-
   format.md, and tests/test_marketplace_metadata.py fixtures. Replaced
   with generic /my-plugin:tool / @my-agent:role / .example/ placeholders.

CHANGELOG
---------
- New "### Fixed (PR #251 follow-ups)" section documenting all 4
  code-side must-fixes
- New "### Internal" section noting the vendor cleanup + new tests
- BREAKING bullet for the file rename now covers operator-side
  migration: running instances see plugin enrichment disappear from
  the UI until upstream curator renames + nightly sync overwrites the
  working tree; POST /api/marketplaces/{id}/sync forces refresh sooner
- Stripped /grpn-eng: leaks from the existing skill/agent rich-content
  bullet

Tests
-----
128 targeted tests pass (test_marketplace_metadata, test_marketplace_api,
test_marketplace, test_markdown_render, test_marketplace_synth_strip,
test_marketplace_filter). New tests added:
- 6 XSS regression tests on render_safe (javascript:/data:/vbscript:
  schemes via autolink, reference link, and mixed-case + positive
  http/https/mailto + noopener noreferrer rel)
- 3 byte-cap tests (truncation + UTF-8 boundary + under-cap pass-through)
- 1 cache eviction stress test (>256 marketplaces -> bounded at cap)
- 1 truthy-vs-presence resolver-contract test

Release-cut
-----------
- pyproject.toml 0.49.1 -> 0.50.0 (minor; BREAKING file rename per
  pre-1.0 CHANGELOG note: "breaking changes called out under Changed
  or Removed with the BREAKING marker")
- CHANGELOG [Unreleased] -> [0.50.0] - 2026-05-12, new empty
  [Unreleased] on top.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-12 08:38:39 +00:00

1351 lines
58 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 %}{{ plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.plugin-detail {
--primary-light: rgba(0, 115, 209, 0.12);
--border-light: #eceff1;
--text-primary: #202124;
--text-secondary: #5f6368;
--warn-color: #b45309;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
}
/* ── Hero ─────────────────────────────────────────────────────────── */
.plugin-detail .hero {
position: relative;
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 14px;
padding: 22px 28px 28px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.18);
color: #fff;
}
.plugin-detail .crumbs {
display: flex; gap: 6px; align-items: center;
font-size: 12px; color: rgba(255,255,255,0.78);
margin-bottom: 18px;
}
.plugin-detail .crumbs a { color: #fff; opacity: 0.92; text-decoration: none; }
.plugin-detail .crumbs a:hover { text-decoration: underline; }
.plugin-detail .crumbs .sep { opacity: 0.5; }
.plugin-detail .hero-head {
display: grid;
grid-template-columns: 380px minmax(0, 1fr) 300px;
gap: 22px;
align-items: start;
}
@media (max-width: 1100px) {
.plugin-detail .hero-head { grid-template-columns: 380px minmax(0, 1fr); }
}
@media (max-width: 720px) {
.plugin-detail .hero-head {
grid-template-columns: 1fr;
}
}
/* Hero window — macOS-style frame around the cover photo. The body has
aspect-ratio 715/310 so curator-uploaded covers never crop to a
square. Titlebar shows 3 traffic-light dots + the plugin's
manifest_name as a centered label. When cover_photo_url is missing
(or the image 404s), the body falls back to a translucent gradient
with the plugin's initials — same placeholder feel as before. */
.plugin-detail .hero-window {
width: 380px;
background: #1e293b;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.40),
0 2px 6px rgba(0, 0, 0, 0.18);
flex-shrink: 0;
align-self: start;
}
.plugin-detail .hero-window-titlebar {
background: linear-gradient(180deg, #2a3445 0%, #1e293b 100%);
height: 26px;
display: flex; align-items: center; gap: 6px;
padding: 0 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
.plugin-detail .hwdot {
width: 11px; height: 11px; border-radius: 50%;
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18);
flex-shrink: 0;
}
.plugin-detail .hwdot.red { background: #ff5f56; }
.plugin-detail .hwdot.yellow { background: #ffbd2e; }
.plugin-detail .hwdot.green { background: #27c93f; }
.plugin-detail .hero-window-label {
margin: 0 auto;
/* mirror the dots' left footprint (~46px) on the right so the
label lands optically centered, not visually offset by the dots */
padding-right: 46px;
font-size: 10.5px; color: rgba(255, 255, 255, 0.55);
font-family: var(--font-mono);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 100%;
}
.plugin-detail .hero-window-body {
aspect-ratio: 715 / 310;
overflow: hidden;
background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0.04) 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 44px; font-weight: 700; letter-spacing: 1px;
}
.plugin-detail .hero-window-body img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.plugin-detail .meta { min-width: 0; }
.plugin-detail h1 {
margin: 0 0 6px; font-size: 28px; font-weight: 700;
letter-spacing: -0.4px; color: #fff;
word-wrap: break-word;
}
.plugin-detail .tagline {
font-size: 14.5px; line-height: 1.6;
color: rgba(255,255,255,0.92); margin-bottom: 6px;
}
.plugin-detail .curator {
font-size: 12.5px; color: rgba(255,255,255,0.78);
margin-bottom: 14px;
}
.plugin-detail .curator strong { color: #fff; font-weight: 600; }
.plugin-detail .curator .todo { color: #FED7AA; font-style: italic; }
.plugin-detail .pills {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
}
.plugin-detail .pill {
background: rgba(255,255,255,0.16); color: #fff;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 500;
}
.plugin-detail .pill.cat { background: rgba(255,255,255,0.22); }
.plugin-detail .pill.ver { font-family: var(--font-mono); }
.plugin-detail .pill.curated { background: #FEF3C7; color: #B45309; font-weight: 600; }
.plugin-detail .pill.flea { background: #EDE9FE; color: #6D28D9; font-weight: 600; }
.plugin-detail .pill.muted { background: transparent; color: rgba(255,255,255,0.72); padding-left: 0; }
.plugin-detail .actions {
/* Absolute on .hero — anchored at the top-right corner with equal
22px offsets from top and right. Hint card stacks below the
button row inside this absolute container, so it stays inside
the hero gradient and toggling it doesn't shift body content. */
position: absolute; top: 22px; right: 22px;
width: 300px;
display: flex; flex-direction: column; gap: 10px;
align-items: stretch;
z-index: 1;
}
@media (max-width: 1100px) {
.plugin-detail .actions {
position: static; width: auto; margin-top: 18px;
align-items: flex-end;
}
}
.plugin-detail .actions-row {
display: flex; flex-direction: row; align-items: center;
justify-content: flex-end; gap: 12px;
}
/* Without this rule the [hidden] HTML attribute is overridden by the
explicit `display: inline-flex` on .status-pill, leaving the "✓ In
your stack" label visible on plugins the user has NOT installed. */
.plugin-detail .actions [hidden] { display: none !important; }
.plugin-detail .btn-install {
appearance: none; cursor: pointer;
padding: 11px 22px; border-radius: 9px;
font-size: 13px; font-weight: 600; font-family: inherit;
/* Transparent border kept on the default so :hover can swap to a
visible white border without shifting the button's size. */
border: 1px solid transparent;
transition: all 0.15s ease;
background: #fff; color: var(--primary);
}
.plugin-detail .btn-install:hover {
/* Darken-glass — same formula as the secondary "Open parent plugin"
button on the skill/agent detail hero, so all hero-action hovers
feel consistent. The blue hero shows through the 20% black tint. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
/* Status label — inline text indicator, NOT a button. No border, no
fill, no padding-as-chrome: this is a label that says "currently
in stack" sitting before the Remove button. The check glyph + light
green color carry the meaning; visual weight stays below the
adjacent button so the user's eye lands on the action. The system
variant uses an amber tone for the same lock semantic the SYSTEM
pill uses elsewhere. */
.plugin-detail .status-pill {
display: inline-flex; align-items: center;
font-size: 13px; font-weight: 500;
color: #fff;
cursor: default; user-select: none;
}
.plugin-detail .status-pill.is-system {
color: #fef3c7;
cursor: not-allowed;
}
/* Remove from stack — outlined red border by default so the
destructive intent is announced even before hover; on hover the
full red fill commits to the message. The red-on-blue contrast is
intentional — same palette logic as the X close button on the
hero's quarantine banner. */
.plugin-detail .btn-remove {
appearance: none; cursor: pointer;
/* Padding + font-size mirror .btn-install so the off-state CTA and
the on-state Remove button are the same physical height — no
layout shift when the user toggles the install state. */
padding: 11px 22px; border-radius: 9px;
font-size: 13px; font-weight: 600; font-family: inherit;
background: transparent; color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.7);
transition: all 0.15s ease;
}
.plugin-detail .btn-remove:hover {
background: rgba(220, 38, 38, 0.85); color: #fff;
border-color: rgba(220, 38, 38, 0.95);
}
.plugin-detail .btn-remove:focus-visible {
outline: 2px solid rgba(254, 202, 202, 0.85); outline-offset: 2px;
}
/* ── Post-add hint panel ─────────────────────────────────────────────
Inline next-steps recipe rendered after a successful "Add to my stack"
click. Lives below the description panel so the user sees it the
moment the page reflows from the Add action. The Catppuccin-Mocha
code chip mirrors the marketplace_item_detail invocation chip + the
/setup terminal blocks, so a familiar visual cue means "this is a
command you run in your terminal". */
/* Glass-on-gradient: lives inside the hero, sitting under the action
row in the third grid column. Translucent white over the blue
gradient reads as "elevated tile of the same hero" — no white-on-
white card insertion, no layout-shift below the hero (the hero
window's titlebar + 715:310 body run taller than the action+hint
stack, so toggling the hint visibility doesn't grow the hero in
practice). */
.plugin-detail .stack-hint {
padding: 10px 12px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.96);
line-height: 1.5;
backdrop-filter: blur(6px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
}
.plugin-detail .stack-hint .head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 4px;
}
.plugin-detail .stack-hint .title {
font-weight: var(--font-semibold);
color: #fff;
font-size: 12px;
}
.plugin-detail .stack-hint .dismiss {
appearance: none; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.30);
color: rgba(255, 255, 255, 0.82); font-size: 10px; cursor: pointer;
padding: 2px 7px; border-radius: 5px;
font-family: inherit;
white-space: nowrap;
}
.plugin-detail .stack-hint .dismiss:hover {
color: #fff; background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.50);
}
.plugin-detail .stack-hint ol {
margin: 6px 0 0; padding-left: 20px;
color: var(--text-secondary);
}
.plugin-detail .stack-hint ol li { margin: 4px 0; }
.plugin-detail .stack-hint ol li strong { color: var(--text-primary); font-weight: var(--font-semibold); }
.plugin-detail .stack-hint .cmd-chip {
display: inline-flex; align-items: center; gap: 8px;
margin-top: 6px;
padding: 6px 10px;
background: #1e1e2e;
border-radius: 6px;
font-family: var(--font-mono); font-size: 12px;
color: #cdd6f4;
}
.plugin-detail .stack-hint .cmd-chip .prompt {
color: #a6e3a1; user-select: none; font-weight: var(--font-bold);
}
.plugin-detail .stack-hint .cmd-chip .btn-copy {
appearance: none; cursor: pointer;
padding: 2px 8px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
border-radius: 4px;
font-size: 10px; font-weight: var(--font-medium);
font-family: var(--font-primary);
transition: all 0.15s ease;
}
.plugin-detail .stack-hint .cmd-chip .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.plugin-detail .stack-hint .cmd-chip .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
}
.plugin-detail .stack-hint .learn-more {
display: inline-block; margin-top: 8px;
font-size: 12px; color: var(--primary); text-decoration: none;
}
.plugin-detail .stack-hint .learn-more:hover { text-decoration: underline; }
/* ── Top row ─────────────────────────────────────────────────────── */
.plugin-detail .top-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-bottom: 24px;
align-items: stretch;
}
/* Spacing between the bottom panels (use-cases / sample / video / docs /
internal structure). The hero-row has its own margin-bottom; subsequent
stacked panels need equivalent breathing room or they collide visually
(~2px between bordered cards looks like a render bug). */
.plugin-detail #panel-use-cases,
.plugin-detail #panel-sample,
.plugin-detail #panel-video,
.plugin-detail #panel-docs,
.plugin-detail .structure {
margin-top: 24px;
}
@media (max-width: 900px) {
.plugin-detail .top-row { grid-template-columns: 1fr; }
}
.plugin-detail .panel {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 22px 26px;
}
.plugin-detail .panel h2 {
font-size: 15px; font-weight: 600;
margin: 0 0 14px;
text-transform: uppercase; letter-spacing: 0.6px;
color: var(--text-secondary);
}
.plugin-detail .lead { font-size: 14.5px; line-height: 1.65; color: var(--text-primary); white-space: pre-wrap; }
/* `.lead-rendered` switches the lead block from plain-text mode (which
uses `white-space: pre-wrap` so plain `description` line breaks survive)
to HTML mode (markdown-rendered body where paragraph breaks come from
`<p>` tags, not whitespace). pre-wrap would otherwise turn the gap
between two `<p>` blocks into stacked blank lines. */
.plugin-detail .lead-rendered { white-space: normal; }
.plugin-detail .lead-rendered > *:first-child { margin-top: 0; }
.plugin-detail .lead-rendered > *:last-child { margin-bottom: 0; }
.plugin-detail .lead-rendered p { margin: 0 0 12px; }
.plugin-detail .lead-rendered h2,
.plugin-detail .lead-rendered h3,
.plugin-detail .lead-rendered h4 {
/* Scoped overrides — the panel already provides an `<h2>` for the
section title; markdown headings nested inside the body shouldn't
inherit the uppercase / letter-spaced style of the section h2. */
font-size: 14.5px; font-weight: 600;
margin: 14px 0 6px;
text-transform: none; letter-spacing: 0; color: var(--text-primary);
}
.plugin-detail .lead-rendered ul,
.plugin-detail .lead-rendered ol { margin: 0 0 12px 22px; padding: 0; }
.plugin-detail .lead-rendered code {
background: var(--surface-alt, #f4f4f5); border-radius: 4px;
padding: 1px 5px; font-size: 0.92em;
font-family: var(--font-mono);
}
.plugin-detail .lead-rendered pre {
background: #1e1e2e; color: #cdd6f4;
border-radius: 8px; padding: 12px 14px;
font-family: var(--font-mono); font-size: 12.5px;
overflow-x: auto; margin: 8px 0 14px;
}
.plugin-detail .lead-rendered pre code {
background: transparent; padding: 0; color: inherit;
}
.plugin-detail .lead-rendered a { color: var(--primary); text-decoration: none; }
.plugin-detail .lead-rendered a:hover { text-decoration: underline; }
/* Use-cases grid — 3-column on wide, 2 on tablet, 1 on phone. Each card
has title, short description, and the literal slash-prompt the user
would paste into Claude Code. */
.plugin-detail .use-cases-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1100px) {
.plugin-detail .use-cases-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.plugin-detail .use-cases-grid { grid-template-columns: 1fr; }
}
.plugin-detail .use-case-card {
border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px;
background: var(--surface-alt, #fafafa);
display: flex; flex-direction: column; gap: 8px;
}
.plugin-detail .use-case-card h3 {
margin: 0; font-size: 13.5px; font-weight: 600;
color: var(--text-primary); letter-spacing: 0;
text-transform: none;
}
.plugin-detail .use-case-card p {
margin: 0; font-size: 12.5px; line-height: 1.5;
color: var(--text-secondary);
}
.plugin-detail .use-case-prompt {
margin: 4px 0 0;
background: #1e1e2e; color: #a6e3a1;
border-radius: 6px;
padding: 8px 10px;
font-family: var(--font-mono); font-size: 12px;
line-height: 1.4;
overflow-x: auto;
white-space: pre-wrap; word-break: break-word;
}
/* Sample interaction — Claude Code transcript styling. Single dark
Catppuccin Mocha panel splits the user prompt from Claude's response
with a thin separator; same visual language as the .invocation chip
elsewhere so the curated detail page reads as "this is what you'll
get inside Claude Code", not "this is a generic chat app". */
.plugin-detail .sample-interaction {
background: #1e1e2e; /* mocha base */
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
overflow: hidden;
display: flex; flex-direction: column;
}
.plugin-detail .sample-user,
.plugin-detail .sample-assistant {
padding: 14px 18px;
}
.plugin-detail .sample-user {
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.015); /* a hair lighter — separates rows */
}
.plugin-detail .sample-label {
/* Hidden in the Claude-Code transcript styling. The `>` green prompt
* indicator on the user row + the bare prose body on the assistant
* row are the same visual cues a real Claude Code session uses to
* tell who's speaking — labels would add chrome the original
* doesn't have. The markup stays for accessibility (screen readers
* read the inline text) but is visually collapsed.
*/
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
/* User prompt — monospace, leading `>` green prompt glyph. Reads like
a literal shell-input line the curator could copy-paste verbatim. */
.plugin-detail .sample-user > div:last-child {
font-family: var(--font-mono);
color: #cdd6f4; /* mocha text */
font-size: 13.5px; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.plugin-detail .sample-user > div:last-child::before {
content: "> ";
color: #a6e3a1; /* mocha green */
font-weight: 700;
user-select: none;
}
/* Claude's response — sans-serif prose (Claude doesn't reply in mono),
markdown body lives inside. Inline code keeps the mono treatment so
code mentions still read as code. */
.plugin-detail .sample-assistant-body {
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
color: #cdd6f4;
font-size: 13.5px; line-height: 1.65;
}
.plugin-detail .sample-assistant-body > *:first-child { margin-top: 0; }
.plugin-detail .sample-assistant-body > *:last-child { margin-bottom: 0; }
.plugin-detail .sample-assistant-body p { margin: 0 0 10px; }
.plugin-detail .sample-assistant-body strong { color: #fab387; } /* mocha peach */
.plugin-detail .sample-assistant-body em { color: #f9e2af; font-style: italic; } /* mocha yellow */
.plugin-detail .sample-assistant-body code {
background: rgba(255,255,255,0.06);
color: #f5c2e7; /* mocha pink */
border-radius: 4px;
padding: 1px 5px;
font-size: 0.92em;
font-family: var(--font-mono);
}
.plugin-detail .sample-assistant-body pre {
background: #181825; /* mocha mantle — darker nested */
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 12px 14px;
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5;
color: #cdd6f4;
overflow-x: auto;
margin: 8px 0;
}
.plugin-detail .sample-assistant-body pre code {
background: transparent; padding: 0;
color: inherit;
}
.plugin-detail .sample-assistant-body ul,
.plugin-detail .sample-assistant-body ol {
margin: 0 0 10px 22px; padding: 0;
}
.plugin-detail .sample-assistant-body li { margin: 2px 0; }
.plugin-detail .sample-assistant-body a {
color: #89b4fa; /* mocha blue */
text-decoration: none;
}
.plugin-detail .sample-assistant-body a:hover { text-decoration: underline; }
.plugin-detail .sample-assistant-body blockquote {
border-left: 3px solid #585b70; /* mocha surface2 */
margin: 8px 0;
padding: 4px 0 4px 12px;
color: #bac2de; /* mocha subtext1 */
}
.plugin-detail .sample-assistant-body h2,
.plugin-detail .sample-assistant-body h3,
.plugin-detail .sample-assistant-body h4 {
color: #cdd6f4;
font-size: 14px; font-weight: 600;
margin: 12px 0 6px;
text-transform: none; letter-spacing: 0;
}
.plugin-detail .details dl { margin: 0; }
.plugin-detail .details .row {
display: grid; grid-template-columns: max-content 1fr; gap: 12px;
padding: 10px 0; border-bottom: 1px solid var(--border-light);
font-size: 13px;
}
.plugin-detail .details .row:last-child { border-bottom: none; }
.plugin-detail .details dt { color: var(--text-secondary); margin: 0; font-weight: 500; }
.plugin-detail .details dd { margin: 0; color: var(--text-primary); font-weight: 500; text-align: right; }
.plugin-detail .details dd.mono { font-family: var(--font-mono); font-size: 12px; }
.plugin-detail .details dd .todo { color: var(--warn-color); font-style: italic; font-weight: 400; }
/* ── Internal structure ──────────────────────────────────────────── */
.plugin-detail .structure { margin-top: 4px; }
.plugin-detail .structure > h2 {
font-size: 16px; font-weight: 700;
margin: 0 0 16px; letter-spacing: -0.2px;
color: var(--text-primary); text-transform: none;
}
.plugin-detail .substruct {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 20px 24px; margin-bottom: 16px;
}
.plugin-detail .substruct .head {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 12px;
border-bottom: 1px solid var(--border-light);
}
.plugin-detail .substruct .head h3 {
margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary);
}
.plugin-detail .substruct .head .count {
font-size: 12px; color: var(--text-secondary); font-family: var(--font-mono);
}
/* v32: demo video uses the shared `.video-embed` 16:9 wrapper from
style-custom.css — no scoped overrides needed here. */
.plugin-detail .doc-link-list {
list-style: none; padding: 0; margin: 0;
display: grid; gap: 8px;
}
.plugin-detail .doc-link-list li {
padding: 10px 12px; border-radius: 8px;
background: var(--surface-alt, #f9fafb);
border: 1px solid var(--border);
}
.plugin-detail .doc-link-list a {
color: var(--primary); text-decoration: none; font-weight: 500;
display: block;
}
.plugin-detail .doc-link-list a:hover { text-decoration: underline; }
/* Inner cards (skills + agents) */
.plugin-detail .inner-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) { .plugin-detail .inner-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 820px) { .plugin-detail .inner-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 540px) { .plugin-detail .inner-grid { grid-template-columns: 1fr; } }
.plugin-detail .inner-card {
display: flex; flex-direction: column;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; cursor: pointer;
transition: all 0.15s ease; text-decoration: none; color: inherit;
}
.plugin-detail .inner-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.10);
transform: translateY(-1px);
}
.plugin-detail .inner-card .photo {
width: 100%; aspect-ratio: 715 / 310;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
color: var(--primary);
font-size: 18px; font-weight: var(--font-bold);
letter-spacing: 0.5px;
border: none; border-radius: 0;
overflow: hidden;
}
.plugin-detail .inner-card .photo img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.plugin-detail .inner-card[data-type="skill"] .photo {
background: linear-gradient(135deg, rgba(16,183,127,0.18) 0%, #ecfdf5 100%);
color: #0e9b6a;
}
.plugin-detail .inner-card[data-type="agent"] .photo {
background: linear-gradient(135deg, rgba(124,58,237,0.18) 0%, #f5f3ff 100%);
color: #6d28d9;
}
.plugin-detail .inner-card .body {
padding: 12px 14px; flex: 1;
display: flex; flex-direction: column; gap: 5px;
}
.plugin-detail .inner-card .type-badge {
align-self: flex-start;
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 10px; font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(16, 183, 127, 0.14); color: #0e9b6a;
}
.plugin-detail .inner-card[data-type="agent"] .type-badge {
background: rgba(124,58,237,0.14); color: #6d28d9;
}
.plugin-detail .inner-card .name {
font-weight: var(--font-semibold); color: var(--text-primary);
font-size: 13.5px; line-height: 1.3;
font-family: var(--font-mono);
}
.plugin-detail .inner-card .desc {
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
/* Tables (commands, hooks, mcps) */
.plugin-detail .substruct table { width: 100%; border-collapse: collapse; font-size: 13px; }
.plugin-detail .substruct th {
text-align: left;
font-size: 11px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.plugin-detail .substruct td {
padding: 10px; border-bottom: 1px solid var(--border-light);
vertical-align: top; color: var(--text-primary);
}
.plugin-detail .substruct tr:last-child td { border-bottom: none; }
.plugin-detail .substruct .cell-name {
font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--primary); white-space: nowrap;
}
.plugin-detail .substruct .cell-event,
.plugin-detail .substruct .cell-type {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-secondary); white-space: nowrap;
}
.plugin-detail .substruct .cell-desc {
font-size: 12.5px; color: var(--text-secondary); line-height: 1.55;
}
.plugin-detail .empty-msg {
color: var(--text-secondary); font-size: 13px; font-style: italic;
}
</style>
<div class="plugin-detail page-shell" id="root"
data-source="{{ source }}"
data-marketplace-id="{{ marketplace_id or '' }}"
data-plugin-name="{{ plugin_name or '' }}"
data-entity-id="{{ entity_id or '' }}"
data-visibility="{{ entity.visibility_status if entity else 'approved' }}">
{# Quarantine banner — owner / admin only when non-approved. Self-guarded. #}
{% include "_quarantine_banner.html" %}
{# Owner-actions strip (Edit + Delete locked-when-not-approved). Mirrors
the policy that previously lived in store_detail.html. Edit is a
placeholder for now ("coming soon"); Delete is gated server-side
so the visible state matches what the API will accept. #}
{% if entity and (is_owner or is_admin) %}
<style>
.plugin-detail .owner-actions {
display: flex; gap: 10px; margin: 0 0 16px 0; justify-content: flex-end;
}
.plugin-detail .owner-actions a,
.plugin-detail .owner-actions button {
padding: 6px 14px; border-radius: 8px;
font-size: 13px; font-weight: 500; font-family: var(--font-primary);
text-decoration: none; border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff); color: var(--text, #111827);
cursor: pointer;
}
.plugin-detail .owner-actions a:hover {
border-color: var(--primary, #0073D1); color: var(--primary, #0073D1);
}
.plugin-detail .owner-actions .delete {
color: #b91c1c; border-color: rgba(185,28,28,0.3);
}
.plugin-detail .owner-actions .delete:hover {
background: rgba(185,28,28,0.08); border-color: #b91c1c;
}
.plugin-detail .owner-actions button:disabled,
.plugin-detail .owner-actions a[aria-disabled="true"] {
color: #9ca3af !important; border-color: #e5e7eb !important;
background: #f3f4f6 !important; cursor: not-allowed;
}
</style>
<div class="owner-actions">
{# v37 edit feature: Edit lands a real page. Disabled while a
prior version is under review (server-side 409 also enforces).
edit_in_flight is set by the router whenever the latest
submission is pending_inline / pending_llm — even if the
entity stayed at visibility='approved' (deferred-promotion
path so existing installers keep receiving the prior bundle). #}
{% if edit_in_flight %}
<a href="#" aria-disabled="true"
title="Wait for the in-flight review to finish before editing.">
Edit (review in flight)
</a>
{% else %}
<a href="/marketplace/flea/{{ entity.id }}/edit"
title="Edit metadata or upload a new version.">
Edit
</a>
{% endif %}
{# v35 delete UX: Archive (soft) is the primary path. Owner sees
Archive only when the entity is approved or already archived
(re-archive is a no-op, but no point exposing). Admin gets
Archive AND Hard Delete (separate red button) regardless of
state. Quarantined (non-approved + non-archived) entities lock
both buttons for the owner — admin still sees both. #}
{% if is_admin %}
{# Archive (soft) only meaningful when the entity is currently
public (approved). For non-approved states the entity is
already hidden — archiving would just lose the quarantine /
pending state info. Admin still has Hard delete + the
override / rescan / retry actions on the quarantine banner
to manage non-approved entities. #}
{% if entity.visibility_status == 'approved' %}
<button class="delete" id="owner-archive-btn" type="button"
title="Soft delete: hides from browse + blocks new installs. Existing user_store_installs continue serving the bundle.">
Archive
</button>
{% elif entity.visibility_status == 'archived' %}
<button class="delete" type="button" disabled
title="Already archived. Hidden from browse; existing installs still served. Use Hard delete to purge.">
Archived
</button>
{% else %}
<button class="delete" type="button" disabled
title="Archive is only available for approved entities. Use Override (in quarantine banner) to publish, Rescan to re-evaluate, or Hard delete to purge.">
Archive (not applicable while {{ entity.visibility_status }})
</button>
{% endif %}
<button class="delete" id="owner-hard-delete-btn" type="button"
style="border-color: rgba(185,28,28,0.45);"
title="Hard delete: drops the bundle from disk + removes existing user_store_installs. Use only for legal / privacy removals — existing users lose the plugin.">
Hard delete (admin)
</button>
{% elif entity.visibility_status == 'approved' %}
<button class="delete" id="owner-archive-btn" type="button"
title="Soft delete: hides from browse + blocks new installs. Existing user_store_installs continue serving the bundle.">
Archive
</button>
{% elif entity.visibility_status == 'archived' %}
<button class="delete" type="button" disabled
title="Already archived. Hidden from browse; existing installs still served. Contact an admin for hard delete.">
Archived
</button>
{% elif entity.visibility_status == 'pending' %}
<button class="delete" type="button" disabled
title="Submission is under review — Delete is locked until checks finish.">
Delete (locked — under review)
</button>
{% else %}
<button class="delete" type="button" disabled
title="Submission is quarantined. Only an admin can delete it (so the failure evidence isn't lost). Edit + re-upload to fix the issues.">
Delete (locked — quarantined)
</button>
{% endif %}
</div>
{% endif %}
{% include "_flea_versions.html" %}
<div class="hero">
<div class="crumbs">
<a href="/marketplace?tab={{ 'curated' if source == 'curated' else 'flea' }}">Marketplace</a>
<span class="sep"></span>
<span id="crumb-mid">{{ source | capitalize }}</span>
<span class="sep"></span>
<span id="crumb-name">{{ plugin_name }}</span>
</div>
<div class="hero-head">
<div class="hero-window" id="hero-photo" aria-hidden="true">
<div class="hero-window-titlebar">
<span class="hwdot red"></span>
<span class="hwdot yellow"></span>
<span class="hwdot green"></span>
<span class="hero-window-label" id="hero-window-label">{{ plugin_name }}</span>
</div>
<div class="hero-window-body" id="hero-window-body">PL</div>
</div>
<div class="meta">
<h1 id="hero-name">{{ plugin_name }}</h1>
<div class="tagline" id="hero-tagline">Loading…</div>
<div class="curator" id="hero-curator"></div>
<div class="pills" id="hero-pills"></div>
</div>
</div>
<!-- Actions absolutely anchored at the hero's top-right corner with
the post-action hint card stacked below them. Both stay inside
the hero gradient — the hero's natural height (window titlebar
+ 715:310 body ≈ 191px) contains them without overflow. -->
<div class="actions">
<div class="actions-row">
<button class="btn-install" id="install-btn" type="button" hidden>+ Add to my stack</button>
<span class="status-pill" id="status-pill" hidden>✓ In your stack</span>
<button class="btn-remove" id="remove-btn" type="button" hidden>✕ Remove from stack</button>
</div>
<div class="stack-hint" id="stack-hint" hidden>
<div class="head">
<span class="title" id="stack-hint-title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button>
</div>
<div>Run in Claude Code:</div>
<div class="cmd-chip">
<span class="prompt">/</span>
<span class="cmd">update-agnes-plugins</span>
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
</div>
</div>
</div>
</div>
<div class="top-row">
<div class="panel" id="panel-what">
<h2>What it does</h2>
<div class="lead" id="lead-text">Loading…</div>
</div>
<div class="panel details" id="panel-details">
<h2>Details</h2>
<dl id="details-list"></dl>
</div>
</div>
<!-- Use cases — populated from marketplace-metadata.json use_cases[]. Hidden
until the curator has supplied at least one card. -->
<div class="panel" id="panel-use-cases" hidden>
<h2>When to use it</h2>
<div class="use-cases-grid" id="panel-use-cases-grid"></div>
</div>
<!-- Sample interaction — single user/assistant Q&A from
marketplace-metadata.json sample_interaction. Hidden until populated. -->
<div class="panel" id="panel-sample" hidden>
<h2>Example</h2>
<div class="sample-interaction">
<div class="sample-user"><span class="sample-label">You</span><div id="sample-user"></div></div>
<div class="sample-assistant"><span class="sample-label">Claude</span><div id="sample-assistant" class="sample-assistant-body"></div></div>
</div>
</div>
<!-- v32: video + doc_links section. Both blocks stay hidden until populated
so the layout collapses gracefully when an upstream marketplace ships
no marketplace-metadata.json. Doc icons differentiate internal-cached files
(📄) from external links (🔗) so the user knows what's a click away. -->
<div class="panel" id="panel-video" hidden>
<h2>Demo video</h2>
<div class="video-embed" id="video-wrap"></div>
</div>
<div class="panel" id="panel-docs" hidden>
<h2>Documentation</h2>
<ul class="doc-link-list" id="doc-link-list"></ul>
</div>
<div class="structure" id="structure" hidden>
<h2>Internal structure</h2>
<div id="struct-skills"></div>
<div id="struct-agents"></div>
<div id="struct-commands"></div>
<div id="struct-hooks"></div>
<div id="struct-mcps"></div>
</div>
<div id="error-msg" class="panel" hidden>
<p class="empty-msg" id="error-text"></p>
</div>
</div>
<script>
'use strict';
(async function(){
{% include "_marketplace_video_embed.html" %}
const root = document.getElementById('root');
const source = root.dataset.source;
const marketplaceId = root.dataset.marketplaceId;
const pluginName = root.dataset.pluginName;
const entityId = root.dataset.entityId;
const apiURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}`
: `/api/marketplace/flea/${encodeURIComponent(entityId)}/detail`;
const installURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}/install`
: `/api/store/entities/${encodeURIComponent(entityId)}/install`;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function fmtBytes(n) {
if (n == null) return '—';
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
if (n < 1024*1024*1024) return (n/(1024*1024)).toFixed(1) + ' MB';
return (n/(1024*1024*1024)).toFixed(2) + ' GB';
}
function fmtRelative(iso) {
if (!iso) return '—';
const t = new Date(iso);
if (isNaN(t)) return iso;
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return days + ' days ago';
if (days < 365) return Math.floor(days/30) + ' months ago';
return Math.floor(days/365) + ' years ago';
}
function showError(status) {
document.getElementById('hero-tagline').textContent = '';
document.getElementById('lead-text').textContent = '';
const err = document.getElementById('error-msg');
const txt = document.getElementById('error-text');
if (status === 403) txt.textContent = 'You do not have access to this plugin. Ask your admin to grant your group access.';
else if (status === 404) txt.textContent = 'Plugin not found.';
else txt.textContent = 'Failed to load plugin (' + status + ').';
err.hidden = false;
}
let res;
try { res = await fetch(apiURL); }
catch (e) { showError(0); return; }
if (!res.ok) { showError(res.status); return; }
const d = await res.json();
// ── Hero ────────────────────────────────────────────────────────
// Display name resolution priority:
// 1. marketplace-metadata.json `display_name` (curator-friendly label)
// 2. .claude-plugin/plugin.json `name` (manifest_name — the value
// Claude Code uses for slash-command namespacing)
// 3. URL path's plugin_name (technical id, last-resort fallback)
// Tagline falls back to marketplace.json description (verbose tech text)
// when the curator hasn't filled the friendly tagline yet — preserves
// the historical hero look for un-enriched plugins.
const heroTitle = d.display_name || d.manifest_name || d.plugin_name;
document.getElementById('crumb-mid').textContent =
d.source === 'curated' ? (d.marketplace_name || d.marketplace_id) : 'Flea Market';
document.getElementById('crumb-name').textContent = heroTitle;
document.title = `${heroTitle} — Marketplace`;
document.getElementById('hero-name').textContent = heroTitle;
document.getElementById('hero-tagline').textContent = d.tagline || d.description || '';
// Window titlebar label mirrors the h1 — keeps the mac-window framing
// consistent with whichever name the user actually sees as the title.
const heroWindowLabel = document.getElementById('hero-window-label');
if (heroWindowLabel) heroWindowLabel.textContent = heroTitle;
const curator = document.getElementById('hero-curator');
if (d.source === 'curated') {
if (d.author_name && d.author_name !== 'owner_todo') {
curator.innerHTML = 'Curator: <strong>' + esc(d.author_name) + '</strong>';
} else {
curator.innerHTML = 'Curator: <span class="todo">owner_todo</span>';
}
} else {
curator.innerHTML = 'by <strong>' + esc(d.author_name || '') + '</strong>';
}
const pills = document.getElementById('hero-pills');
const pillBits = [];
if (d.category) pillBits.push(`<span class="pill cat">${esc(d.category)}</span>`);
if (d.source === 'curated')
pillBits.push(`<span class="pill curated">Curated</span>`);
else
pillBits.push(`<span class="pill flea">Flea</span>`);
const verLabel = d.source === 'curated'
? `${esc(d.marketplace_name || d.marketplace_id)} v${esc(d.version || '')}`
: `v${esc(d.version || '')}`;
if (d.version) pillBits.push(`<span class="pill ver">${verLabel}</span>`);
if (d.updated_at) pillBits.push(`<span class="pill muted">Updated ${esc(fmtRelative(d.updated_at))}</span>`);
pills.innerHTML = pillBits.join('');
// Hero window — macOS-style frame around the cover photo. The titlebar
// label carries the plugin's manifest_name; the body holds the cover
// photo (or a placeholder gradient + initials when no cover is set).
const labelEl = document.getElementById('hero-window-label');
if (labelEl) labelEl.textContent = d.manifest_name || d.plugin_name;
const bodyEl = document.getElementById('hero-window-body');
if (d.cover_photo_url) {
// Same fallback pattern as the marketplace card grid — when the cover
// 404s (missing internal file, or external mirror failed and the
// pass-through URL is dead), restore the initials placeholder so the
// hero looks identical to the no-cover case rather than showing the
// browser's broken-image icon.
const initials = bodyEl.textContent || 'PL';
bodyEl.innerHTML = `<img src="${esc(d.cover_photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent=this.dataset.fallback;"
data-fallback="${esc(initials)}">`;
} else {
bodyEl.textContent = 'PL';
}
// Three-element install state: install button (off-state CTA), status
// pill (on-state label), remove button (on-state action). Only one of
// {installBtn} vs {statusPill + removeBtn} is visible at a time. The
// separation lets the on-state communicate BOTH facts simultaneously
// ("currently in stack" + "click here to remove") without the GitHub
// hover-morph trick — explicit affordance, desktop-first.
const installBtn = document.getElementById('install-btn');
const statusPill = document.getElementById('status-pill');
const removeBtn = document.getElementById('remove-btn');
function renderInstallState(installed) {
installBtn.hidden = installed;
statusPill.hidden = !installed;
statusPill.classList.remove('is-system');
statusPill.textContent = '✓ In your stack';
statusPill.removeAttribute('title');
removeBtn.hidden = !installed;
}
renderInstallState(!!d.installed);
// v32+ quarantine: when the entity is non-approved (only owner +
// admin land here in that state — gated server-side), disable the
// install button with a gray inert style + tooltip. The API also
// refuses POST /install with `entity_not_approved` so a clever user
// who toggles the disabled attribute in devtools still hits a 409.
if (d.visibility_status && d.visibility_status !== 'approved') {
installBtn.hidden = false;
statusPill.hidden = true;
removeBtn.hidden = true;
installBtn.disabled = true;
installBtn.title = 'This submission is not approved yet — install is disabled until checks pass.';
installBtn.textContent = '+ Add to my stack (unavailable while under review)';
installBtn.style.background = '#e5e7eb';
installBtn.style.color = '#6b7280';
installBtn.style.cursor = 'not-allowed';
}
// v39: system plugins are mandatory — already in the user's stack and
// cannot be removed. Render only the locked amber status pill; the
// install + remove controls stay hidden. API also refuses uninstall
// with 409 so a devtools-poke can't bypass the visual lock.
if (d.is_system) {
installBtn.hidden = true;
removeBtn.hidden = true;
statusPill.hidden = false;
statusPill.classList.add('is-system');
statusPill.textContent = '✓ Required by your org';
statusPill.title = 'Required by your organization — managed by admin';
}
// Post-add hint panel — fires only on the *transition* into 'installed'
// and only when the user hasn't permanently dismissed it. The dismiss
// flag lives in localStorage so a returning user who already understands
// the two-step model isn't pestered. Re-shown to nontechnical users
// who hit "+ Add to my stack" for the first time on a new browser/laptop.
const HINT_DISMISS_KEY = 'mp.stack-hint.dismissed.v1';
const hintEl = document.getElementById('stack-hint');
function showHint() {
if (localStorage.getItem(HINT_DISMISS_KEY) === '1') return;
hintEl.hidden = false;
}
document.getElementById('stack-hint-dismiss').addEventListener('click', () => {
localStorage.setItem(HINT_DISMISS_KEY, '1');
hintEl.hidden = true;
});
document.getElementById('stack-hint-copy').addEventListener('click', async (ev) => {
const copyBtn = ev.currentTarget;
try {
await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = copyBtn.textContent;
copyBtn.classList.add('copied');
copyBtn.textContent = 'Copied';
setTimeout(() => { copyBtn.textContent = orig; copyBtn.classList.remove('copied'); }, 1500);
} catch { /* clipboard blocked — chip text remains selectable */ }
});
// Two click handlers — one per element. Both surface the same
// /update-agnes-plugins recipe afterwards because the local Claude
// Code session needs the same refresh whether the user just added
// OR removed the plugin from the served set. Title swaps between
// "Added" / "Removed" so the reason for the recipe is unambiguous.
const hintTitle = document.getElementById('stack-hint-title');
function setHintTitle(kind) {
if (!hintTitle) return;
hintTitle.textContent = kind === 'removed'
? '✓ Removed from your stack'
: '✓ Added to your stack';
}
installBtn.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'POST' });
if (!r.ok) { alert('Add failed (' + r.status + ')'); return; }
renderInstallState(true);
setHintTitle('added');
showHint();
});
removeBtn.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'DELETE' });
if (!r.ok) { alert('Remove failed (' + r.status + ')'); return; }
renderInstallState(false);
setHintTitle('removed');
showHint();
});
// v35 owner / admin delete handlers. Two paths:
// * Archive (soft) — DELETE /api/store/entities/{id}, default body.
// Hides from browse, blocks new installs, KEEPS existing
// user_store_installs serving the bundle.
// * Hard delete (admin only) — DELETE /api/store/entities/{id}?hard=true.
// Drops the bundle from disk + removes existing installs.
// Existing users lose the plugin on next sync. Confirmation
// mentions the install count so admin doesn't nuke a popular
// plugin by accident.
function bindDelete(id, opts) {
const btn = document.getElementById(id);
if (!btn || root.dataset.source !== 'flea' || !root.dataset.entityId) return;
btn.addEventListener('click', async () => {
if (!confirm(opts.confirm)) return;
const url = `/api/store/entities/${encodeURIComponent(root.dataset.entityId)}${opts.hard ? '?hard=true' : ''}`;
const r = await fetch(url, { method: 'DELETE' });
if (!r.ok) {
alert((opts.hard ? 'Hard delete' : 'Archive') + ' failed (' + r.status + ')');
return;
}
window.location = '/marketplace?tab=flea';
});
}
bindDelete('owner-archive-btn', {
hard: false,
confirm: 'Archive this entity? It disappears from browse + nobody can install it. Existing installs keep working.',
});
bindDelete('owner-hard-delete-btn', {
hard: true,
confirm: 'HARD DELETE — this drops the bundle and removes ALL existing installs. Users who already added it will lose the plugin on next sync. Continue?',
});
// ── What it does ────────────────────────────────────────────────
// When the curator authored a rich markdown body in marketplace-metadata.json,
// the API renders + sanitizes it server-side and ships it in
// `description_long_html`. We inject as HTML. Falling back to the plain
// marketplace.json `description` preserves the historical render path for
// plugins whose curator hasn't filled the new field yet.
const lead = document.getElementById('lead-text');
if (d.description_long_html && d.description_long_html.trim()) {
lead.innerHTML = d.description_long_html;
lead.classList.add('lead-rendered');
} else if (d.description && d.description.trim()) {
lead.textContent = d.description;
} else {
document.getElementById('panel-what').hidden = true;
}
// ── Kdy to použiju (Use cases) ──────────────────────────────────
// marketplace-metadata.json :: use_cases[] — one card per entry. Hidden
// until populated; un-enriched plugins skip this section entirely.
const useCasesEl = document.getElementById('panel-use-cases-grid');
const useCasesPanel = document.getElementById('panel-use-cases');
if (useCasesEl && Array.isArray(d.use_cases) && d.use_cases.length) {
useCasesEl.innerHTML = d.use_cases.map(uc => `
<div class="use-case-card">
<h3>${esc(uc.title)}</h3>
<p>${esc(uc.description)}</p>
<pre class="use-case-prompt"><code>${esc(uc.prompt)}</code></pre>
</div>
`).join('');
useCasesPanel.hidden = false;
}
// ── Ukázka konverzace (Sample interaction) ──────────────────────
// marketplace-metadata.json :: sample_interaction — {user, assistant_html}.
// Assistant body was rendered + sanitized server-side; we inject as HTML.
const sampleEl = document.getElementById('panel-sample');
if (sampleEl && d.sample_interaction
&& d.sample_interaction.user && d.sample_interaction.assistant_html) {
document.getElementById('sample-user').textContent = d.sample_interaction.user;
document.getElementById('sample-assistant').innerHTML = d.sample_interaction.assistant_html;
sampleEl.hidden = false;
}
// ── v32: demo video (marketplace-metadata.json :: video_url) ─────────
// Detection logic lives in the shared partial so item_detail.html and
// this page stay in lockstep on YouTube nocookie / Vimeo / mp4 / link
// fallback handling.
if (d.video_url) {
document.getElementById('video-wrap').innerHTML =
buildVideoEmbed(d.video_url, esc);
document.getElementById('panel-video').hidden = false;
}
// ── v32: doc_links (marketplace-metadata.json :: doc_links[]) ────────
// The user contract: every entry in this list is a real downloadable
// document Agnes can serve (PDF / Markdown / plain text). Sync pipeline
// already dropped HTML pages, 404s, SSRF-blocked URLs, and any internal
// path with the wrong extension — what remains is one shape: clickable
// link, browser starts a download. No badges, no chips, no source
// distinction in the UI (where the file lives is a sync-time concern,
// not something the analyst clicking the link cares about).
//
// The `download` attribute is honored for same-origin URLs (which all of
// ours are — both /doc/ and /mirrored/ are Agnes endpoints). The server
// also sets Content-Disposition: attachment, so even cross-origin tools
// that ignore the attribute still trigger a download.
const docLinks = (d.docs && d.docs.length) ? d.docs : [];
if (docLinks.length) {
const list = document.getElementById('doc-link-list');
list.innerHTML = docLinks.map(doc => {
const url = doc.url || '#';
return `<li>
<a href="${esc(url)}" download>${esc(doc.name)}</a>
</li>`;
}).join('');
document.getElementById('panel-docs').hidden = false;
}
// ── Details ─────────────────────────────────────────────────────
// Render only rows that have a real value — missing/null/owner_todo
// entries get hidden so the panel stays compact.
const detailRows = [];
const slugVal = d.source === 'curated' ? d.marketplace_id : d.entity_id;
if (slugVal) {
detailRows.push(`<div class="row"><dt>Slug</dt><dd class="mono">${esc(slugVal)}</dd></div>`);
}
if (d.released_at) {
detailRows.push(`<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>`);
}
if (d.bundle_size != null) {
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
}
// Owner: render real value when present; for curated keep the
// `owner_todo` placeholder visible as a reminder to wire up curator
// metadata (intentional — flea falls through silently).
if (d.author_name && d.author_name !== 'owner_todo') {
detailRows.push(`<div class="row"><dt>Owner</dt><dd>${esc(d.author_name)}</dd></div>`);
} else if (d.source === 'curated') {
detailRows.push(`<div class="row"><dt>Owner</dt><dd><span class="todo">owner_todo</span></dd></div>`);
}
const detailsEl = document.getElementById('details-list');
if (detailRows.length) {
detailsEl.innerHTML = detailRows.join('');
} else {
document.getElementById('panel-details').hidden = true;
}
// ── Internal structure ─────────────────────────────────────────
function buildCardSection(title, items, type) {
if (!items || !items.length) return '';
// Inner cards render the marketplace-metadata cover photo when present
// (mirrored OK for external, file exists for internal); otherwise the
// initials fall through ("SK" / "AG") on the colored gradient. Same
// onerror fallback as the top-level cards so a missing file doesn't
// produce the browser's broken-image icon.
const initials = type === 'skill' ? 'SK' : 'AG';
const cards = items.map(it => {
const photoMarkup = it.cover_photo_url
? `<div class="photo"><img src="${esc(it.cover_photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent='${initials}';"></div>`
: `<div class="photo">${initials}</div>`;
return `
<a class="inner-card" data-type="${type}" href="${esc(it.detail_url || '#')}">
${photoMarkup}
<div class="body">
<span class="type-badge">${type}</span>
<div class="name">${esc(it.name)}</div>
<div class="desc">${esc(it.description || '')}</div>
</div>
</a>`;
}).join('');
return `
<div class="substruct">
<div class="head">
<h3>${title}</h3>
<span class="count">${items.length} ${type}${items.length === 1 ? '' : 's'}</span>
</div>
<div class="inner-grid">${cards}</div>
</div>`;
}
function buildTableSection(title, items, columns) {
if (!items || !items.length) return '';
const head = columns.map(c => `<th${c.width ? ' style="width:'+c.width+'px"' : ''}>${esc(c.label)}</th>`).join('');
const rows = items.map(it => columns.map(c => {
const v = it[c.key];
if (c.cls === 'cell-name') return `<td class="cell-name">${esc(v || '')}</td>`;
if (c.cls === 'cell-event' || c.cls === 'cell-type') return `<td class="${c.cls}">${esc(v || '—')}</td>`;
return `<td class="cell-desc">${esc(v || '')}</td>`;
}).join('')).map(tr => `<tr>${tr}</tr>`).join('');
return `
<div class="substruct">
<div class="head">
<h3>${title}</h3>
<span class="count">${items.length} ${title.toLowerCase()}</span>
</div>
<table>
<thead><tr>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
const hasAny = (d.skills && d.skills.length)
|| (d.agents && d.agents.length)
|| (d.commands && d.commands.length)
|| (d.hooks && d.hooks.length)
|| (d.mcps && d.mcps.length);
if (hasAny) {
document.getElementById('structure').hidden = false;
document.getElementById('struct-skills').innerHTML = buildCardSection('Skills', d.skills, 'skill');
document.getElementById('struct-agents').innerHTML = buildCardSection('Agents', d.agents, 'agent');
document.getElementById('struct-commands').innerHTML = buildTableSection('Commands', d.commands, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'description', label: 'Description' },
]);
document.getElementById('struct-hooks').innerHTML = buildTableSection('Hooks', d.hooks, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'event', label: 'Event', cls: 'cell-event', width: 180 },
{ key: 'description', label: 'Description' },
]);
document.getElementById('struct-mcps').innerHTML = buildTableSection('MCP servers', d.mcps, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'type', label: 'Type', cls: 'cell-type', width: 180 },
{ key: 'description', label: 'Description' },
]);
}
})();
</script>
{% endblock %}