agnes-the-ai-analyst/app/web/templates/marketplace_item_detail.html
minasarustamyan c6c72b9c00
feat(flea): marketplace refactor — data model, attribution, UI unification (#342)
* feat(flea): phase-1 — title, tagline, synthetic_name columns + upload UX

Schema v49 adds three user-facing metadata columns to store_entities:

- title (NOT NULL) — humanized display name shown on marketplace
  surfaces in later phases. Acronym-aware humanizer in
  src/store_naming.py (27 entries: MCP, API, OAuth, S3, …) shared
  with the frontend via Jinja-injected dict so JS pre-fill and
  Python backfill produce identical output.
- tagline (NULL, ≤200 chars) — optional short description for card
  listings. Long-form `description` stays.
- synthetic_name (NOT NULL) — deterministic `<name>-by-<owner_username>`
  stored as a column for indexing and as the single source of truth
  for attribution lookups in later phases. Today's bundle bake still
  uses suffixed_name() at the same call sites.

Migration (_v48_to_v49_migrate, Python function — humanize has no
SQL equivalent) backfills existing rows: title from
humanize_name(strip_archive_suffix(name)), synthetic from the concat
formula; tagline stays NULL. Idempotent (ADD COLUMN IF NOT EXISTS +
SET NOT NULL no-op on re-run).

Upload form (store_upload.html step 2) reorders fields: Title
(pre-filled from server-side humanize, JS keeps it in sync until
the user edits manually) → Name + dark synthetic preview on one
row (matches marketplace_item_detail.html dark code styling, no
copy button — preview only) → Short description with character
counter → Description (unchanged). Edit form (store_edit.html)
mirrors the layout with pre-filled values from the entity row.

API:

- POST /api/store/entities/preview returns `title` (humanized
  fallback) for upload form pre-fill.
- POST + PUT /api/store/entities accept `title` and `tagline` form
  fields with 100/200-char validation; PUT recomputes
  synthetic_name when `name` changes (caller responsibility per
  repo contract).
- StoreEntityResponse exposes all three new fields.

Repository:

- create() takes title + tagline + synthetic_name as optional
  kwargs with derived defaults (humanize_name(name) / concat) so
  existing test fixtures don't need to thread them.
- update() supports partial updates on all three; tagline empty
  string clears via NULL sentinel.
- archive() recomputes synthetic_name on rename to the archived
  slug so the column stays consistent with name.

Tests:

- New test_schema_v48_to_v49_migration.py: fresh install,
  populated-row backfill (incl. archived row strip), idempotence,
  NOT NULL constraint verification.
- test_store_naming.py: 14 humanize parametrize cases + acronym
  dict invariants.
- test_store_api.py::TestStoreV49Metadata: preview humanize, POST
  with explicit + fallback title, 100/200-char rejects, PUT
  partial update + synthetic recompute on rename.
- Schema version assertion bumps (48 → 49) in test_db_schema_version,
  test_home_stats, test_schema_v42_migration, test_schema_v46_migration.

Phase 1 only — surface rendering on cards / detail pages and
Claude Code bundle propagation come in later phases.

* feat(flea): phase-2 — wire title/tagline/owner through marketplace cards + detail pages

Phase 1 (7f4cfcbb) populated the three new columns on store_entities;
phase 2 surfaces them across the web presentation layer so the kebab-
case slug + bare username no longer leak into user-facing copy.

API:

- `_flea_to_item` now takes `conn` (both callsites updated) and sets
  `display_name=entity.title`, `tagline=entity.tagline`, `owner=
  _resolve_owner_display(conn, owner_user_id, owner_username)` —
  matches the chain the curated path already uses (users.name →
  users.email → fallback). The card JS chain `it.display_name ||
  it.name` then renders the friendly form; `name` stays at the
  suffixed slug as the technical identifier JS uses for fallbacks.
- `flea_detail` adds `display_name` + `tagline` to PluginDetailResponse
  so the standalone skill/agent + plugin detail heroes pick them up
  through the existing `d.display_name` / `d.tagline` chains.
- `_flea_inner_parent_fields` swaps `parent_display_name` from
  `strip_archive_suffix(name)` to `entity.title or strip_archive_suffix(
  name)`. Drives parent-plugin label in four surfaces at once:
  breadcrumb 3rd segment, hero "part of <plugin>" meta-row,
  helper "This skill is part of <plugin>" panel, and the Details
  sidebar's "Parent plugin" row.

Templates — `marketplace_item_detail.html`:

- Pre-render: browser title, hero h1, and hero-window-label read
  `(entity.title if entity else None) or inner_name or item_name or
  plugin_name` so the SSR shell shows the friendly title before the
  JS fetch lands (no flash of kebab-case).
- Breadcrumb last segment for flea standalone drops the `d.manifest_name
  || heroTitle` fallback in favour of just `heroTitle` — manifest_name
  is the suffixed slug and users explicitly didn't want it in the path.
- Hero meta-row for flea standalone is now hidden. The prior "by
  <author> · N installed · <size>" line duplicated install count
  (hero telemetry chip below), owner + bundle size (Details sidebar).

Templates — `marketplace_plugin_detail.html`:

- Same SSR pre-render swap (title, h1, window-label, crumb-name).
- Hero tagline element starts hidden; JS shows it only when
  `d.tagline` is truthy. Pre-fix it fell back to `d.description`
  (long-form text), which read awkwardly under the h1 and pulled the
  hero too tall. Description still renders in the "What it does"
  panel below the hero.
- Initial "Loading…" placeholder removed so entities without a
  tagline don't flash that text mid-fetch.

Tests:

- New `TestFleaPhase2Presentation` class in test_marketplace_api.py
  (6 cases): card title + tagline + full-name owner, owner fallback
  chain when users.name is NULL, flea_detail exposes title + tagline,
  tagline null when omitted, inner skill parent_display_name uses
  entity.title (explicit + humanize-fallback variants).
- Updated `TestListItems.test_flea_lists_uploads` to assert both
  `display_name == "Alpha"` (humanized) and `name ==
  "alpha-by-alice"` (suffixed slug compat).
- Updated `TestWebPages.test_marketplace_flea_detail_page_renders`
  to look for the humanized title ("Page Skill") in the SSR shell
  instead of the kebab-case `page-skill`.

* feat(flea): phase-3 — read synthetic_name from DB, suffixed_name() only on write

Phase 1 added the column + backfill, repo write paths keep it in sync.
Phase 3 routes every READ callsite through `store_entities.synthetic_name`
directly instead of recomputing `<name>-by-<owner_username>` on the fly,
and switches the collision query off the inline string concat. The
`suffixed_name()` primitive now lives exclusively in write flows.

Read callsites updated (all read `entity["synthetic_name"]` directly,
no fallback — the column is NOT NULL and a missing value would be a
real bug worth surfacing as KeyError):

- app/api/marketplace.py:_flea_to_item — card MarketplaceItem.name.
- app/api/marketplace.py:flea_detail — PluginDetailResponse.manifest_name.
- app/api/store.py:_entity_to_response — StoreEntityResponse.invocation_name.
- app/api/store.py PUT bundle re-bake — `suffixed` passed to
  `_bake_plugin_tree`; entity is loaded pre-rename, so its
  synthetic_name is the OLD value `_bake_plugin_tree` expects.
- app/api/store.py PUT rename — `old_suffix` for `_rename_baked_tree`.
- app/api/my_stack.py — StoreInstallEntry.invocation_name.
- src/marketplace_filter.py — manifest_name in served plugin entry.

`suffixed_name` imports removed from marketplace.py, my_stack.py, and
marketplace_filter.py (no remaining callsites). store.py keeps the
import for its write paths:

- POST create (`suffixed = suffixed_name(final_name, username)` →
  passed to `_bake_plugin_tree` and `repo.create(synthetic_name=...)`).
- PUT rename collision check (`new_suffixed`).
- PUT rename `new_suffix` for `_rename_baked_tree` (proposed value).
- PUT rename `new_synthetic` for `repo.update(synthetic_name=...)`.
- Archive `old_suffix` + `new_suffix` for `_rename_baked_tree`
  (retro-compute pre-archive value after `repo.archive` already
  overwrote the DB row with the post-archive synthetic).

Collision SQL — `_suffixed_already_taken`:

  WHERE name || '-by-' || owner_username = ?   (before)
  WHERE synthetic_name = ?                     (after)

Same matches today (phase 1 backfill + NOT NULL invariant + write
paths in sync); indexable + single source of truth going forward.

Repository:

- UserStoreInstallsRepository.list_for_user explicit SELECT extended
  with `se.title`, `se.tagline`, `se.synthetic_name` so my_stack and
  marketplace_filter callers can read them off the joined row.

Tests:

- test_store_api.py::test_invocation_name_reads_from_synthetic_column —
  upload entity, manually override the column with a non-canonical
  value, verify GET response returns the override (proves read path
  consumes the column, not recomputes).
- test_marketplace_api.py::test_flea_card_and_detail_read_synthetic_name_from_db —
  same proof for `MarketplaceItem.name` (card) and
  `PluginDetailResponse.manifest_name` (detail).

* feat(flea): phase-4 — rename agnes-store-bundle → flea (synthetic plugin)

The synthetic plugin that wraps loose flea-market skills + agents into
one Claude Code plugin is renamed from `agnes-store-bundle` to `flea`.
Plugin-type flea uploads (their own standalone plugin entry) are
unaffected.

Constants:
- src/marketplace_filter.py:
  - BUNDLE_PLUGIN_NAME: "agnes-store-bundle" → "flea"  (Claude Code
    plugin manifest name + .claude-plugin/plugin.json name)
  - BUNDLE_PREFIXED_NAME: "store-bundle" → "flea"      (on-disk ZIP /
    git tree path, now plugins/flea/...)

Attribution layer (services/session_processors/usage_lib.py):
- FLEA_BUNDLE_PREFIX: "agnes-store-bundle" → "flea". The JSONL
  invocation identifier going forward is `flea:<skill-name>`.
- New `_LEGACY_FLEA_BUNDLE_PREFIXES = ("agnes-store-bundle",)`.
  `MarketplaceItemLookup.resolve()` + `_attribute_event()` accept BOTH
  the new and the legacy prefix so historic usage_events (~90-day
  retention) continue attributing to source='flea'. The tuple becomes
  a no-op once the rename has been live past the retention window —
  a follow-up commit can drop it then.
- USAGE_PROCESSOR_VERSION bumped 6 → 7 so the session-pipeline reprocess
  loop re-runs attribution with the new + legacy prefix branches.

User-facing copy:
- /api/store/bundle.zip Content-Disposition filename: agnes-store-bundle.zip → flea.zip
- `agnes admin store pull` default --out: agnes-store-bundle.zip → flea.zip
- Docstrings + JS comment + welcome template comment updated.

Tests:
- skill_flea.jsonl fixture identifier updated to flea:flea-skill.
- New skill_flea_legacy.jsonl with the legacy prefix for backward-compat
  coverage.
- New test `test_legacy_agnes_store_bundle_prefix_resolves` replays the
  legacy fixture and asserts source='flea' attribution still lands.
- All other test assertions / mocks substituted mechanically:
  test_session_processor_usage.py, test_usage_rollups.py,
  test_marketplace_filter_store.py, test_store_api.py,
  test_cli_refresh_marketplace.py.
- `_seed_flea_entity` (test_usage_rollups.py) + `_seed_attribution`
  (test_session_processor_usage.py) helpers now supply the NOT NULL
  `title` + `synthetic_name` columns from phase 1, since they INSERT
  directly bypassing the repo's create() fallback.

Client rollover note (CHANGELOG): `agnes refresh-marketplace` will
install the new `flea@agnes` plugin and the local marketplace clone's
`plugins/store-bundle/` source folder is removed via `git reset --hard`.
Whether Claude Code itself auto-prunes the orphan `agnes-store-bundle
@agnes` registry entry is undocumented — to verify empirically on the
dev VM. If the orphan entry lingers, a follow-up will add targeted
cleanup; until then users can manually run
`claude plugin uninstall agnes-store-bundle@agnes`.

Verified locally: 98 passed (session_processor_usage + usage_rollups +
marketplace_filter_store + cli_refresh_marketplace) + 228 passed/2
skipped (store_api + marketplace_api + admin_store_submissions +
store_entity_versions + store_repositories).

* fix(flea): phase-5 — attribution keyspace mismatch (closes #335)

Pre-fix every flea skill/agent invocation silently fell through to
`usage_events.source = 'builtin'`. Root cause: lookup tables in
`services/session_processors/usage_lib.py` keyed `_flea_entities` (and
the derived `_flea_plugins` set) by `store_entities.name` — the
un-suffixed display name. Claude Code writes invocations as
`flea:<synthetic_name>` (e.g. `flea:xlsx-by-c-marustamyan`), so
`dict.get(local)` always missed and the resolver fell through to
builtin. Result: marketplace cards, detail telemetry chips, admin
group-by-source all showed 0 flea invocations even when the raw
JSONL stream was correct.

Phase 1 added the `synthetic_name` column + backfill; phase 4 renamed
the bundle prefix to `flea`; phase 5 finally flips the lookup
keyspace to match what JSONL writes.

usage_lib.py:
- `MarketplaceItemLookup.__init__` preload: `SELECT synthetic_name,
  type FROM store_entities` (was `SELECT name, type`). `_flea_plugins`
  set derived from those keys, so it now carries synthetic_names
  too — matches what Claude Code writes when invoking a skill nested
  inside a flea plugin (`<synthetic>:<inner>`).
- `rebuild_rollups` preload: same SELECT change; also derives
  `flea_plugins` and threads it through `_aggregate_events` /
  `_rebuild_window`.
- `_attribute_event`: signature extended with `flea_plugins`; new
  branch `if prefix in flea_plugins: return ("flea", default_type,
  prefix, local)` for flea-plugin-nested skills/agents. This branch
  was added to `MarketplaceItemLookup.resolve()` in v6 (commit
  e076ebbe) but the rollup builder's helper was never updated to
  match, so nested skills inside flea plugins silently dropped out
  of the daily/window fact tables.
- `USAGE_PROCESSOR_VERSION`: 7 → 8. Forces the session-pipeline
  reprocess loop to re-attribute existing usage_events rows with
  the corrected lookup so rollup tables fill correctly on the next
  tick.

marketplace.py — 4 API stats lookup callsites switched from
`entity["name"]` to `entity["synthetic_name"]`:
- `_flea_to_item` (card stats lookup)
- `flea_detail` (`_build_telemetry` + `_load_inner_items_stats_by_parent`)
- `flea_skill_detail` (inner detail `parent_plugin` key)
- `flea_agent_detail` (inner detail `parent_plugin` key)

Tests:
- `skill_flea.jsonl` invocation: `flea:flea-skill` →
  `flea:flea-skill-by-alice` (mirrors what Claude Code writes after
  phase 1/4 — the suffixed synthetic_name).
- `test_flea_skill_attributed_with_empty_parent` assertion: rollup
  `name` column now carries the synthetic_name.

No legacy `agnes-store-bundle` prefix backward compat — clean cut per
user direction (dev phase, no production data worth preserving).

Verified locally: 53 passed targeted (session_processor_usage +
usage_rollups + marketplace_filter_store) + 215 passed/2 skipped
broader (store_api + marketplace_api + admin_store_submissions +
store_entity_versions).

* fix(flea): phase-6 — plugin-level rollup aggregation parity for flea

Flea plugin entity cards + detail pages showed 0 invocations even
though nested skills had correct rollup rows. Root cause: the
plugin-level aggregation pass in `_aggregate_events` was hardcoded
to `source='curated'` only:

    if source != "curated" or not parent:
        continue
    if group_by_day:
        pkey = (day, "curated", "plugin", "", parent)
    else:
        pkey = ("curated", "plugin", "", parent)

So flea plugin entities never got a synthetic
`(source='flea', type='plugin', parent_plugin='', name=<synth>)`
row aggregating nested invocations. `_load_invocation_stats('flea')`
filters `parent_plugin = ''` and returned no row for flea plugin
entity cards, so `stats.get(entity["synthetic_name"])` missed and
the API exposed 0/0.

Triggered by empirical observation on the dev VM —
`codex-second-opinion-by-c-marustamyan` plugin showed 0 calls in
the listing card while its three inner skills (codex-setup ×3,
codex-review ×1, codex-second-opinion ×1) had the expected child
rollup rows.

Fix:

- Extend the guard to `source in ("curated", "flea")`.
- Replace the hardcoded `"curated"` in the `pkey` tuple with the
  loop's `source` variable, so flea aggregation lands as `source=
  'flea'` and curated aggregation continues landing as
  `source='curated'`.

API path unchanged — `_load_invocation_stats('flea')` filters
`parent_plugin = ''` already picks up the new aggregated row
alongside standalone skill/agent rows. Rollup `name` field carries
the synthetic_name keyspace; no collision between standalone entity
synthetic and plugin entity synthetic (global suffix uniqueness
enforced by `_suffixed_already_taken`).

`USAGE_PROCESSOR_VERSION` bumped 8 → 9 to force a reprocess pass so
historic nested-invocation data fills the new plugin-level rows on
the next tick (instead of waiting for the next live invocation).

Tests:

- New `test_flea_plugin_row_aggregates_children` mirrors the existing
  `test_curated_plugin_row_aggregates_children`: seeds a flea plugin
  entity, three nested events (one user invoking two skills, a
  second user invoking one) → asserts the aggregated plugin row
  carries count=3, distinct_users=2 (union, not sum), plus the child
  rows survive alongside.

Verified locally: 43 passed (session_processor_usage + usage_rollups)
+ 82 passed/2 skipped broader (+ marketplace_filter_store +
marketplace_api).

* refactor(marketplace): phase-7 — unify Details sidebar across detail surfaces

Five marketplace detail surfaces (curated plugin, flea plugin, curated
inner skill/agent, flea inner skill/agent, flea standalone skill/agent)
had drifted on which Details rows they show and what order — the same
field landed in different positions, some fields duplicated hero info,
and the flea plugin Owner row leaked the kebab-case `owner_username`
slug instead of the user's real name. This commit aligns all five
surfaces on a single scan order driven by UX priority:

  identity → life-stage → telemetry → debug-tier

Concretely:

  1. Curator / Owner          (first scan signal — trust)
  2. Parent plugin            (inner skill/agent only)
  3. Released                 (top-level only — plugins + flea standalone)
  4. Last used                (recency)
  5. Active days              (engagement consistency)
  6. Version                  (flea standalone only — content hash)
  7. Bundle size              (debug-tier)

Dropped:

  - Slug field on plugin detail surfaces (`marketplace_id` for curated,
    `entity_id` for flea). Pure debug info, never user-relevant; URL
    already carries it.
  - Category + Installs on flea standalone skill/agent detail.
    Category is already shown as a hero badge; install count is in
    the hero telemetry chip — sidebar duplication added noise.

Owner display:

  - Flea plugin Owner row now reads `d.owner_display` (resolved through
    `users.name → users.email → owner_username` by `_resolve_owner_display`
    in `app/api/marketplace.py:1491`) instead of the raw `d.author_name`
    (which is `owner_username`, the kebab-case slug). API field already
    populated from phase 2; templates just consume it.
  - Curated Curator row continues to read `d.author_name` from
    marketplace-metadata.json; `owner_todo` placeholder behavior
    preserved.

Files:

  - app/web/templates/marketplace_plugin_detail.html — rewrote the
    Details render loop (lines 1364-1427 area). Slug row removed,
    rows reordered, Owner branch reads `d.owner_display`.
  - app/web/templates/marketplace_item_detail.html — both branches of
    the Details sidebar (inner skill/agent + flea standalone) re-laid
    around the same scan order. Telemetry helper unchanged, just
    repositioned. Category + Installs rows removed from the
    standalone branch.

No new tests — no existing test asserts the precise order of Details
rows or references the dropped fields in a sidebar context (grep
confirmed). API surface unchanged.

Verified locally: 84 passed / 2 skipped on `test_marketplace_api.py`
+ `test_store_api.py`.

* fix(flea): post-review hardening — N+1, v50 UNIQUE, docs, test cleanup

Addresses 5 critical findings from PR #342 code review:

1. N+1 query in `_flea_to_item` — owner-display resolution previously
   ran one `SELECT … FROM users WHERE id = ?` per item in the listing
   comprehension. Now batched via `_load_users_display` IN-query
   prefetch; 50 items drops 51 user queries to 2. Regression-guarded
   by `TestFleaOwnerDisplayBatched` (spies `_resolve_owner_display`
   and asserts it's not called inside the list path).

2. Misleading comment in `src/marketplace_filter.py` claimed the
   attribution layer accepts both `agnes-store-bundle` and `flea`
   prefixes — it doesn't (clean cut per CHANGELOG). Rewrote to match
   reality.

3. CHANGELOG `[Unreleased]` had two `### Changed` blocks. Merged into
   one (BREAKING bullet first).

4. New v49→v50 migration adds `UNIQUE INDEX
   idx_store_entities_synthetic_name`. v49 made `synthetic_name` the
   canonical attribution key but uniqueness was only app-enforced;
   v50 promotes the invariant to the DB layer. Migration pre-checks
   for existing duplicates and raises `RuntimeError` listing them
   rather than letting `CREATE UNIQUE INDEX` fail mid-way. v48→v49
   migration gained an `is_nullable='YES'` guard on its `SET NOT NULL`
   ALTERs so re-runs on a fully-migrated DB don't trip DuckDB's
   "cannot alter entry … entries depend on it" block (the new index
   counts as such an entry). Index is created by the migration only —
   keeping it out of `_SYSTEM_SCHEMA` preserves fresh-install ordering
   (CREATE TABLE → v49 ALTERs → v50 CREATE INDEX).

5. Deleted three redundant version-pinned schema asserts whose names
   lied about their bodies (`test_schema_version_is_42` asserting
   `== 49`, etc.). Canonical assert lives in
   `test_db_schema_version.py`, renamed to
   `test_schema_version_matches_constant`.

* fix(db): gate v34→v38 store_entities ALTER COLUMN steps on column state

CI on Linux failed `test_v17_to_v18_drops_*` after the v50 UNIQUE INDEX
landed. Root cause: those tests open a DB at the full target version,
seed fixtures, then reset `schema_version` to 17 and reopen — forcing
the ladder to re-run from 17 → current. With the v50 index now in place,
DuckDB blocks intermediate `ALTER COLUMN` steps on `store_entities`
("Cannot drop this column: an index depends on a column after it!" /
"Cannot alter entry because there are entries that depend on it"),
because `synthetic_name` (the indexed column) sits positionally after
the columns those steps touch.

Fix: convert the three SQL-list migrations that hit store_entities into
defensive Python functions:

- `_v34_to_v35_migrate` short-circuits when `synthetic_name` already
  exists (post-v49 shape — the visibility_status rebuild is moot and
  the DROP COLUMN would be blocked by the index).
- `_v35_to_v36_migrate` gates the `visibility_status SET NOT NULL` +
  `SET DEFAULT` on `is_nullable='YES'` so it's a true no-op when the
  column is already constrained.
- `_v37_to_v38_migrate` gates the `version_no SET NOT NULL` step the
  same way.

Forward-roll path (real installs that never reset schema_version) is
unchanged: the gates fire `YES` → ALTERs run. The fix only changes
behavior for the "DB is already at v50 shape but version row says 17"
scenario the tests construct.

---------

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

1550 lines
70 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 %}{{ (entity.title if entity else None) or inner_name or item_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">{{ (entity.title if entity else None) or 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">{{ (entity.title if entity else None) or 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 {
// v49 phase-2: drop `d.manifest_name` fallback. manifest_name is the
// suffixed slug (e.g. `xlsx-by-c-marustamyan`) and users explicitly
// didn't want to see it in breadcrumbs. heroTitle (resolved from
// `display_name` which now mirrors `entity.title`) is the
// user-friendly label.
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(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 flea 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) → hidden.
// v49 phase-3: the prior "by <author> · N installed · size" line
// duplicated three values that already live on dedicated surfaces
// — install count in the hero telemetry chip below, owner +
// bundle size in the Details sidebar. Empty meta-row would still
// consume vertical rhythm via .meta-row's margin, so we hide it.
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>`;
metaRow.hidden = false;
} else {
metaRow.innerHTML = '';
metaRow.hidden = true;
}
// 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.
//
// v49 phase-7: unified scan order with plugin detail surface.
// Priority: identity (who) → life-stage (when) → telemetry (how used)
// → debug-tier (version, size). Inner skills/agents pivot on parent
// plugin, so Parent plugin slots immediately after authorship.
const dl = document.getElementById('details-list');
// Telemetry helper — Last used first (recency, primary decision
// driver), Active days second (engagement consistency over 30d).
// Both rendered only when the item has at least one active day so a
// brand-new skill doesn't show "Last used —" + "0 of 30".
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 (lastIso
? `<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
: '')
+ `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`;
})();
if (source === 'curated' || innerName) {
// Nested skill/agent (curated OR flea-inner). Sidebar render order:
// 1. Curator / Owner — first scan signal (trust)
// 2. Parent plugin — where this lives
// 3. Last used — recency
// 4. Active days — engagement
// 5. Bundle size — debug-tier
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';
dl.innerHTML = `
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>
<div class="row"><dt>Parent plugin</dt><dd>${esc(d.parent_display_name || d.manifest_name || pluginName)}</dd></div>
${telemetryRows}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
} else {
// Flea standalone skill/agent. Sidebar render order:
// 1. Owner — first scan signal (trust)
// 2. Released — life-stage
// 3. Last used — recency
// 4. Active days — engagement
// 5. Version — debug-tier
// 6. Bundle size — debug-tier
// Drops the previous Category + Installs rows (duplicated hero
// badge + telemetry chip — see v49 phase-7 notes).
const ownerLabel = d.owner_display || d.author_name || '';
dl.innerHTML = `
${ownerLabel ? `<div class="row"><dt>Owner</dt><dd>${esc(ownerLabel)}</dd></div>` : ''}
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}
${telemetryRows}
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</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 %}