Adds a community-driven Store where any authenticated user uploads
skills/agents/plugins as ZIPs, plus /my-ai-stack as the per-user
composition view. The served Claude Code marketplace is now:
(admin_granted ∖ opt_outs) ∪ store_installs
Skill + agent installs are merged into a single `agnes-store-bundle`
plugin in the served marketplace; type=plugin uploads stay standalone.
Names are suffixed with `-by-<owner-username>` at upload time so two
owners can use the same display name without colliding in Claude Code's
flat skill/agent namespace.
Schema v23 → v24 adds three tables:
- store_entities — community-uploaded skills/agents/plugins
- user_store_installs — what each user has chosen to install
- user_plugin_optouts — opt-out overlay on top of admin grants
Admin grant-delete drops every user's opt-out for that plugin so
re-grant resets cleanly to enabled (no sticky personal preference).
UI:
- /store — e-commerce-style listing with type/category/owner
filters, search, pagination, owner-aware [Install]
buttons, clickable cards
- /store/new — 2-step upload wizard with drag & drop, preview
validation (POST /api/store/entities/preview), docs
multi-upload, photo + video URL
- /store/{id} — detail page with hero, file list, docs, owner
actions (Edit/Delete) for the uploader
- /my-ai-stack — Granted plugins (toggle opt-out) + From the Store
(uninstall) sections
- Admin nav: Marketplaces moved into Admin dropdown, renamed to
"Curated Marketplaces"
Validation hardening: type-mismatch guards reject skill ZIP uploaded as
agent (or vice versa), and plugin ZIPs masquerading as skills/agents.
Human-readable error messages mapped client-side from machine codes.
Cross-source naming: Store entity-id-prefixed dirs (`plugins/store-<id>/`)
plus the bundle (`plugins/store-bundle/`) avoid collisions with admin
marketplaces (whose `store` slug is reserved by `is_valid_slug`).
Bundle composition is content-hashed at serve time — install/uninstall
or owner re-upload bumps the bundle's plugin.json `version`, so Claude
Code's auto-update toggle picks up changes.
Tests: 50+ new tests across naming, repositories, filter (admin ∪ store
∪ bundle), API (upload/install/uninstall/delete/preview/docs), end-to-end
marketplace.zip with bundle merging.
404 lines
15 KiB
HTML
404 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}My AI Stack — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.container:has(.stack-page) { max-width: 1280px; margin: 0 auto; padding: 16px 32px 48px; }
|
|
.container:has(.stack-page) > main { margin: 0; padding: 0; }
|
|
|
|
/* ── Page header (slim — no gradient) ──────────────────────────── */
|
|
/*
|
|
* Skipped the big blue hero used on Store / Upload because My AI Stack
|
|
* is a control/settings surface, not a browse one. A slim heading keeps
|
|
* the focus on the two long lists below.
|
|
*/
|
|
.stack-header {
|
|
margin-bottom: 20px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.stack-header h1 {
|
|
margin: 0; font-size: 24px; font-weight: var(--font-bold);
|
|
color: var(--text-primary); letter-spacing: -0.3px;
|
|
}
|
|
.stack-header .sub {
|
|
margin: 6px 0 0; font-size: 13px;
|
|
color: var(--text-secondary); line-height: 1.6;
|
|
}
|
|
.stack-header .sub a { color: var(--primary); text-decoration: none; }
|
|
.stack-header .sub a:hover { text-decoration: underline; }
|
|
|
|
/* ── Section card (same visual as store_detail.section) ────────── */
|
|
.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: 12px; flex-wrap: wrap;
|
|
}
|
|
.section-header .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
.section-header .eyebrow {
|
|
font-size: 10px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.8px;
|
|
color: var(--primary);
|
|
}
|
|
.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-hint {
|
|
padding: 8px 24px 0;
|
|
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
|
|
}
|
|
.section-body { padding: 14px 24px 22px; }
|
|
|
|
/* ── List items ────────────────────────────────────────────────── */
|
|
.stack-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.stack-row {
|
|
display: flex; align-items: center; gap: 16px;
|
|
padding: 12px 14px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 10px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.stack-row:hover { border-color: var(--primary); }
|
|
.stack-row .meta { flex: 1; min-width: 0; }
|
|
.stack-row .name {
|
|
font-weight: var(--font-semibold); color: var(--text-primary);
|
|
font-size: var(--text-base);
|
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
}
|
|
.stack-row .name a {
|
|
color: inherit; text-decoration: none;
|
|
}
|
|
.stack-row .name a:hover { color: var(--primary); }
|
|
.stack-row .pill {
|
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
font-size: 10px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
background: var(--primary-light); color: var(--primary);
|
|
}
|
|
.stack-row .pill.slug {
|
|
background: var(--border-light); color: var(--text-secondary);
|
|
font-family: var(--font-mono); text-transform: none; letter-spacing: 0;
|
|
}
|
|
.stack-row .desc {
|
|
font-size: 12px; color: var(--text-secondary);
|
|
margin-top: 4px; line-height: 1.5;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.stack-row .invocation {
|
|
font-family: var(--font-mono); font-size: 11px;
|
|
color: var(--primary); margin-top: 4px;
|
|
}
|
|
.stack-row .by { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
|
|
|
|
/* ── Toggle switch ─────────────────────────────────────────────── */
|
|
.switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; cursor: pointer; }
|
|
.switch input { opacity: 0; width: 0; height: 0; }
|
|
.switch .slider {
|
|
position: absolute; inset: 0;
|
|
background: var(--border); border-radius: 22px;
|
|
transition: 0.15s; cursor: pointer;
|
|
}
|
|
.switch .slider::before {
|
|
content: ""; position: absolute; height: 16px; width: 16px;
|
|
left: 3px; bottom: 3px;
|
|
background: #fff; border-radius: 50%; transition: 0.15s;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
|
}
|
|
.switch input:checked + .slider { background: var(--primary); }
|
|
.switch input:checked + .slider::before { transform: translateX(18px); }
|
|
|
|
/* ── Buttons ───────────────────────────────────────────────────── */
|
|
.btn-uninstall {
|
|
appearance: none; padding: 7px 14px;
|
|
border: 1px solid rgba(234, 88, 12, 0.3);
|
|
background: var(--surface); color: var(--error);
|
|
border-radius: 8px;
|
|
font-size: 12px; font-weight: var(--font-medium); cursor: pointer;
|
|
font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-uninstall:hover {
|
|
background: rgba(234, 88, 12, 0.08);
|
|
border-color: var(--error);
|
|
}
|
|
|
|
/* ── Store cards (compact — 4 per row at full width) ───────────── */
|
|
.store-grid {
|
|
display: grid; gap: 12px;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
@media (max-width: 1024px) { .store-grid { grid-template-columns: repeat(3, 1fr); } }
|
|
@media (max-width: 760px) { .store-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
@media (max-width: 480px) { .store-grid { grid-template-columns: 1fr; } }
|
|
|
|
.store-card {
|
|
display: flex; flex-direction: column;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 10px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
overflow: hidden; transition: all 0.15s ease;
|
|
cursor: pointer;
|
|
}
|
|
.store-card:hover {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.1);
|
|
transform: translateY(-1px);
|
|
}
|
|
.store-card .photo {
|
|
width: 100%; height: 96px; object-fit: cover;
|
|
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--primary);
|
|
font-size: 22px; font-weight: var(--font-bold);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.store-card .body {
|
|
padding: 10px 12px; flex: 1;
|
|
display: flex; flex-direction: column; gap: 4px;
|
|
}
|
|
.store-card .type-badge {
|
|
display: inline-block; padding: 1px 7px; border-radius: 4px;
|
|
background: var(--primary-light); color: var(--primary);
|
|
font-size: 9px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.4px;
|
|
align-self: flex-start;
|
|
}
|
|
.store-card .name {
|
|
font-weight: var(--font-semibold); color: var(--text-primary);
|
|
font-size: 13px; line-height: 1.3;
|
|
overflow: hidden; text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.store-card .by-line {
|
|
font-size: 10px; color: var(--text-secondary);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.store-card .desc {
|
|
font-size: 11px; color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.store-card .invocation {
|
|
font-family: var(--font-mono); font-size: 10px;
|
|
color: var(--primary);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.store-card .footer {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 6px;
|
|
padding: 8px 12px; border-top: 1px solid var(--border-light);
|
|
font-size: 10px; color: var(--text-secondary);
|
|
}
|
|
.store-card .footer .meta {
|
|
flex: 1; min-width: 0;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.store-card .footer button {
|
|
appearance: none; padding: 5px 10px; border-radius: 6px;
|
|
border: 1px solid rgba(234, 88, 12, 0.3);
|
|
background: var(--surface); color: var(--error);
|
|
font-size: 11px; font-weight: var(--font-medium); cursor: pointer;
|
|
font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.store-card .footer button:hover {
|
|
background: rgba(234, 88, 12, 0.08);
|
|
border-color: var(--error);
|
|
}
|
|
|
|
/* ── Empty state ───────────────────────────────────────────────── */
|
|
.empty {
|
|
text-align: center; padding: 36px 20px;
|
|
color: var(--text-secondary); font-size: 13px;
|
|
background: var(--background);
|
|
border: 1px dashed var(--border); border-radius: 10px;
|
|
}
|
|
.empty a { color: var(--primary); text-decoration: none; font-weight: var(--font-medium); }
|
|
.empty a:hover { text-decoration: underline; }
|
|
</style>
|
|
|
|
<div class="stack-page">
|
|
<header class="stack-header">
|
|
<h1>My AI Stack</h1>
|
|
<p class="sub">What ships into your Claude Code marketplace right now.
|
|
Toggle a granted plugin off if you don't want it; uninstall a Store
|
|
entity to drop it from your stack. Browse the
|
|
<a href="/store">Store</a> for community uploads.</p>
|
|
</header>
|
|
|
|
<section class="section">
|
|
<div class="section-header">
|
|
<div class="titles">
|
|
<span class="eyebrow">Curated marketplace</span>
|
|
<h2>Granted plugins</h2>
|
|
</div>
|
|
<span class="count" id="curated-count">0</span>
|
|
</div>
|
|
<div class="section-hint">
|
|
Plugins your administrator has granted to one of your groups. Default is
|
|
enabled. Switching off writes a personal opt-out — admin revoking the
|
|
grant later resets everyone back to enabled.
|
|
</div>
|
|
<div class="section-body">
|
|
<div id="curated-list" class="stack-list"></div>
|
|
<div id="curated-empty" class="empty" hidden>No plugins granted yet.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="section-header">
|
|
<div class="titles">
|
|
<span class="eyebrow">Community store</span>
|
|
<h2>From the Store</h2>
|
|
</div>
|
|
<span class="count" id="store-count">0</span>
|
|
</div>
|
|
<div class="section-hint">
|
|
Skills, agents, and plugins you've installed from the community Store.
|
|
</div>
|
|
<div class="section-body">
|
|
<div id="store-list" class="store-grid"></div>
|
|
<div id="store-empty" class="empty" hidden>
|
|
Nothing installed yet — visit the <a href="/store">Store</a> to browse.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
async function loadStack() {
|
|
const res = await fetch('/api/my-stack');
|
|
if (!res.ok) {
|
|
alert('Failed to load: ' + res.status);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
renderCurated(data.curated);
|
|
renderStore(data.store);
|
|
}
|
|
|
|
function renderCurated(items) {
|
|
const list = document.getElementById('curated-list');
|
|
const empty = document.getElementById('curated-empty');
|
|
document.getElementById('curated-count').textContent = items.length;
|
|
list.innerHTML = '';
|
|
if (!items.length) { empty.hidden = false; return; }
|
|
empty.hidden = true;
|
|
for (const p of items) {
|
|
const row = document.createElement('div');
|
|
row.className = 'stack-row';
|
|
const versionBit = p.version ? `<span class="pill slug">v${escapeHtml(p.version)}</span>` : '';
|
|
row.innerHTML = `
|
|
<div class="meta">
|
|
<div class="name">
|
|
<span class="pill slug">${escapeHtml(p.marketplace_slug)}</span>
|
|
${escapeHtml(p.plugin_name)}
|
|
${versionBit}
|
|
</div>
|
|
<div class="desc">${escapeHtml(p.description || '')}</div>
|
|
</div>
|
|
<label class="switch" title="${p.enabled ? 'Enabled' : 'Opted out'}">
|
|
<input type="checkbox" ${p.enabled ? 'checked' : ''}
|
|
data-mp="${escapeAttr(p.marketplace_id)}"
|
|
data-name="${escapeAttr(p.plugin_name)}">
|
|
<span class="slider"></span>
|
|
</label>`;
|
|
list.appendChild(row);
|
|
}
|
|
list.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
|
cb.addEventListener('change', async (e) => {
|
|
const cb = e.target;
|
|
const enabled = cb.checked;
|
|
const mp = cb.dataset.mp;
|
|
const name = cb.dataset.name;
|
|
const res = await fetch(
|
|
`/api/my-stack/curated/${encodeURIComponent(mp)}/${encodeURIComponent(name)}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({enabled})
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
cb.checked = !enabled;
|
|
alert('Failed to update');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderStore(items) {
|
|
const list = document.getElementById('store-list');
|
|
const empty = document.getElementById('store-empty');
|
|
document.getElementById('store-count').textContent = items.length;
|
|
list.innerHTML = '';
|
|
if (!items.length) { empty.hidden = false; return; }
|
|
empty.hidden = true;
|
|
for (const e of items) {
|
|
const card = document.createElement('div');
|
|
card.className = 'store-card';
|
|
card.dataset.href = `/store/${encodeURIComponent(e.entity_id)}`;
|
|
const photoMarkup = e.photo_url
|
|
? `<img class="photo" src="${escapeAttr(e.photo_url)}" alt="">`
|
|
: `<div class="photo">${escapeHtml(typeIcon(e.type))}</div>`;
|
|
const ownerLabel = e.owner_display_name || e.owner_username;
|
|
card.innerHTML = `
|
|
${photoMarkup}
|
|
<div class="body">
|
|
<span class="type-badge">${escapeHtml(e.type)}</span>
|
|
<div class="name">${escapeHtml(e.name)}</div>
|
|
<div class="by-line">by ${escapeHtml(ownerLabel)}</div>
|
|
<div class="desc">${escapeHtml(e.description || '')}</div>
|
|
<div class="invocation">/${escapeHtml(e.invocation_name)}</div>
|
|
</div>
|
|
<div class="footer">
|
|
<span class="meta">${e.install_count} installed${e.category ? ' · ' + escapeHtml(e.category) : ''}</span>
|
|
<button data-id="${escapeAttr(e.entity_id)}">Uninstall</button>
|
|
</div>`;
|
|
card.addEventListener('click', () => {
|
|
window.location = card.dataset.href;
|
|
});
|
|
list.appendChild(card);
|
|
}
|
|
list.querySelectorAll('button[data-id]').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.id;
|
|
if (!confirm('Uninstall this entity from your stack?')) return;
|
|
const res = await fetch(`/api/store/entities/${encodeURIComponent(id)}/install`, {method: 'DELETE'});
|
|
if (!res.ok) { alert('Failed'); return; }
|
|
loadStack();
|
|
});
|
|
});
|
|
}
|
|
|
|
function typeIcon(t) {
|
|
if (t === 'skill') return 'SK';
|
|
if (t === 'agent') return 'AG';
|
|
if (t === 'plugin') return 'PL';
|
|
return '?';
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s); }
|
|
|
|
loadStack();
|
|
</script>
|
|
{% endblock %}
|