agnes-the-ai-analyst/app/web/templates/marketplace_plugin_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

910 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}{{ plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.plugin-detail {
--primary-light: rgba(0, 115, 209, 0.12);
--border-light: #eceff1;
--text-primary: #202124;
--text-secondary: #5f6368;
--warn-color: #b45309;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
}
/* ── Hero ─────────────────────────────────────────────────────────── */
.plugin-detail .hero {
position: relative;
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 14px;
padding: 22px 28px 28px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.18);
color: #fff;
}
.plugin-detail .crumbs {
display: flex; gap: 6px; align-items: center;
font-size: 12px; color: rgba(255,255,255,0.78);
margin-bottom: 18px;
}
.plugin-detail .crumbs a { color: #fff; opacity: 0.92; text-decoration: none; }
.plugin-detail .crumbs a:hover { text-decoration: underline; }
.plugin-detail .crumbs .sep { opacity: 0.5; }
.plugin-detail .hero-head {
display: grid;
grid-template-columns: 160px 1fr;
gap: 22px;
align-items: start;
}
@media (max-width: 720px) {
.plugin-detail .hero-head {
grid-template-columns: 1fr;
}
}
.plugin-detail .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;
overflow: hidden;
color: #fff; font-size: 44px; font-weight: 700;
letter-spacing: 1px;
flex-shrink: 0;
}
.plugin-detail .photo img { width: 100%; height: 100%; object-fit: cover; }
.plugin-detail .meta { min-width: 0; }
.plugin-detail h1 {
margin: 0 0 6px; font-size: 28px; font-weight: 700;
letter-spacing: -0.4px; color: #fff;
word-wrap: break-word;
}
.plugin-detail .tagline {
font-size: 14.5px; line-height: 1.6;
color: rgba(255,255,255,0.92); margin-bottom: 6px;
}
.plugin-detail .curator {
font-size: 12.5px; color: rgba(255,255,255,0.78);
margin-bottom: 14px;
}
.plugin-detail .curator strong { color: #fff; font-weight: 600; }
.plugin-detail .curator .todo { color: #FED7AA; font-style: italic; }
.plugin-detail .pills {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
}
.plugin-detail .pill {
background: rgba(255,255,255,0.16); color: #fff;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 500;
}
.plugin-detail .pill.cat { background: rgba(255,255,255,0.22); }
.plugin-detail .pill.ver { font-family: var(--font-mono); }
.plugin-detail .pill.curated { background: #FEF3C7; color: #B45309; font-weight: 600; }
.plugin-detail .pill.flea { background: #EDE9FE; color: #6D28D9; font-weight: 600; }
.plugin-detail .pill.muted { background: transparent; color: rgba(255,255,255,0.72); padding-left: 0; }
.plugin-detail .actions {
/* Absolute, anchored to the hero — matches the skill/agent detail
page so the install button sits at the same exact offset across
both pages (top-right corner of the hero, not aligned to the
photo's top edge). */
position: absolute; top: 18px; right: 22px;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
z-index: 1;
}
.plugin-detail .btn-install {
appearance: none; cursor: pointer;
padding: 11px 22px; border-radius: 9px;
font-size: 13px; font-weight: 600; font-family: inherit;
/* Transparent border kept on the default so :hover can swap to a
visible white border without shifting the button's size. */
border: 1px solid transparent;
transition: all 0.15s ease;
background: #fff; color: var(--primary);
}
.plugin-detail .btn-install:hover {
/* Darken-glass — same formula as the secondary "Open parent plugin"
button on the skill/agent detail hero, so all hero-action hovers
feel consistent. The blue hero shows through the 20% black tint. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
.plugin-detail .btn-install.installed {
background: rgba(16, 183, 127, 0.18); color: #d1fae5;
border: 1px solid rgba(16, 183, 127, 0.5);
}
/* ── Post-add hint panel ─────────────────────────────────────────────
Inline next-steps recipe rendered after a successful "Add to my stack"
click. Lives below the description panel so the user sees it the
moment the page reflows from the Add action. The Catppuccin-Mocha
code chip mirrors the marketplace_item_detail invocation chip + the
/setup terminal blocks, so a familiar visual cue means "this is a
command you run in your terminal". */
.plugin-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;
}
.plugin-detail .stack-hint .head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 8px;
}
.plugin-detail .stack-hint .title {
font-weight: var(--font-semibold);
color: #0e9b6a;
font-size: 13px;
}
.plugin-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;
}
.plugin-detail .stack-hint .dismiss:hover { color: var(--text-primary); background: rgba(0,0,0,0.04); }
.plugin-detail .stack-hint ol {
margin: 6px 0 0; padding-left: 20px;
color: var(--text-secondary);
}
.plugin-detail .stack-hint ol li { margin: 4px 0; }
.plugin-detail .stack-hint ol li strong { color: var(--text-primary); font-weight: var(--font-semibold); }
.plugin-detail .stack-hint .cmd-chip {
display: inline-flex; align-items: center; gap: 8px;
margin-top: 6px;
padding: 6px 10px;
background: #1e1e2e;
border-radius: 6px;
font-family: var(--font-mono); font-size: 12px;
color: #cdd6f4;
}
.plugin-detail .stack-hint .cmd-chip .prompt {
color: #a6e3a1; user-select: none; font-weight: var(--font-bold);
}
.plugin-detail .stack-hint .cmd-chip .btn-copy {
appearance: none; cursor: pointer;
padding: 2px 8px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
border-radius: 4px;
font-size: 10px; font-weight: var(--font-medium);
font-family: var(--font-primary);
transition: all 0.15s ease;
}
.plugin-detail .stack-hint .cmd-chip .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.plugin-detail .stack-hint .cmd-chip .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
}
.plugin-detail .stack-hint .learn-more {
display: inline-block; margin-top: 8px;
font-size: 12px; color: var(--primary); text-decoration: none;
}
.plugin-detail .stack-hint .learn-more:hover { text-decoration: underline; }
/* ── Top row ─────────────────────────────────────────────────────── */
.plugin-detail .top-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-bottom: 24px;
align-items: stretch;
}
/* Spacing between the bottom panels (video / docs / internal structure).
The hero-row has its own margin-bottom; subsequent stacked panels need
equivalent breathing room or they collide visually (~2px between
bordered cards looks like a render bug). */
.plugin-detail #panel-video,
.plugin-detail #panel-docs,
.plugin-detail .structure {
margin-top: 24px;
}
@media (max-width: 900px) {
.plugin-detail .top-row { grid-template-columns: 1fr; }
}
.plugin-detail .panel {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 22px 26px;
}
.plugin-detail .panel h2 {
font-size: 15px; font-weight: 600;
margin: 0 0 14px;
text-transform: uppercase; letter-spacing: 0.6px;
color: var(--text-secondary);
}
.plugin-detail .lead { font-size: 14.5px; line-height: 1.65; color: var(--text-primary); white-space: pre-wrap; }
.plugin-detail .details dl { margin: 0; }
.plugin-detail .details .row {
display: grid; grid-template-columns: max-content 1fr; gap: 12px;
padding: 10px 0; border-bottom: 1px solid var(--border-light);
font-size: 13px;
}
.plugin-detail .details .row:last-child { border-bottom: none; }
.plugin-detail .details dt { color: var(--text-secondary); margin: 0; font-weight: 500; }
.plugin-detail .details dd { margin: 0; color: var(--text-primary); font-weight: 500; text-align: right; }
.plugin-detail .details dd.mono { font-family: var(--font-mono); font-size: 12px; }
.plugin-detail .details dd .todo { color: var(--warn-color); font-style: italic; font-weight: 400; }
/* ── Internal structure ──────────────────────────────────────────── */
.plugin-detail .structure { margin-top: 4px; }
.plugin-detail .structure > h2 {
font-size: 16px; font-weight: 700;
margin: 0 0 16px; letter-spacing: -0.2px;
color: var(--text-primary); text-transform: none;
}
.plugin-detail .substruct {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 20px 24px; margin-bottom: 16px;
}
.plugin-detail .substruct .head {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 12px;
border-bottom: 1px solid var(--border-light);
}
.plugin-detail .substruct .head h3 {
margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary);
}
.plugin-detail .substruct .head .count {
font-size: 12px; color: var(--text-secondary); font-family: var(--font-mono);
}
/* v32: demo video uses the shared `.video-embed` 16:9 wrapper from
style-custom.css — no scoped overrides needed here. */
.plugin-detail .doc-link-list {
list-style: none; padding: 0; margin: 0;
display: grid; gap: 8px;
}
.plugin-detail .doc-link-list li {
padding: 10px 12px; border-radius: 8px;
background: var(--surface-alt, #f9fafb);
border: 1px solid var(--border);
}
.plugin-detail .doc-link-list a {
color: var(--primary); text-decoration: none; font-weight: 500;
display: block;
}
.plugin-detail .doc-link-list a:hover { text-decoration: underline; }
/* Inner cards (skills + agents) */
.plugin-detail .inner-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) { .plugin-detail .inner-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 820px) { .plugin-detail .inner-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 540px) { .plugin-detail .inner-grid { grid-template-columns: 1fr; } }
.plugin-detail .inner-card {
display: flex; flex-direction: column;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; cursor: pointer;
transition: all 0.15s ease; text-decoration: none; color: inherit;
}
.plugin-detail .inner-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.10);
transform: translateY(-1px);
}
.plugin-detail .inner-card .photo {
width: 100%; height: 78px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
color: var(--primary);
font-size: 18px; font-weight: var(--font-bold);
letter-spacing: 0.5px;
border: none; border-radius: 0;
overflow: hidden;
}
.plugin-detail .inner-card .photo img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.plugin-detail .inner-card[data-type="skill"] .photo {
background: linear-gradient(135deg, rgba(16,183,127,0.18) 0%, #ecfdf5 100%);
color: #0e9b6a;
}
.plugin-detail .inner-card[data-type="agent"] .photo {
background: linear-gradient(135deg, rgba(124,58,237,0.18) 0%, #f5f3ff 100%);
color: #6d28d9;
}
.plugin-detail .inner-card .body {
padding: 12px 14px; flex: 1;
display: flex; flex-direction: column; gap: 5px;
}
.plugin-detail .inner-card .type-badge {
align-self: flex-start;
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 10px; font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(16, 183, 127, 0.14); color: #0e9b6a;
}
.plugin-detail .inner-card[data-type="agent"] .type-badge {
background: rgba(124,58,237,0.14); color: #6d28d9;
}
.plugin-detail .inner-card .name {
font-weight: var(--font-semibold); color: var(--text-primary);
font-size: 13.5px; line-height: 1.3;
font-family: var(--font-mono);
}
.plugin-detail .inner-card .desc {
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
/* Tables (commands, hooks, mcps) */
.plugin-detail .substruct table { width: 100%; border-collapse: collapse; font-size: 13px; }
.plugin-detail .substruct th {
text-align: left;
font-size: 11px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.plugin-detail .substruct td {
padding: 10px; border-bottom: 1px solid var(--border-light);
vertical-align: top; color: var(--text-primary);
}
.plugin-detail .substruct tr:last-child td { border-bottom: none; }
.plugin-detail .substruct .cell-name {
font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--primary); white-space: nowrap;
}
.plugin-detail .substruct .cell-event,
.plugin-detail .substruct .cell-type {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-secondary); white-space: nowrap;
}
.plugin-detail .substruct .cell-desc {
font-size: 12.5px; color: var(--text-secondary); line-height: 1.55;
}
.plugin-detail .empty-msg {
color: var(--text-secondary); font-size: 13px; font-style: italic;
}
</style>
<div class="plugin-detail page-shell" id="root"
data-source="{{ source }}"
data-marketplace-id="{{ marketplace_id or '' }}"
data-plugin-name="{{ plugin_name or '' }}"
data-entity-id="{{ entity_id or '' }}"
data-visibility="{{ entity.visibility_status if entity else 'approved' }}">
{# Quarantine banner — owner / admin only when non-approved. Self-guarded. #}
{% include "_quarantine_banner.html" %}
{# Owner-actions strip (Edit + Delete locked-when-not-approved). Mirrors
the policy that previously lived in store_detail.html. Edit is a
placeholder for now ("coming soon"); Delete is gated server-side
so the visible state matches what the API will accept. #}
{% if entity and (is_owner or is_admin) %}
<style>
.plugin-detail .owner-actions {
display: flex; gap: 10px; margin: 0 0 16px 0; justify-content: flex-end;
}
.plugin-detail .owner-actions a,
.plugin-detail .owner-actions button {
padding: 6px 14px; border-radius: 8px;
font-size: 13px; font-weight: 500; font-family: var(--font-primary);
text-decoration: none; border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff); color: var(--text, #111827);
cursor: pointer;
}
.plugin-detail .owner-actions a:hover {
border-color: var(--primary, #0073D1); color: var(--primary, #0073D1);
}
.plugin-detail .owner-actions .delete {
color: #b91c1c; border-color: rgba(185,28,28,0.3);
}
.plugin-detail .owner-actions .delete:hover {
background: rgba(185,28,28,0.08); border-color: #b91c1c;
}
.plugin-detail .owner-actions button:disabled,
.plugin-detail .owner-actions a[aria-disabled="true"] {
color: #9ca3af !important; border-color: #e5e7eb !important;
background: #f3f4f6 !important; cursor: not-allowed;
}
</style>
<div class="owner-actions">
<a href="#" id="owner-edit-btn" aria-disabled="true"
title="Edit flow lands in a follow-up — for now, re-upload to update.">
Edit (coming soon)
</a>
{# v35 delete UX: Archive (soft) is the primary path. Owner sees
Archive only when the entity is approved or already archived
(re-archive is a no-op, but no point exposing). Admin gets
Archive AND Hard Delete (separate red button) regardless of
state. Quarantined (non-approved + non-archived) entities lock
both buttons for the owner — admin still sees both. #}
{% if is_admin %}
{# Archive (soft) only meaningful when the entity is currently
public (approved). For non-approved states the entity is
already hidden — archiving would just lose the quarantine /
pending state info. Admin still has Hard delete + the
override / rescan / retry actions on the quarantine banner
to manage non-approved entities. #}
{% if entity.visibility_status == 'approved' %}
<button class="delete" id="owner-archive-btn" type="button"
title="Soft delete: hides from browse + blocks new installs. Existing user_store_installs continue serving the bundle.">
Archive
</button>
{% elif entity.visibility_status == 'archived' %}
<button class="delete" type="button" disabled
title="Already archived. Hidden from browse; existing installs still served. Use Hard delete to purge.">
Archived
</button>
{% else %}
<button class="delete" type="button" disabled
title="Archive is only available for approved entities. Use Override (in quarantine banner) to publish, Rescan to re-evaluate, or Hard delete to purge.">
Archive (not applicable while {{ entity.visibility_status }})
</button>
{% endif %}
<button class="delete" id="owner-hard-delete-btn" type="button"
style="border-color: rgba(185,28,28,0.45);"
title="Hard delete: drops the bundle from disk + removes existing user_store_installs. Use only for legal / privacy removals — existing users lose the plugin.">
Hard delete (admin)
</button>
{% elif entity.visibility_status == 'approved' %}
<button class="delete" id="owner-archive-btn" type="button"
title="Soft delete: hides from browse + blocks new installs. Existing user_store_installs continue serving the bundle.">
Archive
</button>
{% elif entity.visibility_status == 'archived' %}
<button class="delete" type="button" disabled
title="Already archived. Hidden from browse; existing installs still served. Contact an admin for hard delete.">
Archived
</button>
{% elif entity.visibility_status == 'pending' %}
<button class="delete" type="button" disabled
title="Submission is under review — Delete is locked until checks finish.">
Delete (locked — under review)
</button>
{% else %}
<button class="delete" type="button" disabled
title="Submission is quarantined. Only an admin can delete it (so the failure evidence isn't lost). Edit + re-upload to fix the issues.">
Delete (locked — quarantined)
</button>
{% endif %}
</div>
{% endif %}
<div class="hero">
<div class="crumbs">
<a href="/marketplace?tab={{ 'curated' if source == 'curated' else 'flea' }}">Marketplace</a>
<span class="sep"></span>
<span id="crumb-mid">{{ source | capitalize }}</span>
<span class="sep"></span>
<span id="crumb-name">{{ plugin_name }}</span>
</div>
<div class="hero-head">
<div class="photo" id="hero-photo" aria-hidden="true">PL</div>
<div class="meta">
<h1 id="hero-name">{{ plugin_name }}</h1>
<div class="tagline" id="hero-tagline">Loading…</div>
<div class="curator" id="hero-curator"></div>
<div class="pills" id="hero-pills"></div>
</div>
<div class="actions">
<button class="btn-install" id="install-btn" type="button" data-installed="0" hidden>+ Add to my stack</button>
</div>
</div>
</div>
<div class="top-row">
<div class="panel" id="panel-what">
<h2>What it does</h2>
<div class="lead" id="lead-text">Loading…</div>
<div 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>
<div class="panel details" id="panel-details">
<h2>Details</h2>
<dl id="details-list"></dl>
</div>
</div>
<!-- v32: video + doc_links section. Both blocks stay hidden until populated
so the layout collapses gracefully when an upstream marketplace ships
no agnes-metadata.json. Doc icons differentiate internal-cached files
(📄) from external links (🔗) so the user knows what's a click away. -->
<div class="panel" id="panel-video" hidden>
<h2>Demo video</h2>
<div class="video-embed" id="video-wrap"></div>
</div>
<div class="panel" id="panel-docs" hidden>
<h2>Documentation</h2>
<ul class="doc-link-list" id="doc-link-list"></ul>
</div>
<div class="structure" id="structure" hidden>
<h2>Internal structure</h2>
<div id="struct-skills"></div>
<div id="struct-agents"></div>
<div id="struct-commands"></div>
<div id="struct-hooks"></div>
<div id="struct-mcps"></div>
</div>
<div id="error-msg" class="panel" hidden>
<p class="empty-msg" id="error-text"></p>
</div>
</div>
<script>
'use strict';
(async function(){
{% include "_marketplace_video_embed.html" %}
const root = document.getElementById('root');
const source = root.dataset.source;
const marketplaceId = root.dataset.marketplaceId;
const pluginName = root.dataset.pluginName;
const entityId = root.dataset.entityId;
const apiURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}`
: `/api/marketplace/flea/${encodeURIComponent(entityId)}/detail`;
const installURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}/install`
: `/api/store/entities/${encodeURIComponent(entityId)}/install`;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function fmtBytes(n) {
if (n == null) return '—';
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
if (n < 1024*1024*1024) return (n/(1024*1024)).toFixed(1) + ' MB';
return (n/(1024*1024*1024)).toFixed(2) + ' GB';
}
function fmtRelative(iso) {
if (!iso) return '—';
const t = new Date(iso);
if (isNaN(t)) return iso;
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return days + ' days ago';
if (days < 365) return Math.floor(days/30) + ' months ago';
return Math.floor(days/365) + ' years ago';
}
function showError(status) {
document.getElementById('hero-tagline').textContent = '';
document.getElementById('lead-text').textContent = '';
const err = document.getElementById('error-msg');
const txt = document.getElementById('error-text');
if (status === 403) txt.textContent = 'You do not have access to this plugin. Ask your admin to grant your group access.';
else if (status === 404) txt.textContent = 'Plugin not found.';
else txt.textContent = 'Failed to load plugin (' + status + ').';
err.hidden = false;
}
let res;
try { res = await fetch(apiURL); }
catch (e) { showError(0); return; }
if (!res.ok) { showError(res.status); return; }
const d = await res.json();
// ── Hero ────────────────────────────────────────────────────────
document.getElementById('crumb-mid').textContent =
d.source === 'curated' ? (d.marketplace_name || d.marketplace_id) : 'Flea Market';
document.getElementById('crumb-name').textContent = d.manifest_name || d.plugin_name;
document.title = `${d.manifest_name || d.plugin_name} — Marketplace`;
document.getElementById('hero-name').textContent = d.manifest_name || d.plugin_name;
document.getElementById('hero-tagline').textContent = d.description || '';
const curator = document.getElementById('hero-curator');
if (d.source === 'curated') {
if (d.author_name && d.author_name !== 'owner_todo') {
curator.innerHTML = 'Curator: <strong>' + esc(d.author_name) + '</strong>';
} else {
curator.innerHTML = 'Curator: <span class="todo">owner_todo</span>';
}
} else {
curator.innerHTML = 'by <strong>' + esc(d.author_name || '') + '</strong>';
}
const pills = document.getElementById('hero-pills');
const pillBits = [];
if (d.category) pillBits.push(`<span class="pill cat">${esc(d.category)}</span>`);
if (d.source === 'curated')
pillBits.push(`<span class="pill curated">Curated</span>`);
else
pillBits.push(`<span class="pill flea">Flea</span>`);
const verLabel = d.source === 'curated'
? `${esc(d.marketplace_name || d.marketplace_id)} v${esc(d.version || '')}`
: `v${esc(d.version || '')}`;
if (d.version) pillBits.push(`<span class="pill ver">${verLabel}</span>`);
if (d.updated_at) pillBits.push(`<span class="pill muted">Updated ${esc(fmtRelative(d.updated_at))}</span>`);
pills.innerHTML = pillBits.join('');
// Cover photo
const photoEl = document.getElementById('hero-photo');
if (d.cover_photo_url) {
// Same fallback pattern as the marketplace card grid — when the cover
// 404s (missing internal file, or external mirror failed and the
// pass-through URL is dead), restore the initials placeholder so the
// hero looks identical to the no-cover case rather than showing the
// browser's broken-image icon.
const initials = photoEl.textContent || 'PL';
photoEl.innerHTML = `<img src="${esc(d.cover_photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent=this.dataset.fallback;"
data-fallback="${esc(initials)}">`;
} else {
photoEl.textContent = 'PL';
}
// Install button (only render the action when API returned a value)
const btn = document.getElementById('install-btn');
btn.hidden = false;
function renderInstallBtn(installed) {
btn.dataset.installed = installed ? '1' : '0';
btn.classList.toggle('installed', installed);
btn.textContent = installed ? '✓ In your stack' : '+ Add to my stack';
}
renderInstallBtn(!!d.installed);
// v32+ quarantine: when the entity is non-approved (only owner +
// admin land here in that state — gated server-side), disable the
// install button with a gray inert style + tooltip. The API also
// refuses POST /install with `entity_not_approved` so a clever user
// who toggles the disabled attribute in devtools still hits a 409.
if (d.visibility_status && d.visibility_status !== 'approved') {
btn.disabled = true;
btn.title = 'This submission is not approved yet — install is disabled until checks pass.';
btn.textContent = '+ Add to my stack (unavailable while under review)';
btn.style.background = '#e5e7eb';
btn.style.color = '#6b7280';
btn.style.cursor = 'not-allowed';
}
// Post-add hint panel — fires only on the *transition* into 'installed'
// and only when the user hasn't permanently dismissed it. The dismiss
// flag lives in localStorage so a returning user who already understands
// the two-step model isn't pestered. Re-shown to nontechnical users
// who hit "+ Add to my stack" for the first time on a new browser/laptop.
const HINT_DISMISS_KEY = 'mp.stack-hint.dismissed.v1';
const hintEl = document.getElementById('stack-hint');
function showHint() {
if (localStorage.getItem(HINT_DISMISS_KEY) === '1') return;
hintEl.hidden = false;
}
document.getElementById('stack-hint-dismiss').addEventListener('click', () => {
localStorage.setItem(HINT_DISMISS_KEY, '1');
hintEl.hidden = true;
});
document.getElementById('stack-hint-copy').addEventListener('click', async (ev) => {
const copyBtn = ev.currentTarget;
try {
await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = copyBtn.textContent;
copyBtn.classList.add('copied');
copyBtn.textContent = 'Copied';
setTimeout(() => { copyBtn.textContent = orig; copyBtn.classList.remove('copied'); }, 1500);
} catch { /* clipboard blocked — chip text remains selectable */ }
});
btn.addEventListener('click', async () => {
const installed = btn.dataset.installed === '1';
const method = installed ? 'DELETE' : 'POST';
const r = await fetch(installURL, { method });
if (!r.ok) { alert('Action failed (' + r.status + ')'); return; }
renderInstallBtn(!installed);
if (!installed) showHint(); // newly added → reveal next-steps
else hintEl.hidden = true; // removed → hide stale hint
});
// v35 owner / admin delete handlers. Two paths:
// * Archive (soft) — DELETE /api/store/entities/{id}, default body.
// Hides from browse, blocks new installs, KEEPS existing
// user_store_installs serving the bundle.
// * Hard delete (admin only) — DELETE /api/store/entities/{id}?hard=true.
// Drops the bundle from disk + removes existing installs.
// Existing users lose the plugin on next sync. Confirmation
// mentions the install count so admin doesn't nuke a popular
// plugin by accident.
function bindDelete(id, opts) {
const btn = document.getElementById(id);
if (!btn || root.dataset.source !== 'flea' || !root.dataset.entityId) return;
btn.addEventListener('click', async () => {
if (!confirm(opts.confirm)) return;
const url = `/api/store/entities/${encodeURIComponent(root.dataset.entityId)}${opts.hard ? '?hard=true' : ''}`;
const r = await fetch(url, { method: 'DELETE' });
if (!r.ok) {
alert((opts.hard ? 'Hard delete' : 'Archive') + ' failed (' + r.status + ')');
return;
}
window.location = '/marketplace?tab=flea';
});
}
bindDelete('owner-archive-btn', {
hard: false,
confirm: 'Archive this entity? It disappears from browse + nobody can install it. Existing installs keep working.',
});
bindDelete('owner-hard-delete-btn', {
hard: true,
confirm: 'HARD DELETE — this drops the bundle and removes ALL existing installs. Users who already added it will lose the plugin on next sync. Continue?',
});
// ── What it does ────────────────────────────────────────────────
const lead = document.getElementById('lead-text');
if (d.description && d.description.trim()) {
lead.textContent = d.description;
} else {
document.getElementById('panel-what').hidden = true;
}
// ── v32: demo video (agnes-metadata.json :: video_url) ─────────
// Detection logic lives in the shared partial so item_detail.html and
// this page stay in lockstep on YouTube nocookie / Vimeo / mp4 / link
// fallback handling.
if (d.video_url) {
document.getElementById('video-wrap').innerHTML =
buildVideoEmbed(d.video_url, esc);
document.getElementById('panel-video').hidden = false;
}
// ── v32: doc_links (agnes-metadata.json :: doc_links[]) ────────
// The user contract: every entry in this list is a real downloadable
// document Agnes can serve (PDF / Markdown / plain text). Sync pipeline
// already dropped HTML pages, 404s, SSRF-blocked URLs, and any internal
// path with the wrong extension — what remains is one shape: clickable
// link, browser starts a download. No badges, no chips, no source
// distinction in the UI (where the file lives is a sync-time concern,
// not something the analyst clicking the link cares about).
//
// The `download` attribute is honored for same-origin URLs (which all of
// ours are — both /doc/ and /mirrored/ are Agnes endpoints). The server
// also sets Content-Disposition: attachment, so even cross-origin tools
// that ignore the attribute still trigger a download.
const docLinks = (d.docs && d.docs.length) ? d.docs : [];
if (docLinks.length) {
const list = document.getElementById('doc-link-list');
list.innerHTML = docLinks.map(doc => {
const url = doc.url || '#';
return `<li>
<a href="${esc(url)}" download>${esc(doc.name)}</a>
</li>`;
}).join('');
document.getElementById('panel-docs').hidden = false;
}
// ── Details ─────────────────────────────────────────────────────
// Render only rows that have a real value — missing/null/owner_todo
// entries get hidden so the panel stays compact.
const detailRows = [];
const slugVal = d.source === 'curated' ? d.marketplace_id : d.entity_id;
if (slugVal) {
detailRows.push(`<div class="row"><dt>Slug</dt><dd class="mono">${esc(slugVal)}</dd></div>`);
}
if (d.released_at) {
detailRows.push(`<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>`);
}
if (d.bundle_size != null) {
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
}
// Owner: render real value when present; for curated keep the
// `owner_todo` placeholder visible as a reminder to wire up curator
// metadata (intentional — flea falls through silently).
if (d.author_name && d.author_name !== 'owner_todo') {
detailRows.push(`<div class="row"><dt>Owner</dt><dd>${esc(d.author_name)}</dd></div>`);
} else if (d.source === 'curated') {
detailRows.push(`<div class="row"><dt>Owner</dt><dd><span class="todo">owner_todo</span></dd></div>`);
}
const detailsEl = document.getElementById('details-list');
if (detailRows.length) {
detailsEl.innerHTML = detailRows.join('');
} else {
document.getElementById('panel-details').hidden = true;
}
// ── Internal structure ─────────────────────────────────────────
function buildCardSection(title, items, type) {
if (!items || !items.length) return '';
// Inner cards render the agnes-metadata cover photo when present
// (mirrored OK for external, file exists for internal); otherwise the
// initials fall through ("SK" / "AG") on the colored gradient. Same
// onerror fallback as the top-level cards so a missing file doesn't
// produce the browser's broken-image icon.
const initials = type === 'skill' ? 'SK' : 'AG';
const cards = items.map(it => {
const photoMarkup = it.cover_photo_url
? `<div class="photo"><img src="${esc(it.cover_photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent='${initials}';"></div>`
: `<div class="photo">${initials}</div>`;
return `
<a class="inner-card" data-type="${type}" href="${esc(it.detail_url || '#')}">
${photoMarkup}
<div class="body">
<span class="type-badge">${type}</span>
<div class="name">${esc(it.name)}</div>
<div class="desc">${esc(it.description || '')}</div>
</div>
</a>`;
}).join('');
return `
<div class="substruct">
<div class="head">
<h3>${title}</h3>
<span class="count">${items.length} ${type}${items.length === 1 ? '' : 's'}</span>
</div>
<div class="inner-grid">${cards}</div>
</div>`;
}
function buildTableSection(title, items, columns) {
if (!items || !items.length) return '';
const head = columns.map(c => `<th${c.width ? ' style="width:'+c.width+'px"' : ''}>${esc(c.label)}</th>`).join('');
const rows = items.map(it => columns.map(c => {
const v = it[c.key];
if (c.cls === 'cell-name') return `<td class="cell-name">${esc(v || '')}</td>`;
if (c.cls === 'cell-event' || c.cls === 'cell-type') return `<td class="${c.cls}">${esc(v || '—')}</td>`;
return `<td class="cell-desc">${esc(v || '')}</td>`;
}).join('')).map(tr => `<tr>${tr}</tr>`).join('');
return `
<div class="substruct">
<div class="head">
<h3>${title}</h3>
<span class="count">${items.length} ${title.toLowerCase()}</span>
</div>
<table>
<thead><tr>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
const hasAny = (d.skills && d.skills.length)
|| (d.agents && d.agents.length)
|| (d.commands && d.commands.length)
|| (d.hooks && d.hooks.length)
|| (d.mcps && d.mcps.length);
if (hasAny) {
document.getElementById('structure').hidden = false;
document.getElementById('struct-skills').innerHTML = buildCardSection('Skills', d.skills, 'skill');
document.getElementById('struct-agents').innerHTML = buildCardSection('Agents', d.agents, 'agent');
document.getElementById('struct-commands').innerHTML = buildTableSection('Commands', d.commands, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'description', label: 'Description' },
]);
document.getElementById('struct-hooks').innerHTML = buildTableSection('Hooks', d.hooks, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'event', label: 'Event', cls: 'cell-event', width: 180 },
{ key: 'description', label: 'Description' },
]);
document.getElementById('struct-mcps').innerHTML = buildTableSection('MCP servers', d.mcps, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'type', label: 'Type', cls: 'cell-type', width: 180 },
{ key: 'description', label: 'Description' },
]);
}
})();
</script>
{% endblock %}