* 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>
161 lines
5.9 KiB
HTML
161 lines
5.9 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ guide_title }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
{# Publication-flow guide. Reached from the action-row CTA on /marketplace
|
|
("Submit a plugin" on the curated tab; the flea variant is reachable
|
|
only as a fallback since flea has a self-service +Upload button).
|
|
|
|
Two distinct flows, one template — `guide_kind` switches the body. #}
|
|
<style>
|
|
.guide-page {
|
|
max-width: 760px; margin: 0 auto;
|
|
padding: 16px 24px 64px;
|
|
color: var(--text-primary);
|
|
}
|
|
.guide-page .back {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
margin-bottom: 12px;
|
|
color: var(--text-secondary); font-size: 13px;
|
|
text-decoration: none;
|
|
}
|
|
.guide-page .back:hover { color: var(--primary); }
|
|
|
|
.guide-page h1 {
|
|
font-size: 26px; font-weight: 700;
|
|
margin: 0 0 8px;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
.guide-page .lede {
|
|
font-size: 15px; color: var(--text-secondary);
|
|
line-height: 1.6; margin: 0 0 28px;
|
|
}
|
|
|
|
/* ── Steps ──────────────────────────────────────────────────────── */
|
|
.guide-steps {
|
|
list-style: none; padding: 0; margin: 0 0 32px;
|
|
counter-reset: step;
|
|
}
|
|
.guide-steps li {
|
|
position: relative;
|
|
padding: 14px 18px 14px 56px;
|
|
margin-bottom: 10px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
counter-increment: step;
|
|
line-height: 1.55;
|
|
font-size: 14px;
|
|
}
|
|
.guide-steps li::before {
|
|
content: counter(step);
|
|
position: absolute; left: 16px; top: 14px;
|
|
width: 28px; height: 28px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: var(--primary); color: #fff;
|
|
border-radius: 999px;
|
|
font-size: 13px; font-weight: 700;
|
|
}
|
|
.guide-steps li strong { color: var(--text-primary); font-weight: 600; }
|
|
.guide-steps li code {
|
|
font-family: var(--font-mono); font-size: 12px;
|
|
background: rgba(0,0,0,0.05); padding: 1px 5px;
|
|
border-radius: 3px; color: var(--text-primary);
|
|
}
|
|
.guide-steps li .note {
|
|
display: block; margin-top: 4px;
|
|
font-size: 12.5px; color: var(--text-secondary);
|
|
}
|
|
|
|
/* Flea variant uses purple step bubbles (matches flea badge accent). */
|
|
.guide-page[data-kind="flea"] .guide-steps li::before { background: #6D28D9; }
|
|
|
|
/* ── Section block ──────────────────────────────────────────────── */
|
|
.guide-section {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 22px 26px;
|
|
margin-bottom: 18px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
}
|
|
.guide-section h2 {
|
|
font-size: 16px; font-weight: 700;
|
|
margin: 0 0 12px;
|
|
}
|
|
.guide-section p {
|
|
margin: 0 0 10px; font-size: 14px; line-height: 1.6;
|
|
color: var(--text-secondary);
|
|
}
|
|
.guide-section p:last-child { margin-bottom: 0; }
|
|
.guide-section p strong { color: var(--text-primary); font-weight: 600; }
|
|
.guide-section code {
|
|
font-family: var(--font-mono); font-size: 12px;
|
|
background: rgba(0,0,0,0.05); padding: 1px 5px;
|
|
border-radius: 3px; color: var(--text-primary);
|
|
}
|
|
|
|
/* ── CTA bar ────────────────────────────────────────────────────── */
|
|
.guide-cta {
|
|
display: flex; gap: 10px; flex-wrap: wrap;
|
|
margin-top: 24px;
|
|
}
|
|
.guide-cta a {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 9px 16px;
|
|
border: 1px solid var(--border);
|
|
background: var(--card-bg);
|
|
color: var(--text-primary);
|
|
border-radius: 8px;
|
|
font-size: 13px; font-weight: 500;
|
|
text-decoration: none;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.guide-cta a:hover { border-color: var(--primary); color: var(--primary); }
|
|
.guide-cta a.primary {
|
|
background: var(--primary); color: #fff; border-color: var(--primary);
|
|
}
|
|
.guide-cta a.primary:hover { background: var(--primary-dark, #0056A3); color: #fff; }
|
|
.guide-page[data-kind="flea"] .guide-cta a.primary {
|
|
background: #6D28D9; border-color: #6D28D9;
|
|
}
|
|
.guide-page[data-kind="flea"] .guide-cta a.primary:hover {
|
|
background: #5B21B6; border-color: #5B21B6;
|
|
}
|
|
</style>
|
|
|
|
<div class="guide-page" data-kind="{{ guide_kind }}">
|
|
<a class="back" href="/marketplace?tab={{ guide_kind }}">← Back to {{ 'Curated Marketplace' if guide_kind == 'curated' else 'Flea Market' }}</a>
|
|
<h1>{{ guide_title }}</h1>
|
|
|
|
{% if guide_kind == 'curated' %}
|
|
{# Curated submission guide — body intentionally left minimal; full
|
|
content to be authored separately. The CTA buttons at the bottom of
|
|
the page still let the user jump back to the Curated tab or browse
|
|
the Flea Market as a self-service alternative. #}
|
|
<p class="lede">
|
|
The Curated Marketplace is publish-by-curator only. To get your skill,
|
|
agent, or plugin listed, hand it off to a curator who reviews and
|
|
publishes it on your behalf.
|
|
</p>
|
|
{% else %}
|
|
{# Flea publishing guide — body intentionally left minimal; full content
|
|
to be authored separately. The CTA buttons at the bottom of the page
|
|
still let the user jump to /store/new or browse the Flea Market. #}
|
|
<p class="lede">
|
|
The Flea Market is self-service. Anyone in the company can upload a
|
|
skill, agent, or plugin.
|
|
</p>
|
|
{% endif %}
|
|
|
|
<div class="guide-cta">
|
|
<a class="primary" href="{{ '/store/new' if guide_kind == 'flea' else '/marketplace?tab=curated' }}">
|
|
{{ '+ Upload to Flea Market' if guide_kind == 'flea' else '← Back to Curated' }}
|
|
</a>
|
|
<a href="/marketplace?tab={{ 'flea' if guide_kind == 'curated' else 'curated' }}">
|
|
{{ 'Browse Flea Market' if guide_kind == 'curated' else 'Browse Curated' }} →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|