agnes-the-ai-analyst/app/web/templates/marketplace_item_detail.html
minasarustamyan d9405a6888
Move marketplace plugin updates from hook to /update-agnes-plugins skill (#237)
* Move marketplace plugin updates from hook to /update-agnes-plugins skill

The SessionStart hook used to run `agnes refresh-marketplace --quiet`,
which performed a full fetch+reset+install cycle on every Claude Code
session start. That work was invisible to the user, slowed session
startup, and was unrecoverable interactively when something failed.

Split the responsibility:

- `agnes refresh-marketplace --check` is a new lightweight detector:
  `git fetch` only, compares local HEAD with remote FETCH_HEAD, emits
  a Claude Code hook JSON message pointing the user at
  `/update-agnes-plugins` when the marketplace has changes. No reset,
  no plugin install/update side effects.
- `/update-agnes-plugins` is a new slash command (installed by
  `agnes init` into `<workspace>/.claude/commands/`) that runs
  `agnes refresh-marketplace` (default chatty path). Output streams
  into the Claude Code transcript so the user sees install/update
  progress and can react to errors interactively.
- The SessionStart hook now runs `--check`. Existing workspaces
  auto-upgrade on next `agnes init` (substring marker matches both
  the old `--quiet` entry and the new `--check` one).

BREAKING: `agnes refresh-marketplace --quiet` is removed. Old hooks
calling it silent-noop after the CLI upgrade (the hook's `|| true`
swallows the unknown-flag error) until re-init rewrites them.

* Point marketplace 'Added to your stack' hint at /update-agnes-plugins

The post-install green panel on plugin and skill/agent detail pages
referenced the SessionStart auto-install path and a shell-prompt
`agnes refresh-marketplace` invocation. With the hook now being
detect-only, that copy was misleading — the actual install path is
the new slash command.

Condensed to a single instruction: "Open a new Claude Code session
and run:" followed by `/update-agnes-plugins` in a copy-chip.
JS clipboard string updated to match.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
2026-05-09 21:10:39 +02:00

919 lines
40 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 '' }}"
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">
<a href="#" aria-disabled="true"
title="Edit flow lands in a follow-up — for now, re-upload to update.">
Edit (coming soon)
</a>
{# 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>
<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="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>Open a new Claude Code session and run:</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>
<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>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 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 v32 may also have one when
// agnes-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 heroPhoto = document.getElementById('hero-photo');
// Initials fallback already lives inside #hero-photo (rendered by the
// server-side template as 'SK' / 'AG'). Capture before swapping so a
// 404 on the cover restores the original glyph.
const initials = heroPhoto.textContent || (kind === 'skill' ? 'SK' : 'AG');
heroPhoto.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)}">`;
}
// 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 {
// 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 {
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('/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 id only exists in the non-quarantined branch above.
// Skip the click wiring entirely when the button is the inert
// disabled variant — addEventListener on null would 500.
const installBtnEl = document.getElementById('install-btn');
if (installBtnEl) {
installBtnEl.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 has always populated `d.docs`. v32 added the same field for curated
// skill/agent inner detail, sourced from agnes-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 agnes-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 %}