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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix v28 migration + post-rebase test fallout

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

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

* Harden curated skill/agent inner endpoints against path traversal

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

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

---------

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

753 lines
31 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: 160px 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 720px) {
.item-detail .hero .head { grid-template-columns: 1fr; }
}
.item-detail .hero .photo {
width: 160px; height: 160px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0.04) 100%);
border: 1px solid rgba(255,255,255,0.18);
display: flex; align-items: center; justify-content: center;
color: #fff;
font-size: 44px; font-weight: var(--font-bold);
letter-spacing: 1px;
overflow: hidden; flex-shrink: 0;
}
.item-detail .hero .photo img { width: 100%; height: 100%; object-fit: cover; }
.item-detail .hero .meta {
min-width: 0;
display: flex; flex-direction: column; justify-content: center;
}
.item-detail .hero .badges {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
margin-top: 12px;
}
.item-detail .type-badge {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
background: rgba(255,255,255,0.22); color: #fff;
font-size: 11px; font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
}
.item-detail .source-badge {
font-size: 11px; padding: 3px 10px; border-radius: 999px;
font-weight: var(--font-semibold);
}
.item-detail .source-badge.curated { background: #FEF3C7; color: #B45309; }
.item-detail .source-badge.flea { background: #EDE9FE; color: #6D28D9; }
.item-detail .cat-badge {
font-size: 11px; padding: 3px 10px; border-radius: 999px;
background: rgba(255,255,255,0.16); color: #fff;
}
.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 {
position: absolute; top: 18px; right: 22px;
display: flex; flex-direction: column; gap: 6px; align-items: flex-end;
max-width: 280px;
}
.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;
}
.item-detail .hero .actions .btn.installed {
background: rgba(16, 183, 127, 0.22);
color: #d1fae5;
border-color: rgba(16, 183, 127, 0.6);
}
.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;
}
.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. */
.item-detail .stack-hint {
margin-top: 18px;
padding: 14px 18px;
background: rgba(16, 183, 127, 0.08);
border: 1px solid rgba(16, 183, 127, 0.35);
border-left: 3px solid #10b77f;
border-radius: 10px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.55;
}
.item-detail .stack-hint .head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 8px;
}
.item-detail .stack-hint .title {
font-weight: var(--font-semibold);
color: #0e9b6a;
font-size: 13px;
}
.item-detail .stack-hint .dismiss {
appearance: none; background: transparent; border: none;
color: var(--text-secondary); font-size: 11px; cursor: pointer;
padding: 2px 6px; border-radius: 4px;
font-family: inherit;
}
.item-detail .stack-hint .dismiss:hover { color: var(--text-primary); background: rgba(0,0,0,0.04); }
.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;
}
.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 '' }}">
<!-- 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="actions" id="hero-actions"></div>
<div class="head">
<div class="photo" id="hero-photo">{{ 'SK' if kind == 'skill' else 'AG' }}</div>
<div class="meta">
<h1 id="hero-name">{{ inner_name or item_name or plugin_name }}</h1>
<div class="meta-row" id="hero-meta-row"></div>
<div class="badges" id="hero-badges">
<span class="type-badge">{{ kind }}</span>
<span class="source-badge {{ source }}">{{ 'Curated' if source == 'curated' else 'Flea' }}</span>
</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 class="stack-hint" id="stack-hint" hidden>
<div class="head">
<span class="title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button>
</div>
<div>To use it in Claude Code:</div>
<ol>
<li><strong>Open a new Claude Code session</strong> — it auto-installs via the SessionStart hook.</li>
<li>Or run now in your terminal:
<div class="cmd-chip">
<span class="prompt">$</span>
<span class="cmd">agnes refresh-marketplace</span>
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
</div>
Then in the running session: <code>/reload-plugins</code>
</li>
</ol>
</div>
</div>
<aside class="panel details">
<h2>Details</h2>
<dl id="details-list">
<div class="row"><dt>Type</dt><dd>{{ kind | capitalize }}</dd></div>
</dl>
</aside>
</div>
<div class="section" id="docs-section" hidden>
<div class="section-head">
<h2>Docs</h2>
<span class="count" id="docs-count">0</span>
</div>
<div class="section-body">
<div class="file-list" id="docs-list"></div>
</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 () {
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 standalone → /api/marketplace/flea/{id}/detail
const apiURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}/${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';
}
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: name from frontmatter (d.name).
// Flea standalone skill/agent reuses PluginDetailResponse — d.plugin_name
// is the entity name; manifest_name is the suffixed `<name>-by-<username>`.
const heroTitle = source === 'curated'
? (d.name || innerName)
: (d.plugin_name || '');
document.title = `${heroTitle} — Marketplace`;
// ── Breadcrumbs ────────────────────────────────────────────────────
const crumbs = document.getElementById('crumbs');
if (source === 'curated') {
crumbs.innerHTML =
`<a href="/marketplace?tab=curated">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}">${esc(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;
// Cover photo — flea may have one; curated always falls back to initials.
if (source === 'flea' && d.cover_photo_url) {
document.getElementById('hero-photo').innerHTML = `<img src="${esc(d.cover_photo_url)}" alt="">`;
}
// Badges — keep type + source (already server-rendered), append category.
const badges = document.getElementById('hero-badges');
const sourceBadge = source === 'curated'
? '<span class="source-badge curated">Curated</span>'
: '<span class="source-badge flea">Flea</span>';
const cat = d.category ? `<span class="cat-badge">${esc(d.category)}</span>` : '';
badges.innerHTML = `<span class="type-badge">${esc(kind)}</span>${sourceBadge}${cat}`;
// 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');
let invokeCmd = null;
if (source === 'flea' && d.manifest_name) {
invokeCmd = d.manifest_name;
} else if (source === 'curated' && d.manifest_name && (d.name || innerName)) {
invokeCmd = `${d.manifest_name}:${d.name || innerName}`;
}
if (invokeCmd) {
document.getElementById('invocation-cmd').textContent = invokeCmd;
invBlock.hidden = false;
const copyBtn = document.getElementById('invocation-copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText('/' + invokeCmd);
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.
const metaRow = document.getElementById('hero-meta-row');
if (source === 'curated') {
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 updated = d.parent_updated_at ? `Updated ${esc(fmtRelative(d.parent_updated_at))}` : '';
metaRow.innerHTML =
`<span>part of <a href="/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}"><strong>${esc(pluginName)}</strong></a></span>
<span class="dot">·</span>
<span>by ${author}</span>
${updated ? `<span class="dot">·</span><span>${updated}</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>` : ''}
${d.updated_at ? `<span class="dot">·</span><span>Updated ${esc(fmtRelative(d.updated_at))}</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;
}
if (source === 'curated') {
actions.innerHTML = `
<a class="btn" href="/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}">Open parent plugin →</a>
<div class="helper">
This ${esc(kind === 'skill' ? 'skill' : 'agent')} is part of <strong>${esc(pluginName)}</strong>.<br>
Add the bundle to your stack to use it.
</div>`;
} else {
const btnLabel = d.installed ? '✓ In your stack' : '+ Add to my stack';
const btnClass = d.installed ? 'btn primary installed' : 'btn primary';
actions.innerHTML = `<button class="${btnClass}" id="install-btn" type="button" data-installed="${d.installed ? '1' : '0'}">${btnLabel}</button>`;
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('agnes refresh-marketplace');
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 */ }
});
}
document.getElementById('install-btn').addEventListener('click', async (ev) => {
const btn = ev.currentTarget;
const installed = btn.dataset.installed === '1';
const method = installed ? 'DELETE' : 'POST';
const r = await fetch(`/api/store/entities/${encodeURIComponent(entityId)}/install`, { method });
if (!r.ok) { alert('Action failed (' + r.status + ')'); return; }
btn.dataset.installed = installed ? '0' : '1';
btn.classList.toggle('installed', !installed);
btn.textContent = installed ? '+ Add to my stack' : '✓ In your stack';
if (!installed) showHint(); // newly added → reveal next-steps
else hintEl.hidden = true; // removed → hide stale hint
});
}
// ── Description (plain text — no markdown rendering, parity with flea) ─
document.getElementById('description-body').textContent = d.description || '';
// ── 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');
if (source === 'curated') {
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
? esc(d.parent_author_name)
: '<span class="todo">owner_todo</span>';
dl.innerHTML = `
<div class="row"><dt>Type</dt><dd>${esc(kind === 'skill' ? 'Skill' : 'Agent')}</dd></div>
<div class="row"><dt>Parent plugin</dt><dd class="mono">${esc(pluginName)}</dd></div>
<div class="row"><dt>Marketplace</dt><dd>${esc(d.marketplace_name || marketplaceId)}</dd></div>
${d.relpath ? `<div class="row"><dt>Path</dt><dd class="mono">${esc(d.relpath)}</dd></div>` : ''}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
${d.parent_updated_at ? `<div class="row"><dt>Updated</dt><dd>${esc(fmtRelative(d.parent_updated_at))}</dd></div>` : ''}
<div class="row"><dt>Curator</dt><dd>${author}</dd></div>`;
} else {
const ownerLabel = d.owner_display || d.author_name || '';
dl.innerHTML = `
<div class="row"><dt>Type</dt><dd>${esc(kind === 'skill' ? 'Skill' : 'Agent')}</dd></div>
${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>
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}
${d.updated_at ? `<div class="row"><dt>Updated</dt><dd>${esc(fmtRelative(d.updated_at))}</dd></div>` : ''}`;
}
// ── Docs section — flea only (curated docs hidden until per-skill YAML) ─
if (source === 'flea' && Array.isArray(d.docs) && d.docs.length) {
document.getElementById('docs-section').hidden = false;
document.getElementById('docs-count').textContent = String(d.docs.length);
document.getElementById('docs-list').innerHTML =
d.docs.map(doc => fileRow(doc.name, null, { href: doc.url })).join('');
}
// ── 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 %}