agnes-the-ai-analyst/app/web/templates/marketplace_item_detail.html
minasarustamyan 302cf58ccd
feat(marketplace): telemetry v46 + flea inner parity + listing polish (#329)
* feat(telemetry): marketplace item rollup refactor (schema v46)

Replace the v42 attribution layer with prefix-split + live lookup against
marketplace_plugins / store_entities. The v42 design had a latent bug —
AttributionLookup keyed on bare skill names while Claude Code writes
`<plugin>:<local>` in JSONL, so lookups never matched and
usage_plugin_daily stayed empty in every deployment.

Schema (v46 migration):
- Drop usage_attribution_skills / _agents / _commands (mapping tables,
  derivable from marketplace_plugins + plugin tree).
- Drop usage_plugin_daily (always empty in production due to the bug above).
- Create usage_marketplace_item_daily — per-day fact (count, distinct_users,
  error_count), composite PK on (day, source, type, parent_plugin, name).
- Create usage_marketplace_item_window — sliding-window snapshot with
  true cross-window distinct user counts; period_label='last_7d' refreshes
  every tick, 'last_30d' refreshes hourly (tracked via session_processor_state).
- Mark usage_tool_daily as candidate for removal (no product-UI consumer).

Attribution flow:
- MarketplaceItemLookup replaces AttributionLookup. Preloads
  marketplace_plugins.name + store_entities.name into memory once per
  UsageProcessor tick, then per-event splits identifier on ':',
  matches prefix, writes resolved source / parent_plugin into
  usage_events. agnes-store-bundle prefix routes to flea entities.
  Slash commands with `plugin:` prefix count as type='skill' in rollup.

API:
- BREAKING: MarketplaceItem.unique_users_30d renamed to distinct_users_30d
  (now a true distinct count from the window snapshot, not sum-of-daily).
- InnerDetailResponse gains a telemetry field — invocations_30d +
  distinct_users_30d surfaced on curated inner skill / agent detail pages.
- Card chip hidden pending UX finalisation; data stays in the response.

Backfill: scripts/backfill_marketplace_rollup.py — one-shot rebuild over
historic usage_events after deploy, idempotent.

USAGE_PROCESSOR_VERSION bumped 4 → 5 so the reprocess loop re-attributes
existing events to the new source/ref_id semantics on the next tick.

Tests rewritten: test_session_processor_usage, test_usage_rollups,
test_marketplace_telemetry, test_api_admin_usage_reprocess,
test_db_schema_version, test_home_stats, test_schema_v42_migration.
New: test_backfill_marketplace_rollup.

* fix(marketplace): refresh Most Popular on search + category changes

`loadMostPopular()` early-exits when `state.q` or `state.category` is
set, but the search + category handlers only called `loadItems()` —
so once the section was visible, typing a query or filtering by
category didn't re-run the hide check and the cards stayed on screen
out of scope. Tab + sort handlers already chained the call.

Add the call to runSearch + category pill click handlers (All +
per-category) so the visibility contract holds for every state
mutation that can flip the early-exit condition.

* feat(marketplace): All-plugins section + 7-day Most Popular

Listing layout:
- Always-visible "All plugins" / "All items" / "Your stack" section
  header (label swaps per tab) wrapped in `#mp-all-section` so its
  margin-collapse mirrors the sibling `#mp-popular-section` and the
  spacing from the filter row stays consistent in both layouts.
- Sort dropdown moved from the filter row into the All-* header,
  pinned right via `margin-left: auto`. Anchored to its section so
  the relationship between sort + grid is obvious.
- `.mp-section-header` gets `min-height: 32px` + `align-items: center`
  so the bare-text Most Popular row matches the dropdown-bearing
  All-* row.
- `.mp-section-header` margin tightened 24px → 20px on top.

Most Popular:
- Capacity reduced 8 → 4 cards.
- Now reflects a 7-day window (was 30-day). Backend surfaces
  `invocations_7d` + `distinct_users_7d` on `MarketplaceItem`
  alongside the existing 30d fields; the loader pulls a wider page
  (server still sorts by 30d) and re-sorts + filters client-side
  on `invocations_7d > 0` so the strip stays "hot right now".
- Section label updated to "Last 7 days".
- Section now renders on both `curated` and `flea` tabs (was
  curated-only). Hidden on `my` and whenever search / category
  filter is active. Refresh hooks wired into search + category
  click handlers so visibility flips immediately on state change.

Backend (`_load_invocation_stats`):
- Single SELECT pulls both `last_30d` and `last_7d` rows from
  `usage_marketplace_item_window`; the result dict carries
  invocations + distinct_users for both windows.
- Trend (recent_7 vs prior_7) kept on the daily fact table so it
  stays independent of the window snapshot's freshness.

* feat(marketplace): Most adopted sort + hide Trending when no trend data

Add a fourth sort option to the All-items dropdown — "Most adopted
(30d)", keyed on `MarketplaceItem.distinct_users_30d` (true 30d
distinct user count from `usage_marketplace_item_window`). Protects
the listing from power-user skew that `most_used` is susceptible to:
one user × 100 invokes can't beat 10 different users × 1 invoke
under adoption sort.

Hide Trending option when the response has no trend data. User
reported `sort=trending` returning an empty grid because every
plugin's `trend_pct` was None (prior-week threshold of >= 3
invocations didn't clear anywhere). Empty grids on a user-selected
sort are worse UX than just not offering the sort — surface what
works, hide what doesn't.

Backend (`app/api/marketplace.py`):
- `_apply_sort` gains a `most_adopted` branch (DESC distinct_users_30d,
  ties by name ASC).
- `sort` Literal extended.
- `ItemListResponse.available_sorts` lists the sort keys the UI
  should expose for this response. recent/most_used/most_adopted
  always; trending only when at least one item in the tab's stats
  carries a non-null trend_pct.
- `_available_sorts(stats_dicts)` helper centralises the rule —
  curated and flea branches pass one stats dict, my-tab passes both
  (option is available when either source has trend data).

Frontend (`app/web/templates/marketplace.html`):
- New `<option value="most_adopted">Most adopted (30d)</option>`
  between Most used and Trending.
- URL state allowlist extended so `?sort=most_adopted` round-trips.
- `applyAvailableSorts(available)` runs after each list fetch:
  hides options not in the response's available_sorts; if the user
  is on a now-unavailable sort, resets to 'recent' and re-fetches.
  Search-mode fan-out unions availability across the curated + flea
  responses so a hit on either side keeps the option visible.

* feat(marketplace): funnel chip on cards + deterministic Most Popular sort

Card chip — funnel telemetry between description and footer:

  [stack-icon] N installed · [user-icon] N active · [bolt-icon] N calls · ↑/↓ N%

- stack_count (new MarketplaceItem field): for curated it's COUNT(*)
  on user_plugin_optouts (post-v28 row PRESENCE = subscribed; system
  plugins are fanned out to every user via fanout_system_for_user so
  the count includes them naturally). For flea it reuses the existing
  store_entities.install_count (bumped on install/uninstall).
- distinct_users_30d (existing) — active users in the 30d window.
- invocations_30d (existing) — call volume.
- trend_pct (existing) — week-over-week, both directions: green ↑ /
  red ↓, magnitude only (sign in the arrow). Hidden when null.

Backend additions in app/api/marketplace.py:
- MarketplaceItem.stack_count field.
- _load_curated_stack_counts() — one SELECT per render, GROUP BY
  (marketplace_id, plugin_name). Wired into the curated + my-tab
  branches; flea reads install_count off the entity row directly.

Frontend (app/web/templates/marketplace.html):
- Heroicons solid 24×24 inlined (one helper per icon, all
  fill="currentColor" so per-segment colour tokens apply): rectangle-
  stack (mirrors the My Stack tab icon), user, bolt, arrow-trending-
  up/down.
- Per-segment colour: installed=amber #F59F0A (My Stack accent),
  active=green #0e9b6a, calls=orange #f97316. Text stays neutral so
  the chip still reads as metadata, the leading glyph carries the
  visual cue. Trend pill keeps the full-segment green/red colour.
- Zero state: chip hidden when stack_count == 0 AND invocations_30d
  == 0 — brand-new cards aren't visually penalised by a "0·0·0" row.
- Tooltips on every segment via title="…" so hover explains the
  number's meaning to anyone uncertain about the icon.

Most Popular section — deterministic ordering:

Previously sorted by invocations_7d DESC with no tie-breakers, so
several cards with identical 7d call counts would swap places on
refresh (JS stable sort fell back on backend order, and the backend's
own tie-breaker for `most_used` was just name ASC — six `grpn`
plugins from six test marketplaces collapse to the same name and
became indeterminate via list_with_filters' created_at order).

New cascading hierarchy (chosen primary now matches what "most
popular" really means — wide adoption, not power-user volume):

  1. distinct_users_7d DESC  ← adoption / social proof
  2. invocations_7d   DESC  ← volume at equal adoption
  3. distinct_users_30d DESC ← broader adoption fallback
  4. invocations_30d  DESC  ← broader volume fallback
  5. name              ASC  ← deterministic textual order
  6. marketplace_slug  ASC  ← splits duplicate plugin names across
                              marketplaces

Six levels guarantee any two items end at a different sort key, so
the strip is stable across refreshes.

* fix(marketplace): unify Most Popular on 30d + right-align installed chip

Most Popular section was sorting on the 7d window while its cards
rendered 30d numbers — header label promised one thing, cards showed
another. Unified everything on 30d so a card means the same data
everywhere on the page.

- Dropped the "Last 7 days" meta from the Most Popular header.
- Sort cascade now starts on distinct_users_30d, then invocations_30d,
  with 7d adoption/volume as recency-aware fallbacks before the name +
  marketplace_slug deterministic tail. Six levels guarantee identical
  sort keys never produce indeterminate order across refreshes.
- Filter switched from invocations_7d > 0 to invocations_30d > 0 to
  match the new horizon.
- Most Popular now only renders on page 1 of the listing. Past initial
  discovery, a top-of-list popularity strip on page 2+ would shadow the
  results the user paged into. Pager click handler refreshes the
  section so navigating back to page 1 re-mounts it.

Chip layout — split engagement vs adoption visually:

  [user] N active · [bolt] N calls · [↑/↓] N%        [stack] N installed
  └────────── LEFT (time-bounded engagement) ────┘   └── RIGHT (all-time) ──┘

- Installed (stack_count) is all-time, decremented on uninstall. Alone
  it says little ("12 people installed it") without the engagement
  context next to it ("…but did anyone actually use it?"). Visually
  separating the two groups makes that distinction obvious — left
  group answers "is it used", right answers "does anyone have it".
- Implemented via flex with margin-left:auto on .seg-installed so
  installed drifts to the trailing edge.
- Installed tooltip now reads "Currently installed by N users" — the
  count is a real-time net (uninstall drops it), and saying "currently"
  makes that explicit. Helps when a card shows 0: signals "nobody has
  this in their stack right now", not "data missing".

* feat(plugin-detail): telemetry chip in hero, derived rows in sidebar

Surface the same telemetry funnel the listing card carries on the
curated plugin detail page, so clicking through from /marketplace
keeps a single mental model — figures match, semantics match. The
detail sidebar drops the two raw numbers that used to live there
(Invocations 30d / Users 30d — duplicated by the chip now) and
replaces them with two *derived* signals only the daily series can
provide: Active days + Last used.

Backend (app/api/marketplace.py):
- PluginDetailResponse.stack_count — curated reads via
  _load_curated_stack_counts(), flea reuses install_count. Frontend
  treats both sources uniformly.
- _build_telemetry() always returns a dict (never None). Frontend
  decides chip visibility from stack_count + invocations_30d the
  same way the listing card does. daily_series is always 30 entries
  (zero-padded) so "Active days" and "Last used" derivations on the
  sidebar are trivial array filters.

Frontend (app/web/templates/marketplace_plugin_detail.html):
- New .hero-telemetry slot at the bottom of the hero meta column,
  between the pills row and the action buttons. Renders the four
  funnel segments — active · calls · trend · installed — joined by
  ` · `. No left/right split: the hero has space, so a single
  coherent metadata strip reads cleaner than the card's split layout.
- Heroicons solid inlined (user / bolt / arrow-trending-up,-down /
  rectangle-stack) recoloured against the dark hero — icons in
  lighter tokens (mint #6ee7b7, peach #fdba74, cream #fde68a), trend
  pill keeps the saturated green/red because direction-coding earns
  its own colour.
- Tooltip on installed reads "Currently installed by N users" — the
  count is a real-time net (drops on uninstall), and "currently"
  makes that explicit when a card shows 0.
- fmtNum helper added so 1.2k / 14M renderings match the card's
  format exactly.
- Sidebar swap: Invocations + Users rows removed, replaced by
    Active days  →  "N of 30"
    Last used    →  fmtRelative of the latest non-zero day
  Both derived from telemetry.daily_series — engagement consistency
  + recency, neither of which the hero chip exposes on its own.

* feat(item-detail): telemetry chip in hero for curated skill/agent

Bring the funnel chip the plugin detail page got in 4cf38d40 to the
curated inner skill/agent detail page — clicking through from the
listing card now keeps the same metadata strip from grid to plugin
page to inner item page.

Backend (app/api/marketplace.py):
- _load_inner_item_stats() rewritten:
    * always returns a dict (never None) so the frontend can decide
      chip visibility client-side, same contract as _build_telemetry
    * adds trend_pct, computed the same way as plugin level
      (recent_7 vs prior_7 from usage_marketplace_item_daily, ≥3
      prior-week threshold)
    * adds daily_series (30 entries, zero-padded) so the sidebar can
      derive Active days + Last used
- InnerDetailResponse.parent_stack_count — new field. Skills/agents
  don't have a per-item subscription model, so the hero shows the
  *parent plugin's* stack count under a "Plugin:" prefix. The
  funnel: "12 installed plugin → 2 actually use this skill".
- curated_skill_detail + curated_agent_detail handlers load
  _load_curated_stack_counts() once and pass the parent's value.

Frontend (app/web/templates/marketplace_item_detail.html):
- New .item-detail .hero .hero-telemetry slot beneath the badges
  row. CSS mirrors plugin-detail's colour tokens (mint/peach/cream
  Heroicons solid + saturated trend pill) so the two surfaces read
  as one visual family.
- Installed segment uses a "Plugin:" label rendered with reduced
  opacity to signal the metric describes the parent, not the item
  itself. Tooltip: "Parent plugin (<plugin_name>) currently
  installed by N users".
- Sidebar Invocations + Users rows removed (chip carries them).
  Active days + Last used derived from telemetry.daily_series replace
  them; only rendered when activeDays > 0 so a brand-new skill
  doesn't show "0 of 30" / "Last used —".
- "Type" row dropped from the sidebar — duplicates the hero badge.
- fmtNum helper added (matches listing card + plugin detail).

Plugin detail (app/web/templates/marketplace_plugin_detail.html):
- Hero "Curator: …" line removed. The Details sidebar already
  carries that info; duplicating it under the h1 was visual noise.
- Sidebar "Owner" row renamed to "Curator" — for curated plugins
  it's a person who curates inclusion in this Agnes instance, not
  the upstream code owner. "Owner" was a hold-over label.

* feat(item-detail): unify hero with plugin detail — pills + breadcrumb + cleaner sidebar

- Inner skill/agent hero now uses the same `.pills` / `.pill.cat / .curated /
  .flea / .muted` class names + CSS as the plugin detail page; the only
  item-only addition is `.pill.type` (Skill / Agent uppercase, plugin detail
  has no kind axis).
- Hero `Updated` moved out of the meta-row into a muted pill (mirrors the
  plugin detail hero), removed from the Details sidebar to avoid duplication.
- Details sidebar slimmed: dropped Marketplace, Path, Updated rows; Parent
  plugin now shows the curator-friendly display name
  (`parent_display_name || manifest_name || slug`) instead of the slug.
- Breadcrumb extended to full path: Marketplace > <marketplace_name> >
  <plugin display name> > <self>, mirroring the plugin detail breadcrumb.
- Backend: new `InnerDetailResponse.parent_display_name` field, populated via
  `_curated_plugin_enrichment` from marketplace-metadata.json — same source
  plugin detail hero already uses.

* feat(marketplace): flea inner skill/agent detail + breadcrumb polish

- Flea inner skill/agent detail page parity with curated:
  * GET /api/marketplace/flea/{id}/skill/{name} + /agent/{name}
    returning InnerDetailResponse (mirror of curated_skill_detail).
  * /marketplace/flea/{id}/skill|agent/{name} web routes that render
    marketplace_item_detail.html with source='flea' + innerName context.
  * Frontend apiURL grows a third branch for flea-inner; breadcrumb
    grows to 4 segments (Marketplace > Flea Market > <plugin display
    name> > <self>) when innerName is set.
  * Telemetry attribution: MarketplaceItemLookup resolves
    <flea_plugin>:<inner> prefixes to (source='flea',
    parent_plugin=<plugin name>) so nested invocations land in the
    same rollups curated nested skills use. USAGE_PROCESSOR_VERSION
    bumped 5 -> 6 so the reprocess loop re-attributes historic events.
- Breadcrumb 2nd segment is now a generic clickable "Curated
  Marketplace" / "Flea Market" link to /marketplace?tab=... instead
  of the opaque per-instance marketplace_name. Applied on both plugin
  detail and inner item detail.
- Inner item hero telemetry chip works for both sources: installedCount
  branches on parent_stack_count (curated) vs install_count (flea),
  installed segment drops the "Plugin:" prefix for flea standalone /
  inner items.
- Updated row dropped from Details sidebar on item detail — the hero
  pill already carries the value, sidebar row was duplicate.

* feat(item-detail): block stack-install on flea inner items (mirror curated)

Inner skills/agents nested inside a flea plugin can no longer be added
to a user's stack on their own — adoption only happens at the plugin
level, same rule curated nested items have followed since launch.

- Hero action: when innerName is set (curated nested OR flea nested),
  render "Open parent plugin →" link + helper text instead of the
  install/remove buttons. Flea standalone entities (no innerName) keep
  the normal install UX.
- Meta-row: same branch now serves curated + flea inner — "part of
  <parent plugin display name> · by <author>" with the parent link
  pointing at the right detail page per source.

No API gate change needed: POST /api/store/entities/{id}/install only
accepts existing entity ids (plugin-level), inner items have no entity
id of their own so the endpoint cannot target them directly.

* feat(marketplace): telemetry chip on inner cards + fix flea hero chip visibility

Inner skill/agent cards on the plugin detail page now carry the same
four-segment funnel chip the marketplace listing cards show (N active
. N calls . trend . N installed), for both curated nested skills and
flea nested skills. Plus two fixes that were keeping the hero chip
hidden on flea plugin / flea inner detail pages.

- Backend `_load_inner_items_stats_by_parent(conn, source, parent_plugin)`
  bulk loader: one query per plugin against usage_marketplace_item_window
  + one against _daily, returning {(name, type): stats}. Avoids N+1
  per-card lookups.
- `InnerItemSummary` gains invocations_30d / distinct_users_30d /
  trend_pct / parent_stack_count fields. `curated_detail` and
  `flea_detail` (in the entity.type=='plugin' branch) enrich the
  skills / agents lists after the existing cover-photo enrichment loop.
- `marketplace_plugin_detail.html`: new `.plugin-detail .inner-card
  .inv-chip*` CSS lifted from marketplace.html with the listing-card
  rules, new buildInnerCardChip() helper, buildCardSection appends
  the chip to each card body. Same gate as the listing card (hidden
  on parent_stack==0 && calls==0).

- fix(flea): flea_detail forgot to populate PluginDetailResponse.stack_count
  from entity.install_count (listing card does this on line 851; detail
  endpoint didn't). Hero chip gate `stackCount===0 && calls===0` then
  always hid the chip even when the entity had installs. Now mirrors
  listing card semantics: stack_count == install_count for flea.
- fix(flea inner): renderInnerHeroTelemetry was reading `d.install_count`
  for any non-curated source. InnerDetailResponse has no install_count
  field — it has parent_stack_count (populated server-side from the
  parent flea plugin's install_count). Gate + label now read
  parent_stack_count for both curated nested AND flea nested scenarios;
  install_count remains the flea standalone path.

* fix(marketplace): Owner label on flea + parent-centric sidebar for flea inner

- Plugin detail Details sidebar — authorship row label now tracks the
  source: curated bundles get `Curator` (existing behaviour), flea
  bundles get `Owner`. The `owner_todo` reminder placeholder stays on
  the curated branch only; flea falls through silently.
- Inner item detail Details sidebar — flea-inner (skill/agent nested
  inside a flea plugin) now shares the curated nested layout: Parent
  plugin / Bundle size / Active days / Last used / Owner. Drops the
  flea-standalone shape's `Category`, `Version`, `Installs`, `Released`
  rows that didn't apply to a nested item. Active days + Last used were
  already wired (telemetryRows) — they just weren't on the flea-inner
  branch.

* fix(tests): bump SCHEMA_VERSION assertions 47 -> 48 post-rebase

The marketplace telemetry migration was renamed _v46_to_v47 -> _v47_to_v48
during the rebase onto main (collision with #326 FTS BM25 migration that
took the v47 slot). Two test files still asserted the pre-rebase value:

- tests/test_home_stats.py::test_schema_version_constant_is_46 (CI red)
- tests/test_schema_v46_migration.py::test_schema_version_is_46

Renames the helper fn name + bumps the assertion. The other two test
files (test_db_schema_version.py, test_schema_v42_migration.py) were
already updated in the rebase resolution.

* fix(telemetry): _build_telemetry returns None when invocations_30d == 0

The follow-up commit that introduced the always-return-dict shape broke
the test contract from the original v46 PR (commit b603e998):

  tests/test_marketplace_telemetry.py::TestDetailTelemetry::
    test_detail_endpoint_telemetry_absent_when_no_data
    AssertionError: assert {'daily_series': [...], ...} is None

Both `PluginDetailResponse.telemetry` and `InnerDetailResponse.telemetry`
are declared `Optional[Dict] = None`, the frontend renders are None-safe
(`d.telemetry || {}` guard + `if (!d.telemetry || ...)` on daily_series),
so dropping the dict on zero activity is the cleaner default.

* release: 0.54.21 — marketplace telemetry refactor (schema v48) + flea inner detail parity + listing UX polish

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-15 20:58:03 +02:00

1534 lines
69 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 %}{{ item_name or inner_name or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.item-detail {
--bg: var(--background);
--warn-color: #b45309;
}
/* Per-kind accents — driven from data-kind. `--kind-from` / `--kind-to`
are the gradient endpoints for the dark hero, mirroring how
plugin_detail uses #0073D1 → #0056A3. The lighter shade leads
(top-left), darker trails (bottom-right). */
.item-detail[data-kind="skill"] {
--kind: #0e9b6a;
--kind-from: #10b77f;
--kind-to: #047857;
--kind-bg: rgba(16, 183, 127, 0.14);
--kind-shadow: rgba(16, 183, 127, 0.22);
}
.item-detail[data-kind="agent"] {
--kind: #6d28d9;
--kind-from: #7c3aed;
--kind-to: #5b21b6;
--kind-bg: rgba(124, 58, 237, 0.14);
--kind-shadow: rgba(124, 58, 237, 0.22);
}
/* ── Hero — full kind-coloured gradient (parity with plugin detail) ─ */
.item-detail .hero {
position: relative;
background: linear-gradient(135deg, var(--kind-from) 0%, var(--kind-to) 100%);
border-radius: 14px;
padding: 22px 28px 28px;
margin-bottom: 24px;
box-shadow: 0 4px 16px var(--kind-shadow);
color: #fff;
}
/* Breadcrumbs — moved INSIDE hero, white-tinted (parity with plugin detail). */
.item-detail .hero .crumbs {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
font-size: 12px; color: rgba(255,255,255,0.78);
margin-bottom: 18px;
}
.item-detail .hero .crumbs a { color: #fff; opacity: 0.92; text-decoration: none; }
.item-detail .hero .crumbs a:hover { text-decoration: underline; }
.item-detail .hero .crumbs .sep { opacity: 0.5; }
.item-detail .hero .crumbs .current { color: #fff; font-weight: var(--font-medium); }
.item-detail .hero .head {
display: grid;
grid-template-columns: 380px minmax(0, 1fr) 300px;
gap: 24px;
align-items: start;
}
@media (max-width: 1100px) {
.item-detail .hero .head { grid-template-columns: 380px minmax(0, 1fr); }
}
@media (max-width: 720px) {
.item-detail .hero .head { grid-template-columns: 1fr; }
}
/* Hero window — macOS-style frame around the skill/agent cover photo.
Mirrors the plugin-detail hero window so the curated/flea detail
pages share a consistent visual language. The window's dark chrome
stays constant; only the surrounding hero gradient changes per kind
(skill=green, agent=purple). The body has aspect-ratio 715/310 so
curator-uploaded covers never crop to a square. */
.item-detail .hero .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;
}
.item-detail .hero .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);
}
.item-detail .hero .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;
}
.item-detail .hero .hwdot.red { background: #ff5f56; }
.item-detail .hero .hwdot.yellow { background: #ffbd2e; }
.item-detail .hero .hwdot.green { background: #27c93f; }
.item-detail .hero .hero-window-label {
margin: 0 auto;
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%;
}
.item-detail .hero .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: var(--font-bold);
letter-spacing: 1px;
}
.item-detail .hero .hero-window-body img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.item-detail .hero .meta {
min-width: 0;
}
/* Pills — name + CSS structure copied from marketplace_plugin_detail.html
so plugin / skill / agent hero badges are visually identical except
for the per-item variants. Item-only addition: `.pill.type` (Skill /
Agent uppercase label) — plugin detail has no kind axis. */
.item-detail .hero .pills {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
margin-top: 12px;
}
.item-detail .pill {
background: rgba(255,255,255,0.16); color: #fff;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 500;
}
.item-detail .pill.cat { background: rgba(255,255,255,0.22); }
.item-detail .pill.ver { font-family: var(--font-mono); }
.item-detail .pill.curated { background: #FEF3C7; color: #B45309; font-weight: 600; }
.item-detail .pill.flea { background: #EDE9FE; color: #6D28D9; font-weight: 600; }
.item-detail .pill.muted { background: transparent; color: rgba(255,255,255,0.72); padding-left: 0; }
.item-detail .pill.type {
background: rgba(255,255,255,0.22); color: #fff;
font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
}
/* Hero telemetry chip — same shape as the plugin detail page, with
a "Plugin:" prefix on the installed segment because skills/agents
show the *parent plugin's* stack count (no per-skill subscription
model exists). Recoloured for the kind-tinted hero background. */
.item-detail .hero .hero-telemetry {
margin-top: 12px;
font-size: 12.5px;
color: rgba(255,255,255,0.92);
line-height: 1.7;
}
.item-detail .hero .hero-telemetry > span {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.item-detail .hero .hero-telemetry svg {
width: 14px; height: 14px;
flex-shrink: 0;
}
/* Icon colors mirror the plugin detail page so the four-segment
chip reads identically across plugin + skill/agent surfaces. */
.item-detail .hero .hero-telemetry .seg-active > svg { color: #6ee7b7; }
.item-detail .hero .hero-telemetry .seg-calls > svg { color: #fdba74; }
.item-detail .hero .hero-telemetry .seg-installed > svg { color: #fde68a; }
.item-detail .hero .hero-telemetry .seg-installed .seg-installed-label {
opacity: 0.78;
font-weight: 600;
margin-right: 2px;
}
.item-detail .hero .hero-telemetry .trend-up { color: #6ee7b7; font-weight: 600; }
.item-detail .hero .hero-telemetry .trend-down { color: #fca5a5; font-weight: 600; }
.item-detail .hero h1 {
margin: 0 0 6px; font-size: 28px; font-weight: var(--font-bold);
letter-spacing: -0.4px; color: #fff;
word-wrap: break-word;
}
.item-detail .hero .meta-row {
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
font-size: 13px; color: rgba(255,255,255,0.85);
}
.item-detail .hero .meta-row strong { color: #fff; font-weight: var(--font-semibold); }
.item-detail .hero .meta-row a { color: #fff; text-decoration: none; }
.item-detail .hero .meta-row a:hover { text-decoration: underline; }
.item-detail .hero .meta-row .dot { color: rgba(255,255,255,0.4); }
/* Invocation block — flea only. Lives inside the Description panel so
"what it does" sits next to "how to call it". The chip itself matches
the .code-block convention from /setup (Catppuccin Mocha palette, mono,
green `/` prompt + Copy button) so it reads as a real terminal command. */
.item-detail .invocation-block { margin-top: 18px; }
.item-detail .invocation-label {
font-size: 12px; font-weight: var(--font-semibold);
color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.6px;
margin: 0 0 8px;
}
.item-detail .invocation {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px;
background: #1e1e2e;
border-radius: 8px;
font-family: var(--font-mono); font-size: 13px;
color: #cdd6f4;
width: 100%;
line-height: 1.5;
box-sizing: border-box;
}
.item-detail .invocation .prompt {
color: #a6e3a1;
user-select: none;
flex-shrink: 0;
font-weight: var(--font-bold);
}
.item-detail .invocation .cmd { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.item-detail .invocation .btn-copy {
appearance: none; cursor: pointer;
padding: 4px 10px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
border-radius: 6px;
font-size: 11px; font-weight: var(--font-medium);
font-family: var(--font-primary);
transition: all 0.15s ease;
flex-shrink: 0;
}
.item-detail .invocation .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.item-detail .invocation .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
background: rgba(166, 227, 161, 0.08);
}
.item-detail .hero .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) {
.item-detail .hero .actions {
position: static; width: auto; margin-top: 18px;
align-items: flex-end;
}
}
.item-detail .hero .actions .actions-row {
display: flex; flex-direction: row; align-items: center;
justify-content: flex-end; gap: 12px;
}
.item-detail .hero .actions [hidden] { display: none !important; }
.item-detail .hero .actions .btn {
appearance: none; cursor: pointer;
padding: 9px 16px; border-radius: 8px;
font-size: 12px; font-weight: var(--font-semibold); font-family: inherit;
border: 1px solid rgba(255,255,255,0.28);
background: rgba(255,255,255,0.12);
color: #fff;
text-decoration: none;
transition: all 0.15s ease;
}
.item-detail .hero .actions .btn:hover {
/* Darken instead of lightening — white text on a brighter white
background was washing out. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
.item-detail .hero .actions .btn.primary {
background: #fff; color: var(--kind); border-color: #fff;
}
.item-detail .hero .actions .btn.primary:hover {
/* Darken-glass — same formula as the secondary .btn:hover above so
primary and secondary hero actions feel consistent. The kind
gradient shows through the 20% black tint, the white border + text
supply contrast on either green (skill) or purple (agent) heroes. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
/* Status label — inline text, NOT a button. Communicates "currently
in stack" sitting before the Remove button. No border, no fill —
visual weight intentionally below the adjacent action so the
user's eye lands on the button. */
.item-detail .hero .actions .status-pill {
display: inline-flex; align-items: center;
font-size: 13px; font-weight: 500;
color: #fff;
cursor: default; user-select: none;
}
/* Remove from stack — red border by default so the destructive
intent is announced before hover; full red fill on hover commits
to it. */
.item-detail .hero .actions .btn-remove {
appearance: none; cursor: pointer;
/* Padding + font-size match .btn.primary so the install / remove
buttons render at the same height across state transitions. */
padding: 9px 16px; border-radius: 8px;
font-size: 12px; font-weight: var(--font-semibold); font-family: inherit;
background: transparent; color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.7);
transition: all 0.15s ease;
}
.item-detail .hero .actions .btn-remove:hover {
background: rgba(220, 38, 38, 0.85); color: #fff;
border-color: rgba(220, 38, 38, 0.95);
}
.item-detail .hero .actions .btn-remove:focus-visible {
outline: 2px solid rgba(254, 202, 202, 0.85); outline-offset: 2px;
}
.item-detail .hero .actions .helper {
font-size: 11px; color: rgba(255,255,255,0.78); line-height: 1.45;
text-align: right; max-width: 260px;
/* Anchor the 260px box at the right edge of the 300px .actions column
so the helper's right edge lines up with the button's right edge —
without this the parent's `align-items: stretch` puts the box flush
LEFT, and text-align: right only right-aligns within the 260px box,
which sits ~40px short of the button. */
align-self: flex-end;
}
.item-detail .hero .actions .helper strong {
color: #fff; font-weight: var(--font-semibold);
}
/* ── Post-add hint panel (flea standalone only) ──────────────────────
Mirrors the panel on marketplace_plugin_detail.html. Lives inside
the Description panel below the invocation chip so the user sees
"what it does → how to call it → what to do to make it callable"
in the natural reading order. */
/* Glass-on-gradient inside the hero — translucent white over the
kind gradient (green for skill, purple for agent) reads as
"elevated tile of the same hero". White text + dismiss outline
work on any of the three kind palettes without per-kind branching. */
.item-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);
}
.item-detail .stack-hint .head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 4px;
}
.item-detail .stack-hint .title {
font-weight: var(--font-semibold);
color: #fff;
font-size: 12px;
}
.item-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;
}
.item-detail .stack-hint .dismiss:hover {
color: #fff; background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.50);
}
.item-detail .stack-hint ol {
margin: 6px 0 0; padding-left: 20px;
color: var(--text-secondary);
}
.item-detail .stack-hint ol li { margin: 4px 0; }
.item-detail .stack-hint ol li strong { color: var(--text-primary); font-weight: var(--font-semibold); }
.item-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;
}
.item-detail .stack-hint .cmd-chip .prompt {
color: #a6e3a1; user-select: none; font-weight: var(--font-bold);
}
.item-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;
}
.item-detail .stack-hint .cmd-chip .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.item-detail .stack-hint .cmd-chip .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
}
.item-detail .stack-hint .learn-more {
display: inline-block; margin-top: 8px;
font-size: 12px; color: var(--primary); text-decoration: none;
}
.item-detail .stack-hint .learn-more:hover { text-decoration: underline; }
/* ── Top row: Description + Details ───────────────────────────────── */
.item-detail .top-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-bottom: 20px;
align-items: stretch;
}
@media (max-width: 900px) {
.item-detail .top-row { grid-template-columns: 1fr; }
}
.item-detail .panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 22px 26px;
}
.item-detail .panel h2 {
font-size: 15px; font-weight: var(--font-semibold);
margin: 0 0 14px;
text-transform: uppercase; letter-spacing: 0.6px;
color: var(--text-secondary);
}
.item-detail .panel .lead {
font-size: 14.5px; line-height: 1.65; color: var(--text-primary);
white-space: pre-wrap;
}
/* Hero tagline — single-line friendly subtitle below the h1. Same
placement contract as the plugin-detail hero tagline. */
.item-detail .hero .hero-tagline {
font-size: 14.5px; line-height: 1.5;
color: rgba(255,255,255,0.92);
margin: -2px 0 8px;
}
/* Rich-content rendering helpers (parity with marketplace_plugin_detail).
`.lead-rendered` switches the lead block from plain-text mode (which
uses `white-space: pre-wrap` so plain frontmatter descriptions retain
line breaks) to HTML mode where paragraph breaks come from <p> tags. */
.item-detail .lead-rendered { white-space: normal; }
.item-detail .lead-rendered > *:first-child { margin-top: 0; }
.item-detail .lead-rendered > *:last-child { margin-bottom: 0; }
.item-detail .lead-rendered p { margin: 0 0 12px; }
.item-detail .lead-rendered h2,
.item-detail .lead-rendered h3,
.item-detail .lead-rendered h4 {
font-size: 14.5px; font-weight: 600;
margin: 14px 0 6px;
text-transform: none; letter-spacing: 0; color: var(--text-primary);
}
.item-detail .lead-rendered ul,
.item-detail .lead-rendered ol { margin: 0 0 12px 22px; padding: 0; }
.item-detail .lead-rendered code {
background: var(--surface-alt, #f4f4f5); border-radius: 4px;
padding: 1px 5px; font-size: 0.92em;
font-family: var(--font-mono);
}
.item-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;
}
.item-detail .lead-rendered pre code {
background: transparent; padding: 0; color: inherit;
}
.item-detail .lead-rendered a { color: var(--primary); text-decoration: none; }
.item-detail .lead-rendered a:hover { text-decoration: underline; }
/* Spacing between the new rich-content panels — same vertical rhythm
as the existing .section blocks below. */
.item-detail .section-block { margin-top: 24px; }
/* Use-cases grid — 3-column on wide, 2 on tablet, 1 on phone. */
.item-detail .use-cases-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1100px) {
.item-detail .use-cases-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.item-detail .use-cases-grid { grid-template-columns: 1fr; }
}
.item-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;
}
.item-detail .use-case-card h3 {
margin: 0; font-size: 13.5px; font-weight: 600;
color: var(--text-primary); letter-spacing: 0;
text-transform: none;
}
.item-detail .use-case-card p {
margin: 0; font-size: 12.5px; line-height: 1.5;
color: var(--text-secondary);
}
.item-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 Catppuccin Mocha dark transcript.
Identical visual treatment as plugin-detail's sample block: one dark
panel, monospace user row with green `>` prompt, sans-serif assistant
body with markdown formatting. */
.item-detail .sample-interaction {
background: #1e1e2e;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
overflow: hidden;
display: flex; flex-direction: column;
}
.item-detail .sample-user,
.item-detail .sample-assistant { padding: 14px 18px; }
.item-detail .sample-user {
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.015);
}
.item-detail .sample-label {
/* visually-hidden — `>` prefix is the speaker indicator */
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
.item-detail .sample-user > div:last-child {
font-family: var(--font-mono);
color: #cdd6f4;
font-size: 13.5px; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.item-detail .sample-user > div:last-child::before {
content: "> ";
color: #a6e3a1; font-weight: 700;
user-select: none;
}
.item-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;
}
.item-detail .sample-assistant-body > *:first-child { margin-top: 0; }
.item-detail .sample-assistant-body > *:last-child { margin-bottom: 0; }
.item-detail .sample-assistant-body p { margin: 0 0 10px; }
.item-detail .sample-assistant-body strong { color: #fab387; }
.item-detail .sample-assistant-body em { color: #f9e2af; font-style: italic; }
.item-detail .sample-assistant-body code {
background: rgba(255,255,255,0.06);
color: #f5c2e7;
border-radius: 4px; padding: 1px 5px;
font-size: 0.92em; font-family: var(--font-mono);
}
.item-detail .sample-assistant-body pre {
background: #181825;
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;
}
.item-detail .sample-assistant-body pre code {
background: transparent; padding: 0; color: inherit;
}
.item-detail .sample-assistant-body ul,
.item-detail .sample-assistant-body ol { margin: 0 0 10px 22px; padding: 0; }
.item-detail .sample-assistant-body li { margin: 2px 0; }
.item-detail .sample-assistant-body a {
color: #89b4fa; text-decoration: none;
}
.item-detail .sample-assistant-body a:hover { text-decoration: underline; }
.item-detail .sample-assistant-body blockquote {
border-left: 3px solid #585b70;
margin: 8px 0; padding: 4px 0 4px 12px;
color: #bac2de;
}
.item-detail .sample-assistant-body h2,
.item-detail .sample-assistant-body h3,
.item-detail .sample-assistant-body h4 {
color: #cdd6f4;
font-size: 14px; font-weight: 600;
margin: 12px 0 6px;
text-transform: none; letter-spacing: 0;
}
.item-detail .details dl { margin: 0; }
.item-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;
}
.item-detail .details .row:last-child { border-bottom: none; }
.item-detail .details dt {
color: var(--text-secondary); margin: 0;
font-weight: var(--font-medium);
}
.item-detail .details dd {
margin: 0; color: var(--text-primary); font-weight: var(--font-medium);
text-align: right; word-break: break-word;
}
.item-detail .details dd.mono { font-family: var(--font-mono); font-size: 12px; }
.item-detail .details dd .todo { color: var(--warn-color); font-style: italic; font-weight: 400; }
/* ── Section card (Docs / Files) ─────────────────────────────────── */
.item-detail .section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
margin-bottom: 18px; overflow: hidden;
}
.item-detail .section-head {
display: flex; align-items: center; gap: 10px;
padding: 18px 24px 0;
}
.item-detail .section-head h2 {
margin: 0; font-size: 15px; font-weight: var(--font-semibold);
color: var(--text-primary);
}
.item-detail .section-head .count {
font-size: 12px; color: var(--text-secondary);
background: var(--border-light);
padding: 2px 8px; border-radius: 999px;
}
.item-detail .section-body { padding: 12px 24px 22px; }
.item-detail .file-list { font-family: var(--font-mono); font-size: 12.5px; }
.item-detail .file-list .file {
display: flex; justify-content: space-between; gap: 16px;
padding: 8px 0; border-bottom: 1px dashed var(--border-light);
color: var(--text-primary);
text-decoration: none;
}
.item-detail .file-list .file:last-child { border-bottom: none; }
.item-detail .file-list a.file:hover .name { color: var(--kind); text-decoration: underline; }
.item-detail .file-list .file .name {
display: flex; align-items: center; gap: 8px; min-width: 0;
}
.item-detail .file-list .file .name .icon {
width: 14px; height: 14px; flex-shrink: 0; color: var(--text-secondary);
}
.item-detail .file-list a.file .name .icon { color: var(--kind); }
.item-detail .file-list .file .size {
color: var(--text-secondary); flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.item-detail .empty-msg {
color: var(--text-secondary); font-size: 13px; font-style: italic;
text-align: center; padding: 32px 16px;
}
</style>
<div class="item-detail page-shell" id="root"
data-source="{{ source }}"
data-kind="{{ kind }}"
data-marketplace-id="{{ marketplace_id or '' }}"
data-plugin-name="{{ plugin_name or '' }}"
data-entity-id="{{ entity_id or '' }}"
data-inner-name="{{ inner_name or '' }}"
data-visibility="{{ entity.visibility_status if entity else 'approved' }}">
{# v32+ quarantine banner. Owner / admin only when non-approved. #}
{% include "_quarantine_banner.html" %}
{% if entity and (is_owner or is_admin) %}
<style>
.item-detail .owner-actions {
display: flex; gap: 10px; margin: 0 0 16px 0; justify-content: flex-end;
}
.item-detail .owner-actions a,
.item-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;
}
.item-detail .owner-actions .delete { color: #b91c1c; }
.item-detail .owner-actions button:disabled,
.item-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">
{% 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 %}
{# Same v35 delete UX as the plugin detail page — see comment there. #}
{% if is_admin %}
{% 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.">
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 + removes existing installs. Use only for legal / privacy removals.">
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.">
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. Edit + re-upload to fix.">
Delete (locked — quarantined)
</button>
{% endif %}
</div>
{% include "_flea_versions.html" %}
<script>
(function(){
const root = document.getElementById('root');
if (root.dataset.source !== 'flea' || !root.dataset.entityId) return;
function bindDel(id, opts){
const b = document.getElementById(id);
if (!b) return;
b.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';
});
}
bindDel('owner-archive-btn', {hard: false, confirm: 'Archive this entity? Disappears from browse; existing installs keep working.'});
bindDel('owner-hard-delete-btn', {hard: true, confirm: 'HARD DELETE — drops bundle + removes ALL existing installs. Continue?'});
})();
</script>
{% endif %}
<!-- Hero (full kind-coloured gradient, parity with plugin detail) -->
<div class="hero">
<div class="crumbs" id="crumbs">
<a href="/marketplace">Marketplace</a>
<span class="sep"></span>
<span id="crumb-loading">Loading…</span>
</div>
<div class="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">{{ inner_name or item_name or plugin_name }}</span>
</div>
<div class="hero-window-body" id="hero-window-body">{{ 'SK' if kind == 'skill' else 'AG' }}</div>
</div>
<div class="meta">
<h1 id="hero-name">{{ inner_name or item_name or plugin_name }}</h1>
<div class="hero-tagline" id="hero-tagline" hidden></div>
<div class="meta-row" id="hero-meta-row"></div>
<div class="pills" id="hero-pills">
<span class="pill type">{{ kind }}</span>
<span class="pill {{ source }}">{{ 'Curated' if source == 'curated' else 'Flea' }}</span>
</div>
<!-- Telemetry funnel chip — same four segments as the plugin
detail page (active · calls · trend · Plugin: N installed).
The installed count is the *parent plugin's* stack count
with a "Plugin:" prefix because skills/agents don't have a
per-item subscription model; tooltip spells it out. JS
hides the slot when the parent isn't installed AND the
item has no 30d activity. -->
<div class="hero-telemetry" id="hero-telemetry" hidden></div>
</div>
</div>
<!-- Actions absolutely anchored at the hero's top-right corner.
Action-row holds the install/remove buttons (JS writes into
#hero-actions); stack-hint stacks below as a glass card. -->
<div class="actions">
<div class="actions-row" id="hero-actions"></div>
<div class="helper" id="hero-helper" hidden></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">
<h2>Description</h2>
<div class="lead" id="description-body">Loading…</div>
<div class="invocation-block" id="invocation-block" hidden>
<h3 class="invocation-label">How to call it</h3>
<div class="invocation" id="invocation" title="Run in Claude Code">
<span class="prompt">/</span>
<span class="cmd" id="invocation-cmd"></span>
<button class="btn-copy" id="invocation-copy" type="button">Copy</button>
</div>
</div>
</div>
<aside class="panel details">
<h2>Details</h2>
<dl id="details-list"></dl>
</aside>
</div>
<!-- Use cases — populated from marketplace-metadata.json use_cases[].
Hidden until the curator has supplied at least one card. -->
<div class="panel section-block" 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 — Claude Code-style dark transcript panel. -->
<div class="panel section-block" 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>
<!-- When-to-use disambiguation: rendered markdown ("Use X for Y, see Z
for the alternative…"). Hidden until populated. -->
<div class="panel section-block" id="panel-when-to-use" hidden>
<h2>When to use this</h2>
<div class="lead" id="when-to-use-body"></div>
</div>
<div class="section" id="docs-section" hidden>
<div class="section-head">
<h2>Documentation</h2>
<span class="count" id="docs-count">0</span>
</div>
<div class="section-body">
{# v32: plain link list matching the plugin detail page. No file
icons, no kind chips — every entry is downloadable PDF / Markdown /
plain text by contract (sync drops anything else). #}
<ul class="doc-link-list" id="docs-list" style="list-style:none;padding:0;margin:0;display:grid;gap:8px;"></ul>
</div>
</div>
<div class="section" id="files-section" hidden>
<div class="section-head">
<h2>Files</h2>
<span class="count" id="files-count">0</span>
</div>
<div class="section-body">
<div class="file-list" id="files-list"></div>
</div>
</div>
<div id="error-msg" class="empty-msg" hidden></div>
</div>
<script>
'use strict';
(async function () {
{% include "_marketplace_video_embed.html" %}
const root = document.getElementById('root');
const source = root.dataset.source; // 'curated' | 'flea'
const kind = root.dataset.kind; // 'skill' | 'agent'
const marketplaceId = root.dataset.marketplaceId;
const pluginName = root.dataset.pluginName;
const entityId = root.dataset.entityId;
const innerName = root.dataset.innerName;
// Curated nested → /api/marketplace/curated/{mp}/{plugin}/{kind}/{name}
// Flea inner → /api/marketplace/flea/{id}/{kind}/{name} (skill/agent inside a flea plugin)
// Flea standalone → /api/marketplace/flea/{id}/detail
const apiURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}/${kind}/${encodeURIComponent(innerName)}`
: (innerName
? `/api/marketplace/flea/${encodeURIComponent(entityId)}/${kind}/${encodeURIComponent(innerName)}`
: `/api/marketplace/flea/${encodeURIComponent(entityId)}/detail`);
const ICON_DOC = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
const ICON_DIR = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CODE = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
const CODE_EXTS = new Set(['.py', '.js', '.ts', '.sh', '.bash', '.zsh', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp']);
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(2) + ' KB';
if (n < 1024*1024*1024) return (n/(1024*1024)).toFixed(2) + ' MB';
return (n/(1024*1024*1024)).toFixed(2) + ' GB';
}
// Short integer formatter — matches the listing card + plugin detail
// so figures look identical across the three surfaces.
function fmtNum(n) {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
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 iconFor(path) {
const lower = path.toLowerCase();
if (lower.endsWith('/')) return ICON_DIR;
const dot = lower.lastIndexOf('.');
const ext = dot >= 0 ? lower.slice(dot) : '';
if (CODE_EXTS.has(ext)) return ICON_CODE;
return ICON_DOC;
}
function fileRow(path, size, opts) {
const tag = opts && opts.href ? 'a' : 'div';
const href = opts && opts.href ? ` href="${esc(opts.href)}" target="_blank" rel="noopener"` : '';
const sizeStr = size == null ? '—' : fmtBytes(size);
return `<${tag} class="file"${href}><span class="name">${iconFor(path)}${esc(path)}</span><span class="size">${sizeStr}</span></${tag}>`;
}
function showError(status) {
document.getElementById('description-body').textContent = '';
const err = document.getElementById('error-msg');
if (status === 403) err.textContent = 'You do not have access to this plugin. Ask your admin to grant your group access.';
else if (status === 404) err.textContent = 'Not found.';
else err.textContent = 'Failed to load (' + status + ').';
err.hidden = false;
}
let res;
try { res = await fetch(apiURL); }
catch { showError(0); return; }
if (!res.ok) { showError(res.status); return; }
const d = await res.json();
// ── Title resolution per source ─────────────────────────────────────
// Curated: marketplace-metadata.json `display_name` wins, else frontmatter `name`.
// Flea standalone skill/agent reuses PluginDetailResponse — `display_name`
// populated by the same on-demand parser path, otherwise `plugin_name`
// (the entity name; manifest_name is the suffixed `<name>-by-<username>`).
const heroTitle = d.display_name
|| (source === 'curated' ? (d.name || innerName) : (d.plugin_name || ''));
document.title = `${heroTitle} — Marketplace`;
// ── Breadcrumbs ────────────────────────────────────────────────────
// Mirror plugin detail shape: Marketplace <Curated Marketplace /
// Flea Market> <parent plugin display name> <self>. The second
// segment is a generic clickable category label (links to the relevant
// marketplace tab), not the per-instance marketplace_name — analysts
// were finding the per-instance name opaque. Parent plugin segment
// (curated only) links to the plugin detail page so users can climb
// one level. Flea items have no parent plugin, so their path is
// Marketplace Flea Market <self>.
const crumbs = document.getElementById('crumbs');
const parentPluginLabel = d.parent_display_name || d.manifest_name || pluginName;
if (source === 'curated') {
crumbs.innerHTML =
`<a href="/marketplace?tab=curated">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=curated">Curated Marketplace</a>
<span class="sep"></span>
<a href="/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}">${esc(parentPluginLabel)}</a>
<span class="sep"></span>
<span class="current">${esc(heroTitle)}</span>`;
} else if (innerName) {
// Flea inner skill/agent — 4-segment path mirroring curated inner:
// Marketplace > Flea Market > <plugin display name> > <self>
crumbs.innerHTML =
`<a href="/marketplace?tab=flea">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=flea">Flea Market</a>
<span class="sep"></span>
<a href="/marketplace/flea/${esc(entityId)}">${esc(d.parent_display_name || d.manifest_name || pluginName)}</a>
<span class="sep"></span>
<span class="current">${esc(heroTitle)}</span>`;
} else {
crumbs.innerHTML =
`<a href="/marketplace?tab=flea">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=flea">Flea Market</a>
<span class="sep"></span>
<span class="current">${esc(d.manifest_name || heroTitle)}</span>`;
}
// ── Hero name ──────────────────────────────────────────────────────
document.getElementById('hero-name').textContent = heroTitle;
// Hero window — macOS-style frame around the cover. The titlebar label
// carries the skill/agent name (heroTitle); the body holds the cover
// photo (or a placeholder gradient + 'SK'/'AG' initials when no cover).
const labelEl = document.getElementById('hero-window-label');
if (labelEl) labelEl.textContent = heroTitle;
// Cover photo — flea may have one; curated v32 may also have one when
// marketplace-metadata.json sub-tree references a skill/agent cover via
// either an internal path (resolved to /asset/) or an external URL.
if (d.cover_photo_url) {
const bodyEl = document.getElementById('hero-window-body');
// Initials fallback already lives inside #hero-window-body (rendered by
// the server-side template as 'SK' / 'AG'). Capture before swapping so
// a 404 on the cover restores the original glyph.
const initials = bodyEl.textContent || (kind === 'skill' ? 'SK' : 'AG');
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)}">`;
}
// Pills — same structure / class names as plugin detail hero so the two
// surfaces share `.pill.cat / .curated / .flea / .muted` CSS. Item-only
// addition: `.pill.type` (Skill / Agent uppercase, no parallel on plugin
// detail which has no kind axis).
const pills = document.getElementById('hero-pills');
const sourceBadge = source === 'curated'
? '<span class="pill curated">Curated</span>'
: '<span class="pill flea">Flea</span>';
const cat = d.category ? `<span class="pill cat">${esc(d.category)}</span>` : '';
const updatedAt = source === 'curated' ? d.parent_updated_at : d.updated_at;
const updated = updatedAt
? `<span class="pill muted">Updated ${esc(fmtRelative(updatedAt))}</span>`
: '';
pills.innerHTML = `<span class="pill type">${esc(kind)}</span>${sourceBadge}${cat}${updated}`;
// Invocation block — lives inside the Description panel so the "how to
// call it" cue sits right under "what it does". Flea entities ship as
// their own plugin (or the agnes-store-bundle), so the manifest_name IS
// the slash invocation. Curated skills/agents live inside a parent
// plugin, so Claude Code namespaces them as /<plugin>:<inner-name>.
const invBlock = document.getElementById('invocation-block');
// Invocation resolution:
// 1. marketplace-metadata.json `invocation` (curator-provided literal —
// may include args like "/my-plugin:tool <your question>",
// and may start with @ for agent invocations). Used verbatim.
// 2. Computed `<manifest_name>:<inner_name>` (legacy behaviour) —
// prefixed with "/" at display + copy time.
let invokeText = null; // what the chip displays (after the "/" prompt)
let invokeCopyText = null; // what gets put in the clipboard on Copy
if (d.invocation) {
// Curator override. Hide the hardcoded "/" prompt so the curator's
// string is shown literally (covering both / and @ forms cleanly).
const promptEl = document.querySelector('#invocation .prompt');
if (promptEl) promptEl.hidden = true;
invokeText = d.invocation;
invokeCopyText = d.invocation;
} else if (source === 'flea' && d.manifest_name) {
invokeText = d.manifest_name;
invokeCopyText = '/' + d.manifest_name;
} else if (source === 'curated' && d.manifest_name && (d.name || innerName)) {
invokeText = `${d.manifest_name}:${d.name || innerName}`;
invokeCopyText = '/' + invokeText;
}
if (invokeText) {
document.getElementById('invocation-cmd').textContent = invokeText;
invBlock.hidden = false;
const copyBtn = document.getElementById('invocation-copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(invokeCopyText);
const orig = copyBtn.textContent;
copyBtn.classList.add('copied');
copyBtn.textContent = 'Copied';
setTimeout(() => {
copyBtn.textContent = orig;
copyBtn.classList.remove('copied');
}, 1500);
} catch {
/* clipboard blocked — leave the chip selectable for manual copy */
}
});
}
// Meta-row.
// * Curated inner (always nested under a parent plugin) → "part of …"
// * Flea inner (skill/agent nested in a flea plugin) → same layout,
// parent link points at the flea plugin detail.
// * Flea standalone (entity is itself the skill/agent) → "by author ·
// N installed · size".
const metaRow = document.getElementById('hero-meta-row');
if (source === 'curated' || innerName) {
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
? `<strong>${esc(d.parent_author_name)}</strong>`
: `<strong style="font-style:italic; color:var(--warn-color);">owner_todo</strong>`;
const parentHref = source === 'curated'
? `/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}`
: `/marketplace/flea/${esc(entityId)}`;
const parentLabel = d.parent_display_name || d.manifest_name || pluginName;
metaRow.innerHTML =
`<span>part of <a href="${parentHref}"><strong>${esc(parentLabel)}</strong></a></span>
<span class="dot">·</span>
<span>by ${author}</span>`;
} else {
const author = d.owner_display || d.author_name || '';
metaRow.innerHTML =
`<span>by <strong>${esc(author)}</strong></span>
<span class="dot">·</span>
<span>${d.install_count || 0} installed</span>
${d.bundle_size != null ? `<span class="dot">·</span><span>${esc(fmtBytes(d.bundle_size))}</span>` : ''}`;
}
// Hero action — Curated nested redirects to parent plugin (no install at
// this level — Q5: install via the plugin). Flea standalone is directly
// installable through the existing /api/store/.../install endpoint.
const actions = document.getElementById('hero-actions');
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;
}
function setHintTitle(kind) {
const t = document.getElementById('stack-hint-title');
if (!t) return;
t.textContent = kind === 'removed'
? '✓ Removed from your stack'
: '✓ Added to your stack';
}
function renderInstallActionsHtml(installed) {
if (installed) {
return `
<span class="status-pill" id="status-pill">✓ In your stack</span>
<button class="btn-remove" id="remove-btn" type="button">✕ Remove from stack</button>
`;
}
return `<button class="btn primary" id="install-btn" type="button">+ Add to my stack</button>`;
}
// Wires whichever of {install-btn, remove-btn} is currently in the
// actions area. Both handlers re-render + re-bind so a returning
// user can flip back and forth without a page reload. The hint
// recipe is identical on add and remove (Claude Code needs the same
// /update-agnes-plugins refresh either way) — only the title swaps.
function bindInstallActions() {
const installURL = `/api/store/entities/${encodeURIComponent(entityId)}/install`;
const installBtnEl = document.getElementById('install-btn');
if (installBtnEl) {
installBtnEl.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'POST' });
if (!r.ok) { alert('Add failed (' + r.status + ')'); return; }
actions.innerHTML = renderInstallActionsHtml(true);
bindInstallActions();
setHintTitle('added');
showHint();
});
}
const removeBtnEl = document.getElementById('remove-btn');
if (removeBtnEl) {
removeBtnEl.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'DELETE' });
if (!r.ok) { alert('Remove failed (' + r.status + ')'); return; }
actions.innerHTML = renderInstallActionsHtml(false);
bindInstallActions();
setHintTitle('removed');
showHint();
});
}
}
// Inner skills/agents (curated nested OR flea nested) can't be added to
// a user's stack on their own — they live inside a parent plugin bundle
// and adoption only happens at the plugin level. Render "Open parent
// plugin →" + helper text instead of the install/remove buttons. Flea
// standalone entities (else branch below) keep the normal install UX.
if (source === 'curated' || innerName) {
const parentHref = source === 'curated'
? `/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}`
: `/marketplace/flea/${esc(entityId)}`;
const parentLabel = d.parent_display_name || d.manifest_name || pluginName;
// Button alone in the actions-row (the row is flex-direction: row;
// putting the helper inside it would lay them side-by-side). The
// helper sits in #hero-helper, a sibling under the actions-row, so
// the outer .actions flex-column stacks them: button → helper.
actions.innerHTML = `<a class="btn primary" href="${parentHref}">Open parent plugin →</a>`;
const helperEl = document.getElementById('hero-helper');
helperEl.innerHTML = `
This ${esc(kind === 'skill' ? 'skill' : 'agent')} is part of <strong>${esc(parentLabel)}</strong>.<br>
Add the bundle to your stack to use it.`;
helperEl.hidden = false;
} else {
// v32+ quarantine: when the entity is non-approved (only owner +
// admin land here — server-side gate 404s anyone else), render
// the install button gray + disabled with explanatory tooltip.
// The API also refuses POST /install with 409 entity_not_approved
// so a clever user toggling `disabled` in devtools still hits the
// gate. Skip listener wiring below for inert buttons.
const isQuarantined = d.visibility_status && d.visibility_status !== 'approved';
if (isQuarantined) {
const stateLabel = d.visibility_status === 'archived' ? 'archived' : 'under review';
actions.innerHTML = `<button class="btn primary" type="button" disabled
title="This submission is not approved yet — install is disabled until checks pass."
style="background:#e5e7eb;color:#6b7280;cursor:not-allowed;border:1px solid #d1d5db;">
+ Add to my stack (unavailable while ${stateLabel})
</button>`;
} else {
// Two-element installed state — inline status label BEFORE the
// Remove button on the same row. Default state is the primary
// install CTA. Re-rendered after every click since the DOM
// elements differ between states; bindInstallActions() re-wires
// listeners on whichever id is now present.
actions.innerHTML = renderInstallActionsHtml(!!d.installed);
}
const dismissBtn = document.getElementById('stack-hint-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
localStorage.setItem(HINT_DISMISS_KEY, '1');
hintEl.hidden = true;
});
}
const copyBtn = document.getElementById('stack-hint-copy');
if (copyBtn) {
copyBtn.addEventListener('click', async (ev) => {
const b = ev.currentTarget;
try {
await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = b.textContent;
b.classList.add('copied');
b.textContent = 'Copied';
setTimeout(() => { b.textContent = orig; b.classList.remove('copied'); }, 1500);
} catch { /* clipboard blocked — chip text remains selectable */ }
});
}
// install-btn / remove-btn ids only exist in the non-quarantined
// branch above. bindInstallActions wires whichever is currently
// rendered; the click handlers re-render the actions area + re-bind
// so the next click hits the freshly-attached listener.
bindInstallActions();
}
// ── Hero tagline (marketplace-metadata.json :: tagline) ────────────
// Optional 1-line value prop sitting under the h1. Stays hidden when
// the curator hasn't filled it; the meta-row below still shows the
// "by author · updated date" line so the hero never collapses.
const heroTaglineEl = document.getElementById('hero-tagline');
if (heroTaglineEl && d.tagline) {
heroTaglineEl.textContent = d.tagline;
heroTaglineEl.hidden = false;
}
// ── Hero telemetry chip ───────────────────────────────────────────
// Four-segment funnel matching the plugin detail page, with one
// twist: the installed segment shows the *parent plugin's* stack
// count under a "Plugin:" label, because skills/agents inherit
// adoption from their plugin (no per-item subscription model). The
// tooltip spells out the relationship. Hidden when neither the
// parent is installed AND the item has no 30d activity.
(function renderInnerHeroTelemetry() {
const slot = document.getElementById('hero-telemetry');
if (!slot) return;
const tel = d.telemetry || {};
// Adoption source per scenario:
// * Curated inner — `parent_stack_count` (parent plugin's subscribers).
// * Flea inner — `parent_stack_count` (backend fills it with the
// parent flea plugin's install_count — see
// `flea_skill_detail` / `flea_agent_detail`).
// * Flea standalone (no innerName) — `install_count` directly on the
// entity.
const installedCount = (source === 'curated' || innerName)
? (d.parent_stack_count || 0)
: (d.install_count || 0);
const activeUsers = tel.distinct_users_30d || 0;
const calls = tel.invocations_30d || 0;
const trend = tel.trend_pct;
if (installedCount === 0 && calls === 0) { slot.hidden = true; return; }
const svg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="${path}"/></svg>`;
const ICON_USER = svg("M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z");
const ICON_BOLT = svg("M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z");
const ICON_STACK = svg("M5.566 4.657A4.505 4.505 0 0 1 6.75 4.5h10.5c.41 0 .806.055 1.183.157A3 3 0 0 0 15.75 3h-7.5a3 3 0 0 0-2.684 1.657ZM2.25 12a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3v-6ZM5.25 7.5c-.41 0-.806.055-1.184.157A3 3 0 0 1 6.75 6h10.5a3 3 0 0 1 2.683 1.657A4.505 4.505 0 0 0 18.75 7.5H5.25Z");
const ICON_TUP = svg("M15.22 6.268a.75.75 0 0 1 .968-.432l5.942 2.28a.75.75 0 0 1 .431.97l-2.28 5.94a.75.75 0 1 1-1.4-.537l1.63-4.251-1.086.483a11.2 11.2 0 0 0-5.45 5.174.75.75 0 0 1-1.199.19L9 12.31l-6.22 6.22a.75.75 0 1 1-1.06-1.06l6.75-6.75a.75.75 0 0 1 1.06 0l3.606 3.605a12.694 12.694 0 0 1 5.68-4.973l1.086-.484-4.251-1.631a.75.75 0 0 1-.432-.97Z");
const ICON_TDOWN = svg("M1.72 5.47a.75.75 0 0 1 1.06 0L9 11.69l3.756-3.756a.75.75 0 0 1 1.218.246l1.63 4.25 1.086-.483a11.2 11.2 0 0 1 5.45 5.174.75.75 0 0 1-1.199.19L17.34 13.79l-1.63 4.25a.75.75 0 0 1-1.218.246L11.07 14.97l-6.22 6.22a.75.75 0 1 1-1.06-1.06l6.75-6.75-7.81-7.811a.75.75 0 0 1 0-1.06Z");
const segs = [
`<span class="seg-active" title="${activeUsers} users invoked this ${esc(kind)} in the last 30 days">${ICON_USER} ${fmtNum(activeUsers)} active</span>`,
`<span class="seg-calls" title="${calls} invocations of this ${esc(kind)} in the last 30 days">${ICON_BOLT} ${fmtNum(calls)} calls</span>`,
];
if (trend !== null && trend !== undefined) {
const up = trend >= 0;
const cls = up ? 'trend-up' : 'trend-down';
const icon = up ? ICON_TUP : ICON_TDOWN;
segs.push(`<span class="${cls}" title="Week-over-week change in invocations of this ${esc(kind)}">${icon} ${Math.abs(Math.round(trend))}%</span>`);
}
// Installed segment differs per scenario:
// * Curated inner / flea inner: parent plugin's stack count —
// "Plugin:" prefix + tooltip make the semantic shift explicit.
// We're not showing how many people use this skill; we're
// showing how many have the parent plugin in their stack. The
// funnel insight: "12 installed plugin → 2 actually use this skill".
// * Flea standalone (no innerName): entity-level install_count.
// Plain `N installed` matches the plugin detail flea hero.
if (source === 'curated' || innerName) {
const parentPluginLabel = esc(d.parent_display_name || d.manifest_name || pluginName || 'parent plugin');
segs.push(`<span class="seg-installed" title="Parent plugin (${parentPluginLabel}) currently installed by ${installedCount} users">${ICON_STACK} <span class="seg-installed-label">Plugin:</span> ${fmtNum(installedCount)} installed</span>`);
} else {
segs.push(`<span class="seg-installed" title="Installed by ${installedCount} users">${ICON_STACK} ${fmtNum(installedCount)} installed</span>`);
}
slot.innerHTML = segs.join(' · ');
slot.hidden = false;
})();
// ── Description ────────────────────────────────────────────────────
// When the curator authored a rich markdown body in marketplace-metadata.json,
// the API renders + sanitizes it server-side and ships as
// `description_long_html`. We inject as HTML. Falling back to the
// 1-line frontmatter `description` preserves the historical render path
// for skills/agents whose curator hasn't filled the new field yet.
const descEl = document.getElementById('description-body');
if (d.description_long_html && d.description_long_html.trim()) {
descEl.innerHTML = d.description_long_html;
descEl.classList.add('lead-rendered');
} else {
descEl.textContent = d.description || '';
}
// ── Use cases (marketplace-metadata.json :: use_cases[]) ───────────
// One card per entry. Hidden until populated.
const useCasesPanel = document.getElementById('panel-use-cases');
const useCasesGrid = document.getElementById('panel-use-cases-grid');
if (useCasesPanel && useCasesGrid && Array.isArray(d.use_cases) && d.use_cases.length) {
useCasesGrid.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;
}
// ── Sample interaction (marketplace-metadata.json :: sample_interaction) ─
// Claude Code-style dark transcript. Assistant body was rendered +
// sanitized server-side; we inject as HTML.
const samplePanel = document.getElementById('panel-sample');
if (samplePanel && 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;
samplePanel.hidden = false;
}
// ── When to use this (marketplace-metadata.json :: when_to_use) ────
// Markdown disambiguation against alternative skills/agents
// ("Use this for X; for Y, see /other-skill"). Rendered server-side.
const whenPanel = document.getElementById('panel-when-to-use');
const whenBody = document.getElementById('when-to-use-body');
if (whenPanel && whenBody && d.when_to_use_html && d.when_to_use_html.trim()) {
whenBody.innerHTML = d.when_to_use_html;
whenBody.classList.add('lead-rendered');
whenPanel.hidden = false;
}
// ── Details sidebar — skip rows whose value is missing. The
// `owner_todo` placeholder for the Curator row stays as a deliberate
// reminder to wire up curator metadata.
const dl = document.getElementById('details-list');
// Activity rows for the sidebar — Invocations + Users live in the
// hero chip now, no need to duplicate. Two derived signals only the
// daily series can give us:
// * Active days — engagement consistency over the 30d window.
// * Last used — recency (answers "is this still being used?").
// Both rendered only when the item has at least one active day so a
// brand-new skill doesn't show "0 of 30" + "Last used —".
const telemetryRows = (() => {
if (!d.telemetry || !Array.isArray(d.telemetry.daily_series)) return '';
const series = d.telemetry.daily_series;
const activeDays = series.filter(p => (p.invocations || 0) > 0).length;
if (activeDays === 0) return '';
let lastIso = null;
for (let i = series.length - 1; i >= 0; i--) {
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
}
return `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
+ (lastIso
? `<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
: '');
})();
if (source === 'curated' || innerName) {
// Nested skill/agent (curated OR flea-inner): the Details sidebar
// is parent-centric — Parent plugin / Bundle size / activity rows /
// authorship label. The authorship row label tracks the source
// (curated bundles have a Curator, flea bundles have an Owner) so
// the label vocabulary stays consistent with the plugin detail page.
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
? esc(d.parent_author_name)
: '<span class="todo">owner_todo</span>';
const authorLabel = source === 'curated' ? 'Curator' : 'Owner';
// Type already shown as a badge in the hero — skip the row here.
dl.innerHTML = `
<div class="row"><dt>Parent plugin</dt><dd>${esc(d.parent_display_name || d.manifest_name || pluginName)}</dd></div>
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
${telemetryRows}
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>`;
} else {
const ownerLabel = d.owner_display || d.author_name || '';
// Type already shown as a badge in the hero — skip the row here.
dl.innerHTML = `
${ownerLabel ? `<div class="row"><dt>Owner</dt><dd>${esc(ownerLabel)}</dd></div>` : ''}
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
${d.category ? `<div class="row"><dt>Category</dt><dd>${esc(d.category)}</dd></div>` : ''}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
<div class="row"><dt>Installs</dt><dd>${d.install_count || 0}</dd></div>
${telemetryRows}
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}`;
}
// ── Docs section ────────────────────────────────────────────────
// Flea has always populated `d.docs`. v32 added the same field for curated
// skill/agent inner detail, sourced from marketplace-metadata.json's per-skill
// sub-tree. Both surface here through a single render path — file rows
// for internal-cached docs, link rows for external ones.
if (Array.isArray(d.docs) && d.docs.length) {
// Plain-link rendering — same shape as the plugin detail page. The
// server already filtered out anything that isn't a real downloadable
// PDF / Markdown / plain text, so every entry here is clickable +
// downloads on click (Content-Disposition: attachment from the API).
document.getElementById('docs-section').hidden = false;
document.getElementById('docs-count').textContent = String(d.docs.length);
document.getElementById('docs-list').innerHTML = d.docs.map(doc => `
<li style="padding:10px 12px;border-radius:8px;background:var(--surface-alt,#f9fafb);border:1px solid var(--border);">
<a href="${esc(doc.url || '#')}" download
style="color:var(--primary);text-decoration:none;font-weight:500;display:block;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">${esc(doc.name)}</a>
</li>`).join('');
}
// ── v32: demo video for skill / agent (from marketplace-metadata sub-tree) ──
// Same auto-embed logic as the parent plugin detail page: YouTube /
// Vimeo are embedded; raw .mp4/.webm/.ogg are inlined as <video>; anything
// else falls back to a "Watch on external site" link.
if (source === 'curated' && d.video_url) {
let videoSection = document.getElementById('video-section');
if (!videoSection) {
videoSection = document.createElement('div');
videoSection.id = 'video-section';
videoSection.className = 'section';
// .video-embed wrapper lives INSIDE .section-body so it inherits
// the same 12px / 24px / 22px padding as the docs / files sections —
// without that, the iframe bleeds to the panel edge.
videoSection.innerHTML = `
<div class="section-head">
<h2>Demo video</h2>
</div>
<div class="section-body">
<div class="video-embed" id="video-wrap"></div>
</div>`;
// Insert above the docs section so video shows first when both exist.
const docs = document.getElementById('docs-section');
docs.parentElement.insertBefore(videoSection, docs);
}
document.getElementById('video-wrap').innerHTML =
buildVideoEmbed(d.video_url, esc);
}
// ── Files section — both sources, when non-empty ────────────────────
if (Array.isArray(d.files) && d.files.length) {
document.getElementById('files-section').hidden = false;
document.getElementById('files-count').textContent = String(d.files.length);
document.getElementById('files-list').innerHTML =
d.files.map(f => fileRow(f.path, f.size, null)).join('');
}
})();
</script>
{% endblock %}