agnes-the-ai-analyst/app/web/templates/my_ai_stack.html
Minas Arustamyan d5a7c9ad79 feat(store): /store + /my-ai-stack — community marketplace + per-user composition
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.
2026-05-05 02:53:49 +02:00

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function escapeAttr(s) { return escapeHtml(s); }
loadStack();
</script>
{% endblock %}