* 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>
265 lines
10 KiB
HTML
265 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ entity.name }} — Store{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.container:has(.store-detail) { max-width: 980px; margin: 0 auto; padding: 16px 32px 48px; }
|
|
.container:has(.store-detail) > main { margin: 0; padding: 0; }
|
|
|
|
.breadcrumb {
|
|
font-size: 12px; color: var(--text-secondary); margin-bottom: 12px;
|
|
}
|
|
.breadcrumb a { color: var(--primary); text-decoration: none; }
|
|
.breadcrumb a:hover { text-decoration: underline; }
|
|
|
|
/* ── Hero ──────────────────────────────────────────────────────── */
|
|
.detail-hero {
|
|
position: relative;
|
|
display: grid; grid-template-columns: 240px 1fr; gap: 28px;
|
|
margin-bottom: 24px; padding: 28px 32px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 12px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
}
|
|
.owner-actions {
|
|
position: absolute; top: 16px; right: 16px;
|
|
display: flex; gap: 8px;
|
|
}
|
|
.owner-actions a, .owner-actions button {
|
|
appearance: none; padding: 7px 14px; border-radius: 8px;
|
|
border: 1px solid var(--border); background: var(--surface);
|
|
color: var(--text-secondary); font-size: 12px;
|
|
font-weight: var(--font-medium); cursor: pointer; text-decoration: none;
|
|
font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
}
|
|
.owner-actions a:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-light); }
|
|
.owner-actions .delete { color: var(--error); border-color: rgba(234, 88, 12, 0.3); }
|
|
.owner-actions .delete:hover { background: rgba(234, 88, 12, 0.08); border-color: var(--error); color: var(--error); }
|
|
|
|
.detail-hero .photo {
|
|
width: 100%; aspect-ratio: 1; border-radius: 10px;
|
|
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--primary); font-size: 56px; font-weight: var(--font-bold);
|
|
object-fit: cover;
|
|
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.12);
|
|
}
|
|
|
|
.detail-hero h1 {
|
|
margin: 8px 0 6px; font-size: 26px; font-weight: var(--font-bold);
|
|
color: var(--text-primary); letter-spacing: -0.3px;
|
|
}
|
|
.detail-hero .type-row {
|
|
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
|
font-size: 12px; color: var(--text-secondary);
|
|
}
|
|
.detail-hero .type-badge {
|
|
padding: 3px 10px; border-radius: 4px;
|
|
background: var(--primary-light); color: var(--primary);
|
|
font-size: 11px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
.detail-hero .meta-row {
|
|
display: flex; gap: 14px; flex-wrap: wrap;
|
|
font-size: 13px; color: var(--text-secondary);
|
|
margin: 12px 0 14px;
|
|
}
|
|
.detail-hero .meta-row .dot { color: var(--border); }
|
|
.detail-hero .invocation {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
padding: 10px 14px;
|
|
background: linear-gradient(180deg, var(--primary-light), rgba(0,115,209,0.04));
|
|
border: 1px solid rgba(0, 115, 209, 0.2); border-radius: 8px;
|
|
font-family: var(--font-mono); font-size: 13px; color: var(--primary);
|
|
margin-bottom: 18px;
|
|
}
|
|
.detail-hero .invocation::before { content: '/'; opacity: 0.6; }
|
|
|
|
.detail-hero .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
.actions .btn-primary {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
appearance: none; border: none; background: var(--primary);
|
|
color: #fff; padding: 11px 22px; border-radius: 8px;
|
|
font-size: 14px; font-weight: var(--font-semibold);
|
|
cursor: pointer; font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
}
|
|
.actions .btn-primary:hover {
|
|
background: var(--primary-dark);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.25);
|
|
}
|
|
.actions .btn-installed {
|
|
appearance: none; border: 1px solid rgba(16, 183, 127, 0.3);
|
|
background: rgba(16, 183, 127, 0.1); color: var(--success);
|
|
padding: 10px 20px; border-radius: 8px;
|
|
font-size: 14px; font-weight: var(--font-semibold);
|
|
cursor: pointer; font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
}
|
|
.actions .btn-installed:hover { background: rgba(16, 183, 127, 0.18); }
|
|
.actions .btn-link {
|
|
color: var(--text-secondary); text-decoration: none;
|
|
font-size: 13px; padding: 10px 12px;
|
|
border: 1px solid var(--border); border-radius: 8px;
|
|
font-family: var(--font-primary);
|
|
}
|
|
.actions .btn-link:hover { color: var(--primary); border-color: var(--primary); }
|
|
|
|
/* ── Section card ──────────────────────────────────────────────── */
|
|
.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;
|
|
}
|
|
.section-header {
|
|
padding: 18px 24px 0;
|
|
display: flex; align-items: center; gap: 10px;
|
|
}
|
|
.section-header h2 {
|
|
margin: 0; font-size: var(--text-md); font-weight: var(--font-semibold);
|
|
color: var(--text-primary);
|
|
}
|
|
.section-header .count {
|
|
font-size: 12px; color: var(--text-secondary);
|
|
background: var(--border-light);
|
|
padding: 2px 8px; border-radius: 999px;
|
|
}
|
|
.section-body { padding: 12px 24px 22px; }
|
|
.section-body .description {
|
|
font-size: 14px; line-height: 1.7;
|
|
color: var(--text-primary);
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.file-list { font-family: var(--font-mono); font-size: 12px; }
|
|
.file-list .file {
|
|
display: flex; justify-content: space-between; gap: 16px;
|
|
padding: 7px 0; border-bottom: 1px dashed var(--border-light);
|
|
color: var(--text-primary);
|
|
}
|
|
.file-list .file:last-child { border-bottom: none; }
|
|
.file-list .file .size { color: var(--text-secondary); flex-shrink: 0; }
|
|
.file-list .file a { color: var(--primary); text-decoration: none; }
|
|
.file-list .file a:hover { text-decoration: underline; }
|
|
|
|
.empty { color: var(--text-secondary); font-size: 13px; padding: 4px 0; }
|
|
|
|
@media (max-width: 720px) {
|
|
.detail-hero { grid-template-columns: 1fr; padding: 24px; }
|
|
.owner-actions { position: static; margin-bottom: 8px; }
|
|
}
|
|
</style>
|
|
|
|
<div class="store-detail">
|
|
<div class="breadcrumb"><a href="/store">Store</a> / {{ entity.name }}</div>
|
|
|
|
<div class="detail-hero">
|
|
{% if is_owner or is_admin %}
|
|
<div class="owner-actions">
|
|
<a href="#" id="edit-btn">Edit (coming soon)</a>
|
|
<button class="delete" id="delete-btn">Delete</button>
|
|
</div>
|
|
{% endif %}
|
|
{% if entity.photo_path %}
|
|
<img class="photo" src="/api/store/entities/{{ entity.id }}/photo" alt="">
|
|
{% else %}
|
|
<div class="photo">{{ entity.type[:2] | upper }}</div>
|
|
{% endif %}
|
|
<div>
|
|
<div class="type-row">
|
|
<span class="type-badge">{{ entity.type }}</span>
|
|
<span>v{{ entity.version }}</span>
|
|
{% if entity.category %}<span>· {{ entity.category }}</span>{% endif %}
|
|
</div>
|
|
<h1>{{ entity.name }}</h1>
|
|
<div class="meta-row">
|
|
<span>by <strong style="color:var(--text-primary);">{{ owner_display }}</strong></span>
|
|
<span class="dot">·</span>
|
|
<span>{{ entity.install_count }} installed</span>
|
|
<span class="dot">·</span>
|
|
<span>{{ entity.file_size | humanbytes }}</span>
|
|
</div>
|
|
<div class="invocation" title="Invoke in Claude Code">{{ invocation_name }}</div>
|
|
<div class="actions">
|
|
{% if is_installed %}
|
|
<button class="btn-installed" id="action-btn" data-action="uninstall">✓ In your stack — Remove</button>
|
|
{% else %}
|
|
<button class="btn-primary" id="action-btn" data-action="install">+ Add to my stack</button>
|
|
{% endif %}
|
|
{% if entity.video_url %}
|
|
<a class="btn-link" href="{{ entity.video_url }}" target="_blank" rel="noopener">▶ Watch video</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if entity.description %}
|
|
<div class="section">
|
|
<div class="section-header"><h2>Description</h2></div>
|
|
<div class="section-body">
|
|
<div class="description">{{ entity.description }}</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if entity.doc_paths %}
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<h2>Documentation</h2>
|
|
<span class="count">{{ entity.doc_paths | length }}</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="file-list">
|
|
{% for d in entity.doc_paths %}
|
|
{% set fname = d.split('/')[-1] %}
|
|
<div class="file">
|
|
<a href="/api/store/entities/{{ entity.id }}/docs/{{ fname }}" target="_blank" rel="noopener">{{ fname }}</a>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="section">
|
|
<div class="section-header">
|
|
<h2>Files</h2>
|
|
<span class="count">{{ files | length }}</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="file-list">
|
|
{% for f in files %}
|
|
<div class="file"><span>{{ f.path }}</span><span class="size">{{ f.size | humanbytes }}</span></div>
|
|
{% else %}
|
|
<div class="empty">No files on disk.</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const actionBtn = document.getElementById('action-btn');
|
|
if (actionBtn) {
|
|
actionBtn.addEventListener('click', async () => {
|
|
const action = actionBtn.dataset.action;
|
|
const method = action === 'install' ? 'POST' : 'DELETE';
|
|
const res = await fetch(`/api/store/entities/{{ entity.id }}/install`, {method});
|
|
if (!res.ok) { alert('Failed: ' + res.status); return; }
|
|
window.location.reload();
|
|
});
|
|
}
|
|
const delBtn = document.getElementById('delete-btn');
|
|
if (delBtn) {
|
|
delBtn.addEventListener('click', async () => {
|
|
if (!confirm('Permanently delete this entity? Anyone who has it installed will lose it.')) return;
|
|
const res = await fetch(`/api/store/entities/{{ entity.id }}`, {method: 'DELETE'});
|
|
if (!res.ok) { alert('Failed: ' + res.status); return; }
|
|
window.location = '/store';
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|