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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix v28 migration + post-rebase test fallout

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

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

* Harden curated skill/agent inner endpoints against path traversal

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

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

---------

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

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 %}