Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

marketplace-agents.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

marketplace-agents.tsxBlame1520 lines · 1 contributor
5ca514aClaude1/**
2 * Agent Marketplace UI + admin moderation queue.
3 *
4 * GET /marketplace/agents — catalog grid
5 * GET /marketplace/agents/publish — publisher submission form
6 * POST /marketplace/agents/publish — submit listing (pending_review)
7 * GET /marketplace/agents/:slug — listing detail + reviews
8 * POST /marketplace/agents/:slug/install — install on a repo
9 * POST /marketplace/agents/:slug/reviews — leave a review
10 * GET /admin/marketplace/queue — moderation queue (admin)
11 * POST /admin/marketplace/queue/:slug/:action — approve | reject
12 *
13 * Visual pattern lifted from `routes/marketplace.tsx` (gradient hairline
14 * hero + orb + eyebrow + verb-as-title + category pills + card grid).
15 * Every selector is scoped under `.amkt-*` so this surface can't bleed
16 * into the existing `.mkt-*` marketplace.
17 */
18
19import { Hono } from "hono";
20import { and, eq } from "drizzle-orm";
21import { db } from "../db";
22import { Layout } from "../views/layout";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { requireAdmin } from "../middleware/admin";
26import { audit } from "../lib/notify";
27import { renderMarkdown } from "../lib/markdown";
28import {
29 repositories,
30 users,
31 agentMarketplaceInstalls,
32} from "../db/schema";
33import {
34 MARKETPLACE_CATEGORIES,
35 PRICING_MODELS,
36 approveListing,
37 createListing,
38 fetchListingBySlug,
39 formatPrice,
40 getListing,
41 gradientForSlug,
42 installListing,
43 isValidCategory,
44 isValidPricingModel,
45 listListings,
46 listingInitials,
47 recordReview,
48 rejectListing,
49 uninstallListing,
50} from "../lib/agent-marketplace";
51
52const marketplaceAgents = new Hono<AuthEnv>();
53marketplaceAgents.use("*", softAuth);
54
55/* ─────────────────────────────────────────────────────────────────────────
56 * Scoped CSS — every selector under `.amkt-*` so we can't bleed into the
57 * pre-existing `.mkt-*` marketplace surface. Pattern mirrors that file.
58 * ───────────────────────────────────────────────────────────────────── */
59const styles = `
eed4684Claude60 .amkt-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
5ca514aClaude61
62 /* ─── Hero ─── */
63 .amkt-hero {
64 position: relative;
65 margin-bottom: var(--space-5);
66 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 18px;
70 overflow: hidden;
71 }
72 .amkt-hero::before {
73 content: '';
74 position: absolute;
75 top: 0; left: 0; right: 0;
76 height: 2px;
77 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
78 opacity: 0.75;
79 pointer-events: none;
80 }
81 .amkt-hero-orb {
82 position: absolute;
83 inset: -30% -10% auto auto;
84 width: 460px; height: 460px;
85 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
86 filter: blur(80px);
87 opacity: 0.7;
88 pointer-events: none;
89 z-index: 0;
90 }
91 .amkt-hero-inner {
92 position: relative;
93 z-index: 1;
94 display: flex;
95 align-items: flex-end;
96 justify-content: space-between;
97 gap: var(--space-4);
98 flex-wrap: wrap;
99 }
100 .amkt-hero-text { max-width: 680px; flex: 1; min-width: 240px; }
101 .amkt-eyebrow {
102 display: inline-flex;
103 align-items: center;
104 gap: 8px;
105 font-family: var(--font-mono);
106 font-size: 11px;
107 letter-spacing: 0.18em;
108 text-transform: uppercase;
109 color: var(--text-muted);
110 font-weight: 600;
111 margin-bottom: 16px;
112 }
113 .amkt-eyebrow-dot {
114 width: 8px; height: 8px;
115 border-radius: 9999px;
116 background: linear-gradient(135deg, #8c6dff, #36c5d6);
117 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
118 }
119 .amkt-title {
120 font-family: var(--font-display);
121 font-size: clamp(32px, 5vw, 48px);
122 font-weight: 800;
123 letter-spacing: -0.030em;
124 line-height: 1.05;
125 margin: 0 0 var(--space-3);
126 color: var(--text-strong);
127 }
128 .amkt-title-grad {
129 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
130 -webkit-background-clip: text;
131 background-clip: text;
132 -webkit-text-fill-color: transparent;
133 color: transparent;
134 }
135 .amkt-sub {
136 font-size: 16px;
137 color: var(--text-muted);
138 margin: 0;
139 line-height: 1.55;
140 max-width: 580px;
141 }
142 .amkt-hero-cta {
143 display: inline-flex;
144 align-items: center;
145 gap: 6px;
146 padding: 9px 16px;
147 border-radius: 10px;
148 font-size: 13px;
149 font-weight: 600;
150 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
151 color: #fff;
152 text-decoration: none;
153 border: 1px solid transparent;
154 box-shadow: 0 6px 16px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
155 transition: transform 120ms ease, box-shadow 120ms ease;
156 }
157 .amkt-hero-cta:hover {
158 transform: translateY(-1px);
159 color: #fff;
160 text-decoration: none;
161 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60);
162 }
163
164 /* ─── Category filter pills ─── */
165 .amkt-pills {
166 display: flex;
167 gap: 6px;
168 flex-wrap: wrap;
169 margin-bottom: var(--space-4);
170 }
171 .amkt-pill {
172 display: inline-flex;
173 align-items: center;
174 gap: 6px;
175 padding: 6px 14px;
176 border-radius: 9999px;
177 font-size: 12px;
178 font-weight: 600;
179 color: var(--text-muted);
180 background: rgba(255,255,255,0.03);
181 border: 1px solid var(--border);
182 text-decoration: none;
183 cursor: pointer;
184 transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
185 }
186 .amkt-pill:hover {
187 color: var(--text-strong);
188 border-color: rgba(140,109,255,0.45);
189 background: rgba(140,109,255,0.06);
190 text-decoration: none;
191 }
192 .amkt-pill.is-active {
193 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
194 color: #c5b3ff;
195 border-color: rgba(140,109,255,0.45);
196 }
197
198 /* ─── Card grid ─── */
199 .amkt-grid {
200 display: grid;
201 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
202 gap: var(--space-3);
203 }
204 .amkt-card {
205 position: relative;
206 padding: var(--space-4);
207 background: var(--bg-elevated);
208 border: 1px solid var(--border);
209 border-radius: 14px;
210 display: flex;
211 flex-direction: column;
212 gap: 10px;
213 color: inherit;
214 text-decoration: none;
215 transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
216 }
217 .amkt-card:hover {
218 border-color: rgba(140,109,255,0.45);
219 transform: translateY(-2px);
220 box-shadow: 0 10px 28px -10px rgba(140,109,255,0.30);
221 text-decoration: none;
222 color: inherit;
223 }
224 .amkt-card-head {
225 display: flex;
226 align-items: center;
227 gap: 12px;
228 }
229 .amkt-logo {
230 display: inline-flex;
231 align-items: center;
232 justify-content: center;
233 width: 44px; height: 44px;
234 border-radius: 11px;
235 flex-shrink: 0;
236 font-family: var(--font-display);
237 font-weight: 800;
238 font-size: 18px;
239 color: #fff;
240 letter-spacing: -0.02em;
241 box-shadow: inset 0 1px 0 rgba(255,255,255,0.16), 0 4px 12px -6px rgba(0,0,0,0.45);
242 }
243 .amkt-card-name {
244 font-family: var(--font-display);
245 font-size: 16px;
246 font-weight: 700;
247 color: var(--text-strong);
248 margin: 0;
249 letter-spacing: -0.012em;
250 }
251 .amkt-card-publisher {
252 font-family: var(--font-mono);
253 font-size: 11px;
254 color: var(--text-muted);
255 margin-top: 1px;
256 }
257 .amkt-card-tagline {
258 font-size: 13px;
259 color: var(--text-muted);
260 line-height: 1.5;
261 margin: 0;
262 flex: 1;
263 }
264 .amkt-card-meta {
265 display: flex;
266 align-items: center;
267 gap: 12px;
268 margin-top: 4px;
269 font-size: 11.5px;
270 color: var(--text-muted);
271 font-variant-numeric: tabular-nums;
272 }
273 .amkt-card-foot {
274 display: flex;
275 align-items: center;
276 justify-content: space-between;
277 gap: 8px;
278 margin-top: 4px;
279 }
280 .amkt-price-pill {
281 display: inline-flex;
282 align-items: center;
283 padding: 4px 10px;
284 border-radius: 9999px;
285 font-size: 11px;
286 font-weight: 700;
287 color: #c5b3ff;
288 background: rgba(140,109,255,0.10);
289 border: 1px solid rgba(140,109,255,0.30);
290 font-family: var(--font-mono);
291 }
292 .amkt-price-pill.is-free {
293 color: #86efac;
294 background: rgba(34,197,94,0.10);
295 border-color: rgba(34,197,94,0.30);
296 }
297 .amkt-install-btn {
298 display: inline-flex;
299 align-items: center;
300 justify-content: center;
301 padding: 6px 14px;
302 border-radius: 8px;
303 font-size: 12px;
304 font-weight: 600;
305 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
306 color: #fff;
307 text-decoration: none;
308 box-shadow: 0 4px 12px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
309 transition: transform 120ms ease;
310 }
311 .amkt-install-btn:hover {
312 transform: translateY(-1px);
313 color: #fff;
314 text-decoration: none;
315 }
316 .amkt-stars { display: inline-flex; gap: 2px; color: #f5b942; }
317
318 /* ─── Empty / zero state ─── */
319 .amkt-empty {
320 position: relative;
321 padding: clamp(28px, 4vw, 44px) clamp(20px, 4vw, 40px);
322 text-align: center;
323 background: var(--bg-elevated);
324 border: 1px dashed rgba(140,109,255,0.40);
325 border-radius: 16px;
326 overflow: hidden;
327 }
328 .amkt-empty-title {
329 font-family: var(--font-display);
330 font-size: 20px;
331 font-weight: 700;
332 margin: 0 0 8px;
333 color: var(--text-strong);
334 letter-spacing: -0.018em;
335 }
336 .amkt-empty-sub {
337 font-size: 14px;
338 color: var(--text-muted);
339 margin: 0 auto 18px;
340 max-width: 480px;
341 line-height: 1.55;
342 }
343
344 /* ─── Section card ─── */
345 .amkt-section {
346 margin-bottom: var(--space-5);
347 background: var(--bg-elevated);
348 border: 1px solid var(--border);
349 border-radius: 14px;
350 overflow: hidden;
351 }
352 .amkt-section-head {
353 padding: var(--space-4) var(--space-5);
354 border-bottom: 1px solid var(--border);
355 }
356 .amkt-section-title {
357 margin: 0;
358 font-family: var(--font-display);
359 font-size: 16px;
360 font-weight: 700;
361 color: var(--text-strong);
362 letter-spacing: -0.014em;
363 }
364 .amkt-section-sub {
365 margin: 4px 0 0;
366 font-size: 12.5px;
367 color: var(--text-muted);
368 }
369 .amkt-section-body { padding: var(--space-4) var(--space-5); }
370
371 /* ─── Detail page ─── */
372 .amkt-detail-head {
373 display: flex;
374 align-items: flex-start;
375 gap: var(--space-4);
376 margin-bottom: var(--space-4);
377 flex-wrap: wrap;
378 }
379 .amkt-detail-logo {
380 width: 64px; height: 64px;
381 border-radius: 14px;
382 flex-shrink: 0;
383 display: inline-flex;
384 align-items: center;
385 justify-content: center;
386 font-family: var(--font-display);
387 font-weight: 800;
388 font-size: 26px;
389 color: #fff;
390 letter-spacing: -0.02em;
391 box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 6px 18px -6px rgba(0,0,0,0.45);
392 }
393 .amkt-detail-name {
394 font-family: var(--font-display);
395 font-size: 28px;
396 font-weight: 800;
397 color: var(--text-strong);
398 margin: 0;
399 letter-spacing: -0.022em;
400 }
401 .amkt-detail-meta {
402 margin-top: 4px;
403 font-size: 12.5px;
404 color: var(--text-muted);
405 font-family: var(--font-mono);
406 }
407 .amkt-prose {
408 color: var(--text);
409 font-size: 14.5px;
410 line-height: 1.65;
411 }
412 .amkt-prose p { margin: 0 0 12px; }
413 .amkt-prose h1, .amkt-prose h2, .amkt-prose h3 {
414 font-family: var(--font-display);
415 color: var(--text-strong);
416 margin: 18px 0 8px;
417 }
418 .amkt-prose code {
419 font-family: var(--font-mono);
420 background: rgba(255,255,255,0.04);
421 padding: 2px 6px;
422 border-radius: 4px;
423 font-size: 0.9em;
424 }
425
426 /* ─── Reviews ─── */
427 .amkt-review {
428 padding: 14px 0;
429 border-bottom: 1px solid var(--border);
430 }
431 .amkt-review:last-child { border-bottom: none; }
432 .amkt-review-head {
433 display: flex;
434 align-items: center;
435 gap: 8px;
436 margin-bottom: 6px;
437 }
438 .amkt-review-author {
439 font-family: var(--font-mono);
440 font-size: 12px;
441 color: var(--text);
442 font-weight: 600;
443 }
444 .amkt-review-body {
445 color: var(--text);
446 font-size: 14px;
447 line-height: 1.55;
448 margin: 0;
449 }
450 .amkt-review-date {
451 font-size: 11.5px;
452 color: var(--text-muted);
453 font-variant-numeric: tabular-nums;
454 margin-left: auto;
455 }
456
457 /* ─── Form ─── */
458 .amkt-form-group { margin-bottom: var(--space-3); }
459 .amkt-form-group label {
460 display: block;
461 font-size: 12.5px;
462 font-weight: 600;
463 color: var(--text-strong);
464 margin-bottom: 6px;
465 }
466 .amkt-form-group input[type="text"],
467 .amkt-form-group input[type="url"],
468 .amkt-form-group input[type="number"],
469 .amkt-form-group textarea,
470 .amkt-form-group select {
471 width: 100%;
472 padding: 9px 12px;
473 background: var(--bg-secondary, rgba(0,0,0,0.15));
474 border: 1px solid var(--border);
475 border-radius: 9px;
476 font: inherit;
477 font-size: 13.5px;
478 color: var(--text);
479 box-sizing: border-box;
480 transition: border-color 120ms ease, box-shadow 120ms ease;
481 }
482 .amkt-form-group input:focus,
483 .amkt-form-group textarea:focus,
484 .amkt-form-group select:focus {
485 outline: none;
486 border-color: rgba(140,109,255,0.45);
487 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
488 }
489
490 /* ─── Buttons ─── */
491 .amkt-btn {
492 display: inline-flex;
493 align-items: center;
494 justify-content: center;
495 gap: 6px;
496 padding: 9px 16px;
497 border-radius: 10px;
498 font-size: 13px;
499 font-weight: 600;
500 text-decoration: none;
501 border: 1px solid transparent;
502 cursor: pointer;
503 font: inherit;
504 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
505 line-height: 1;
506 }
507 .amkt-btn-primary {
508 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
509 color: #ffffff;
510 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
511 }
512 .amkt-btn-primary:hover {
513 transform: translateY(-1px);
514 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60);
515 color: #ffffff;
516 text-decoration: none;
517 }
518 .amkt-btn-ghost {
519 background: transparent;
520 color: var(--text);
521 border-color: var(--border);
522 }
523 .amkt-btn-ghost:hover {
524 background: rgba(140,109,255,0.06);
525 border-color: rgba(140,109,255,0.45);
526 color: var(--text-strong);
527 text-decoration: none;
528 }
529 .amkt-btn-danger {
530 background: transparent;
531 color: #fca5a5;
532 border-color: rgba(248,113,113,0.35);
533 }
534 .amkt-btn-danger:hover {
535 background: rgba(248,113,113,0.06);
536 border-color: rgba(248,113,113,0.70);
537 color: #fecaca;
538 }
539
540 /* ─── Token reveal ─── */
541 .amkt-token-block {
542 padding: 14px 16px;
543 margin-top: 12px;
544 background: rgba(255,255,255,0.04);
545 border: 1px solid var(--border);
546 border-radius: 10px;
547 font-family: var(--font-mono);
548 font-size: 13px;
549 color: var(--text-strong);
550 word-break: break-all;
551 }
552
553 /* ─── Queue row ─── */
554 .amkt-queue-row {
555 display: flex;
556 align-items: center;
557 justify-content: space-between;
558 gap: 12px;
559 padding: 14px 18px;
560 border-bottom: 1px solid var(--border);
561 flex-wrap: wrap;
562 }
563 .amkt-queue-row:last-child { border-bottom: none; }
564 .amkt-queue-actions { display: flex; gap: 6px; }
565`;
566
567function StarRow({ rating }: { rating: number }) {
568 const full = Math.round(rating);
569 return (
570 <span class="amkt-stars" aria-label={`${rating} out of 5`}>
571 {[1, 2, 3, 4, 5].map((i) => (
572 <span style={`opacity:${i <= full ? 1 : 0.25}`}>★</span>
573 ))}
574 </span>
575 );
576}
577
578// -------- GET /marketplace/agents (catalog) ---------------------------------
579
580marketplaceAgents.get("/marketplace/agents", async (c) => {
581 const user = c.get("user");
582 const category = c.req.query("category") || "";
583 const search = c.req.query("q") || "";
584 const sortRaw = c.req.query("sort") || "top";
585 const sort: "top" | "new" | "rated" =
586 sortRaw === "new" || sortRaw === "rated" ? sortRaw : "top";
587 const listings = await listListings({
588 category: category || undefined,
589 search: search || undefined,
590 sort,
591 });
592
593 return c.html(
594 <Layout title="Agent Marketplace — Gluecron" user={user}>
595 <style dangerouslySetInnerHTML={{ __html: styles }} />
596 <div class="amkt-wrap">
597 <section class="amkt-hero">
598 <div class="amkt-hero-orb" aria-hidden="true" />
599 <div class="amkt-hero-inner">
600 <div class="amkt-hero-text">
601 <div class="amkt-eyebrow">
602 <span class="amkt-eyebrow-dot" aria-hidden="true" />
603 Marketplace · Agents
604 </div>
605 <h1 class="amkt-title">
606 <span class="amkt-title-grad">Agents from the community.</span>
607 </h1>
608 <p class="amkt-sub">
609 Third-party AI agents that plug into your repos with one click.
610 Each install provisions a scoped session with its own branch
611 namespace and daily budget — safe to run alongside the rest of
612 your fleet.
613 </p>
614 </div>
615 <div style="display:flex;gap:8px;align-items:center">
616 <a href="/marketplace" class="amkt-pill">All apps</a>
617 {user && (
618 <a href="/marketplace/agents/publish" class="amkt-hero-cta">
619 + Publish agent
620 </a>
621 )}
622 </div>
623 </div>
624 </section>
625
626 <form
627 method="get"
628 action="/marketplace/agents"
629 style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap"
630 >
631 <input
632 type="text"
633 name="q"
634 value={search}
635 placeholder="Search agents"
636 aria-label="Search agents"
637 style="flex:1;min-width:240px;padding:9px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;font-size:14px;color:var(--text)"
638 />
639 {category && <input type="hidden" name="category" value={category} />}
640 <select
641 name="sort"
642 aria-label="Sort"
643 style="padding:9px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text)"
644 >
645 <option value="top" selected={sort === "top"}>Top installed</option>
646 <option value="new" selected={sort === "new"}>Newest</option>
647 <option value="rated" selected={sort === "rated"}>Highest rated</option>
648 </select>
649 <button
650 type="submit"
651 class="amkt-btn amkt-btn-ghost"
652 style="padding:9px 16px"
653 >
654 Search
655 </button>
656 </form>
657
658 <div class="amkt-pills" role="tablist" aria-label="Filter by category">
659 <a
660 href={`/marketplace/agents${search ? `?q=${encodeURIComponent(search)}` : ""}`}
661 class={"amkt-pill" + (!category ? " is-active" : "")}
662 >
663 All
664 </a>
665 {MARKETPLACE_CATEGORIES.map((cat) => {
666 const params = new URLSearchParams();
667 params.set("category", cat);
668 if (search) params.set("q", search);
669 return (
670 <a
671 href={`/marketplace/agents?${params.toString()}`}
672 class={"amkt-pill" + (category === cat ? " is-active" : "")}
673 >
674 {cat}
675 </a>
676 );
677 })}
678 </div>
679
680 {listings.length === 0 ? (
681 <div class="amkt-empty">
682 <h2 class="amkt-empty-title">No agents match.</h2>
683 <p class="amkt-empty-sub">
684 {search || category
685 ? "Try clearing the filter or searching for something else."
686 : "No agents have been published yet. Be the first."}
687 </p>
688 {user && (
689 <a href="/marketplace/agents/publish" class="amkt-btn amkt-btn-primary">
690 Publish an agent
691 </a>
692 )}
693 </div>
694 ) : (
695 <div class="amkt-grid">
696 {listings.map((l) => {
697 const ratingNum = Number(l.ratingAvg) || 0;
698 return (
699 <a href={`/marketplace/agents/${l.slug}`} class="amkt-card">
700 <div class="amkt-card-head">
701 <span
702 class="amkt-logo"
703 aria-hidden="true"
704 style={`background:${gradientForSlug(l.slug)}`}
705 >
706 {listingInitials(l.name)}
707 </span>
708 <div style="min-width:0">
709 <h3 class="amkt-card-name">{l.name}</h3>
710 <div class="amkt-card-publisher">
711 {l.category} · {l.installCount} install
712 {l.installCount === 1 ? "" : "s"}
713 </div>
714 </div>
715 </div>
716 <p class="amkt-card-tagline">
717 {(l.tagline || "No tagline.").slice(0, 140)}
718 </p>
719 <div class="amkt-card-meta">
720 <span>
721 <StarRow rating={ratingNum} /> {ratingNum.toFixed(1)}{" "}
722 ({l.ratingCount})
723 </span>
724 </div>
725 <div class="amkt-card-foot">
726 <span
727 class={
728 "amkt-price-pill" +
729 (l.pricingModel === "free" ? " is-free" : "")
730 }
731 >
732 {formatPrice(l.priceCents, l.pricingModel)}
733 </span>
734 <span class="amkt-install-btn">Install &rarr;</span>
735 </div>
736 </a>
737 );
738 })}
739 </div>
740 )}
741 </div>
742 </Layout>
743 );
744});
745
746// -------- GET /marketplace/agents/publish (form) ----------------------------
747
748marketplaceAgents.get("/marketplace/agents/publish", requireAuth, async (c) => {
749 const user = c.get("user")!;
750 return c.html(
751 <Layout title="Publish agent — Marketplace" user={user}>
752 <style dangerouslySetInnerHTML={{ __html: styles }} />
753 <div class="amkt-wrap">
754 <section class="amkt-hero">
755 <div class="amkt-hero-orb" aria-hidden="true" />
756 <div class="amkt-hero-inner">
757 <div class="amkt-hero-text">
758 <div class="amkt-eyebrow">
759 <span class="amkt-eyebrow-dot" aria-hidden="true" />
760 Publisher
761 </div>
762 <h1 class="amkt-title">
763 <span class="amkt-title-grad">Publish.</span>
764 </h1>
765 <p class="amkt-sub">
766 Submit your AI agent. Once approved by a moderator, it appears
767 in the catalog and earns a 70% revenue share on paid
768 invocations.
769 </p>
770 </div>
771 <a href="/marketplace/agents" class="amkt-btn amkt-btn-ghost">
772 Cancel
773 </a>
774 </div>
775 </section>
776
777 <form method="post" action="/marketplace/agents/publish" class="amkt-section">
778 <div class="amkt-section-body">
779 <div class="amkt-form-group">
780 <label>Name</label>
781 <input type="text" name="name" required maxlength={80} />
782 </div>
783 <div class="amkt-form-group">
784 <label>Tagline (one line)</label>
785 <input type="text" name="tagline" maxlength={280} />
786 </div>
787 <div class="amkt-form-group">
788 <label>Description (markdown)</label>
789 <textarea name="description" rows={6} />
790 </div>
791 <div class="amkt-form-group">
792 <label>Category</label>
793 <select name="category">
794 {MARKETPLACE_CATEGORIES.map((c) => (
795 <option value={c}>{c}</option>
796 ))}
797 </select>
798 </div>
799 <div class="amkt-form-group">
800 <label>Pricing model</label>
801 <select name="pricingModel">
802 {PRICING_MODELS.map((p) => (
803 <option value={p}>{p}</option>
804 ))}
805 </select>
806 </div>
807 <div class="amkt-form-group">
808 <label>Price (cents)</label>
809 <input type="number" name="priceCents" min={0} value={0} />
810 </div>
811 <div class="amkt-form-group">
812 <label>Source URL</label>
813 <input type="url" name="sourceUrl" />
814 </div>
815 <button type="submit" class="amkt-btn amkt-btn-primary">
816 Submit for review
817 </button>
818 </div>
819 </form>
820 </div>
821 </Layout>
822 );
823});
824
825// -------- POST /marketplace/agents/publish ----------------------------------
826
827marketplaceAgents.post("/marketplace/agents/publish", requireAuth, async (c) => {
828 const user = c.get("user")!;
829 const body = await c.req.parseBody();
830 const name = String(body.name || "").trim();
831 if (!name) return c.redirect("/marketplace/agents/publish");
832 const listing = await createListing({
833 publisherUserId: user.id,
834 name,
835 tagline: String(body.tagline || ""),
836 description: String(body.description || ""),
837 category: String(body.category || "custom"),
838 pricingModel: String(body.pricingModel || "free"),
839 priceCents: Number(body.priceCents || 0),
840 sourceUrl: String(body.sourceUrl || "") || undefined,
841 });
842 if (!listing) return c.text("failed to create", 500);
843 await audit({
844 userId: user.id,
845 action: "marketplace.agent.submit",
846 targetType: "agent_marketplace_listing",
847 targetId: listing.id,
848 });
849 return c.redirect(`/marketplace/agents/${listing.slug}`);
850});
851
852// -------- GET /marketplace/agents/:slug (detail) ----------------------------
853
854marketplaceAgents.get("/marketplace/agents/:slug", async (c) => {
855 const user = c.get("user");
856 const slug = c.req.param("slug");
857 const detail = await getListing(slug);
858 if (!detail) return c.notFound();
859 // Only the publisher + admins can see a non-approved listing.
860 const isOwner = user?.id === detail.listing.publisherUserId;
861 const isAdmin = !!user?.isAdmin;
862 if (detail.listing.status !== "approved" && !isOwner && !isAdmin) {
863 return c.notFound();
864 }
865
866 // Show the install form against the user's repos.
867 const userRepos = user
868 ? await db
869 .select({
870 id: repositories.id,
871 name: repositories.name,
872 ownerName: users.username,
873 })
874 .from(repositories)
875 .leftJoin(users, eq(users.id, repositories.ownerId))
876 .where(eq(repositories.ownerId, user.id))
877 .limit(50)
878 : [];
879
880 const ratingNum = Number(detail.listing.ratingAvg) || 0;
881 const descHtml = renderMarkdown(detail.listing.description || "");
882
883 return c.html(
884 <Layout title={`${detail.listing.name} — Agent Marketplace`} user={user}>
885 <style dangerouslySetInnerHTML={{ __html: styles }} />
886 <div class="amkt-wrap">
887 <section class="amkt-hero">
888 <div class="amkt-hero-orb" aria-hidden="true" />
889 <div class="amkt-hero-inner">
890 <div class="amkt-hero-text" style="flex:1">
891 <div class="amkt-detail-head">
892 <span
893 class="amkt-detail-logo"
894 aria-hidden="true"
895 style={`background:${gradientForSlug(detail.listing.slug)}`}
896 >
897 {listingInitials(detail.listing.name)}
898 </span>
899 <div style="min-width:0">
900 <h1 class="amkt-detail-name">{detail.listing.name}</h1>
901 <div class="amkt-detail-meta">
902 by{" "}
903 <strong style="color:var(--text)">
904 @{detail.listing.publisherUsername || "unknown"}
905 </strong>{" "}
906 · {detail.listing.installCount} install
907 {detail.listing.installCount === 1 ? "" : "s"} ·{" "}
908 <StarRow rating={ratingNum} /> {ratingNum.toFixed(1)} (
909 {detail.listing.ratingCount})
910 </div>
911 </div>
912 </div>
913 <p class="amkt-sub">{detail.listing.tagline}</p>
914 <div style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
915 <span
916 class={
917 "amkt-price-pill" +
918 (detail.listing.pricingModel === "free" ? " is-free" : "")
919 }
920 >
921 {formatPrice(
922 detail.listing.priceCents,
923 detail.listing.pricingModel
924 )}
925 </span>
926 <span class="amkt-price-pill">{detail.listing.category}</span>
927 {detail.listing.status !== "approved" && (
928 <span class="amkt-price-pill">{detail.listing.status}</span>
929 )}
930 {detail.listing.sourceUrl && (
931 <a
932 href={detail.listing.sourceUrl}
933 class="amkt-btn amkt-btn-ghost"
934 style="padding:6px 12px;font-size:12px"
935 >
936 Source
937 </a>
938 )}
939 </div>
940 </div>
941 </div>
942 </section>
943
944 <section class="amkt-section">
945 <header class="amkt-section-head">
946 <h3 class="amkt-section-title">About</h3>
947 </header>
948 <div class="amkt-section-body">
949 <div
950 class="amkt-prose"
951 dangerouslySetInnerHTML={{
952 __html: descHtml || "<p>No description provided.</p>",
953 }}
954 />
955 </div>
956 </section>
957
958 {user ? (
959 <section class="amkt-section">
960 <header class="amkt-section-head">
961 <h3 class="amkt-section-title">Install on a repo</h3>
962 <p class="amkt-section-sub">
963 Provisions a scoped agent session with budget{" "}
964 {detail.listing.agentTemplate?.budgetCentsPerDay ?? 500}¢/day.
965 Token is shown once.
966 </p>
967 </header>
968 <div class="amkt-section-body">
969 {userRepos.length === 0 ? (
970 <p style="color:var(--text-muted);font-size:13.5px;margin:0">
971 You don't own any repos yet —{" "}
972 <a href="/new" style="color:var(--accent)">
973 create one
974 </a>{" "}
975 to install this agent.
976 </p>
977 ) : (
978 <form
979 method="post"
980 action={`/marketplace/agents/${slug}/install`}
981 style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"
982 >
983 <select
984 name="repositoryId"
985 aria-label="Repository"
986 style="flex:1;min-width:200px;padding:9px 12px;background:var(--bg-secondary,rgba(0,0,0,0.15));border:1px solid var(--border);border-radius:9px;color:var(--text);font:inherit;font-size:13.5px"
987 >
988 {userRepos.map((r) => (
989 <option value={r.id}>
990 {r.ownerName}/{r.name}
991 </option>
992 ))}
993 </select>
994 <button
995 type="submit"
996 class="amkt-btn amkt-btn-primary"
997 disabled={detail.listing.status !== "approved"}
998 >
999 Install
1000 </button>
1001 </form>
1002 )}
1003 </div>
1004 </section>
1005 ) : (
1006 <section class="amkt-section">
1007 <div class="amkt-section-body">
1008 <p style="margin:0;color:var(--text-muted);font-size:14px">
1009 <a
1010 href={`/login?next=/marketplace/agents/${slug}`}
1011 style="color:var(--accent)"
1012 >
1013 Sign in
1014 </a>{" "}
1015 to install this agent on one of your repos.
1016 </p>
1017 </div>
1018 </section>
1019 )}
1020
1021 <section class="amkt-section">
1022 <header class="amkt-section-head">
1023 <h3 class="amkt-section-title">
1024 Reviews ({detail.listing.ratingCount})
1025 </h3>
1026 </header>
1027 <div class="amkt-section-body">
1028 {detail.reviews.length === 0 ? (
1029 <p style="color:var(--text-muted);font-size:13.5px;margin:0">
1030 No reviews yet.
1031 </p>
1032 ) : (
1033 detail.reviews.map((r) => (
1034 <div class="amkt-review">
1035 <div class="amkt-review-head">
1036 <StarRow rating={r.rating} />
1037 <span class="amkt-review-author">
1038 @{r.reviewerUsername || "anon"}
1039 </span>
1040 <span class="amkt-review-date">
1041 {r.createdAt
1042 ? new Date(r.createdAt).toLocaleDateString()
1043 : ""}
1044 </span>
1045 </div>
1046 <p class="amkt-review-body">{r.body || "(no body)"}</p>
1047 </div>
1048 ))
1049 )}
1050 {user && (
1051 <form
1052 method="post"
1053 action={`/marketplace/agents/${slug}/reviews`}
1054 style="margin-top:18px;padding-top:18px;border-top:1px solid var(--border)"
1055 >
1056 <div class="amkt-form-group">
1057 <label>Rating</label>
1058 <select name="rating" style="max-width:120px">
1059 {[5, 4, 3, 2, 1].map((n) => (
1060 <option value={n}>{n} ★</option>
1061 ))}
1062 </select>
1063 </div>
1064 <div class="amkt-form-group">
1065 <label>Review</label>
1066 <textarea name="body" rows={3} maxlength={4000} />
1067 </div>
1068 <button type="submit" class="amkt-btn amkt-btn-primary">
1069 Post review
1070 </button>
1071 </form>
1072 )}
1073 </div>
1074 </section>
1075 </div>
1076 </Layout>
1077 );
1078});
1079
1080// -------- POST /marketplace/agents/:slug/install ----------------------------
1081
1082marketplaceAgents.post(
1083 "/marketplace/agents/:slug/install",
1084 requireAuth,
1085 async (c) => {
1086 const user = c.get("user")!;
1087 const slug = c.req.param("slug");
1088 const listing = await fetchListingBySlug(slug);
1089 if (!listing || listing.status !== "approved") {
1090 return c.text("not found", 404);
1091 }
1092 const body = await c.req.parseBody();
1093 const repositoryId = String(body.repositoryId || "");
1094 if (!repositoryId) {
1095 return c.redirect(`/marketplace/agents/${slug}`);
1096 }
1097 // Must own the repo.
1098 const [repo] = await db
1099 .select()
1100 .from(repositories)
1101 .where(
1102 and(eq(repositories.id, repositoryId), eq(repositories.ownerId, user.id))
1103 )
1104 .limit(1);
1105 if (!repo) return c.text("forbidden", 403);
1106
1107 const result = await installListing({
1108 listingId: listing.id,
1109 repositoryId: repo.id,
1110 installedByUserId: user.id,
1111 });
1112 if (!result) {
1113 return c.text(
1114 "install failed (already installed or session provisioning error)",
1115 409
1116 );
1117 }
1118 await audit({
1119 userId: user.id,
1120 action: "marketplace.agent.install",
1121 targetType: "agent_marketplace_install",
1122 targetId: result.install.id,
1123 metadata: { slug, repositoryId },
1124 });
1125
1126 return c.html(
1127 <Layout title="Agent installed" user={user}>
1128 <style dangerouslySetInnerHTML={{ __html: styles }} />
1129 <div class="amkt-wrap">
1130 <section class="amkt-hero">
1131 <div class="amkt-hero-orb" aria-hidden="true" />
1132 <div class="amkt-hero-inner">
1133 <div class="amkt-hero-text">
1134 <div class="amkt-eyebrow">
1135 <span class="amkt-eyebrow-dot" aria-hidden="true" />
1136 Token issued
1137 </div>
1138 <h1 class="amkt-title">
1139 <span class="amkt-title-grad">Copy now.</span>
1140 </h1>
1141 <p class="amkt-sub">
1142 This agent token is shown once. Store it safely — it's the
1143 agent's bearer credential.
1144 </p>
1145 </div>
1146 </div>
1147 </section>
1148 <section class="amkt-section">
1149 <div class="amkt-section-body">
1150 <div class="amkt-token-block">{result.agentToken}</div>
1151 <a
1152 href={`/marketplace/agents/${slug}`}
1153 class="amkt-btn amkt-btn-ghost"
1154 style="margin-top:14px"
1155 >
1156 Back to listing
1157 </a>
1158 </div>
1159 </section>
1160 </div>
1161 </Layout>
1162 );
1163 }
1164);
1165
1166// -------- POST /marketplace/agents/:slug/reviews ----------------------------
1167
1168marketplaceAgents.post(
1169 "/marketplace/agents/:slug/reviews",
1170 requireAuth,
1171 async (c) => {
1172 const user = c.get("user")!;
1173 const slug = c.req.param("slug");
1174 const listing = await fetchListingBySlug(slug);
1175 if (!listing) return c.text("not found", 404);
1176 const body = await c.req.parseBody();
1177 const rating = Number(body.rating || 0);
1178 await recordReview({
1179 listingId: listing.id,
1180 reviewerUserId: user.id,
1181 rating,
1182 body: String(body.body || ""),
1183 });
1184 return c.redirect(`/marketplace/agents/${slug}`);
1185 }
1186);
1187
1188// -------- POST /marketplace/installs/:id/uninstall --------------------------
1189
1190marketplaceAgents.post(
1191 "/marketplace/installs/:id/uninstall",
1192 requireAuth,
1193 async (c) => {
1194 const user = c.get("user")!;
1195 const id = c.req.param("id");
1196 const [row] = await db
1197 .select()
1198 .from(agentMarketplaceInstalls)
1199 .where(eq(agentMarketplaceInstalls.id, id))
1200 .limit(1);
1201 if (!row || row.installedByUserId !== user.id) {
1202 return c.text("forbidden", 403);
1203 }
1204 const ok = await uninstallListing({ installId: id });
1205 if (ok) {
1206 await audit({
1207 userId: user.id,
1208 action: "marketplace.agent.uninstall",
1209 targetType: "agent_marketplace_install",
1210 targetId: id,
1211 });
1212 }
1213 return c.redirect("/marketplace/agents");
1214 }
1215);
1216
1217// -------- GET /admin/marketplace/queue --------------------------------------
1218
1219marketplaceAgents.get(
1220 "/admin/marketplace/queue",
1221 requireAuth,
1222 requireAdmin,
1223 async (c) => {
1224 const user = c.get("user")!;
1225 const pending = await listListings({ status: "pending_review", sort: "new" });
1226 const rejected = await listListings({ status: "rejected", sort: "new" });
1227 return c.html(
1228 <Layout title="Marketplace moderation — Admin" user={user}>
1229 <style dangerouslySetInnerHTML={{ __html: styles }} />
1230 <div class="amkt-wrap">
1231 <section class="amkt-hero">
1232 <div class="amkt-hero-orb" aria-hidden="true" />
1233 <div class="amkt-hero-inner">
1234 <div class="amkt-hero-text">
1235 <div class="amkt-eyebrow">
1236 <span class="amkt-eyebrow-dot" aria-hidden="true" />
1237 Admin · Marketplace
1238 </div>
1239 <h1 class="amkt-title">
1240 <span class="amkt-title-grad">Moderate.</span>
1241 </h1>
1242 <p class="amkt-sub">
1243 Approve or reject pending agent submissions. Approved listings
1244 go live immediately.
1245 </p>
1246 </div>
1247 </div>
1248 </section>
1249
1250 <section class="amkt-section">
1251 <header class="amkt-section-head">
1252 <h3 class="amkt-section-title">
1253 Pending ({pending.length})
1254 </h3>
1255 </header>
1256 {pending.length === 0 ? (
1257 <div class="amkt-section-body">
1258 <p style="color:var(--text-muted);margin:0">Queue is empty.</p>
1259 </div>
1260 ) : (
1261 pending.map((l) => (
1262 <div class="amkt-queue-row">
1263 <div style="min-width:0;flex:1">
1264 <a
1265 href={`/marketplace/agents/${l.slug}`}
1266 style="color:var(--text-strong);font-weight:600;text-decoration:none"
1267 >
1268 {l.name}
1269 </a>
1270 <div
1271 style="font-size:12px;color:var(--text-muted);margin-top:2px"
1272 >
1273 {l.category} · {formatPrice(l.priceCents, l.pricingModel)}{" "}
1274 · submitted{" "}
1275 {l.createdAt
1276 ? new Date(l.createdAt).toLocaleDateString()
1277 : ""}
1278 </div>
1279 </div>
1280 <div class="amkt-queue-actions">
1281 <form
1282 method="post"
1283 action={`/admin/marketplace/queue/${l.slug}/approve`}
1284 style="margin:0"
1285 >
1286 <button class="amkt-btn amkt-btn-primary">Approve</button>
1287 </form>
1288 <form
1289 method="post"
1290 action={`/admin/marketplace/queue/${l.slug}/reject`}
1291 style="margin:0"
1292 >
1293 <button class="amkt-btn amkt-btn-danger">Reject</button>
1294 </form>
1295 </div>
1296 </div>
1297 ))
1298 )}
1299 </section>
1300
1301 {rejected.length > 0 && (
1302 <section class="amkt-section">
1303 <header class="amkt-section-head">
1304 <h3 class="amkt-section-title">
1305 Recently rejected ({rejected.length})
1306 </h3>
1307 </header>
1308 {rejected.map((l) => (
1309 <div class="amkt-queue-row">
1310 <div style="min-width:0;flex:1">
1311 <span style="color:var(--text);font-weight:600">{l.name}</span>
1312 <div
1313 style="font-size:12px;color:var(--text-muted);margin-top:2px"
1314 >
1315 {l.category} · rejected
1316 </div>
1317 </div>
1318 </div>
1319 ))}
1320 </section>
1321 )}
1322 </div>
1323 </Layout>
1324 );
1325 }
1326);
1327
1328// -------- POST /admin/marketplace/queue/:slug/:action -----------------------
1329
1330marketplaceAgents.post(
1331 "/admin/marketplace/queue/:slug/:action",
1332 requireAuth,
1333 requireAdmin,
1334 async (c) => {
1335 const user = c.get("user")!;
1336 const slug = c.req.param("slug");
1337 const action = c.req.param("action");
1338 if (action === "approve") {
1339 await approveListing(slug, user.id);
1340 await audit({
1341 userId: user.id,
1342 action: "marketplace.agent.approve",
1343 targetType: "agent_marketplace_listing",
1344 targetId: slug,
1345 });
1346 } else if (action === "reject") {
1347 const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>;
1348 await rejectListing(slug, user.id, String(body.reason || ""));
1349 await audit({
1350 userId: user.id,
1351 action: "marketplace.agent.reject",
1352 targetType: "agent_marketplace_listing",
1353 targetId: slug,
1354 });
1355 }
1356 return c.redirect("/admin/marketplace/queue");
1357 }
1358);
1359
1360// ============================================================================
1361// API v2 endpoints — JSON surface for the same operations.
1362// Mounted as a separate sub-app at /api/v2/marketplace so the public auth
1363// stack (PAT / Bearer) applies cleanly.
1364// ============================================================================
1365
1366import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
1367import type { ApiAuthEnv } from "../middleware/api-auth";
1368
1369const marketplaceAgentsApi = new Hono<ApiAuthEnv>().basePath(
1370 "/api/v2/marketplace"
1371);
1372marketplaceAgentsApi.use("*", apiAuth);
1373
1374marketplaceAgentsApi.get("/agents", async (c) => {
1375 const list = await listListings({
1376 category: c.req.query("category") || undefined,
1377 search: c.req.query("q") || undefined,
1378 sort: ((): "top" | "new" | "rated" => {
1379 const s = c.req.query("sort");
1380 return s === "new" || s === "rated" ? s : "top";
1381 })(),
1382 });
1383 return c.json({ listings: list });
1384});
1385
1386marketplaceAgentsApi.get("/agents/:slug", async (c) => {
1387 const slug = c.req.param("slug");
1388 const detail = await getListing(slug);
1389 if (!detail || detail.listing.status !== "approved") {
1390 return c.json({ error: "not found" }, 404);
1391 }
1392 return c.json(detail);
1393});
1394
1395marketplaceAgentsApi.post(
1396 "/agents",
1397 requireApiAuth,
1398 requireScope("repo"),
1399 async (c) => {
1400 const user = c.get("user")!;
1401 const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
1402 const name = String(body.name || "").trim();
1403 if (!name) return c.json({ error: "name required" }, 400);
1404 const category = String(body.category || "custom");
1405 const pricingModel = String(body.pricingModel || "free");
1406 if (!isValidCategory(category)) return c.json({ error: "bad category" }, 400);
1407 if (!isValidPricingModel(pricingModel)) {
1408 return c.json({ error: "bad pricingModel" }, 400);
1409 }
1410 const listing = await createListing({
1411 publisherUserId: user.id,
1412 name,
1413 tagline: String(body.tagline || ""),
1414 description: String(body.description || ""),
1415 category,
1416 pricingModel,
1417 priceCents: Number(body.priceCents || 0),
1418 sourceUrl:
1419 typeof body.sourceUrl === "string" ? body.sourceUrl : undefined,
1420 agentTemplate:
1421 body.agentTemplate && typeof body.agentTemplate === "object"
1422 ? (body.agentTemplate as Record<string, unknown>)
1423 : undefined,
1424 });
1425 if (!listing) return c.json({ error: "failed" }, 500);
1426 return c.json({ listing }, 201);
1427 }
1428);
1429
1430marketplaceAgentsApi.post(
1431 "/agents/:slug/install",
1432 requireApiAuth,
1433 requireScope("repo"),
1434 async (c) => {
1435 const user = c.get("user")!;
1436 const slug = c.req.param("slug");
1437 const listing = await fetchListingBySlug(slug);
1438 if (!listing || listing.status !== "approved") {
1439 return c.json({ error: "not found" }, 404);
1440 }
1441 const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
1442 const repositoryId = String(body.repositoryId || "");
1443 if (!repositoryId) {
1444 return c.json({ error: "repositoryId required" }, 400);
1445 }
1446 const [repo] = await db
1447 .select()
1448 .from(repositories)
1449 .where(
1450 and(eq(repositories.id, repositoryId), eq(repositories.ownerId, user.id))
1451 )
1452 .limit(1);
1453 if (!repo) return c.json({ error: "repo not found or not owned" }, 403);
1454
1455 const result = await installListing({
1456 listingId: listing.id,
1457 repositoryId: repo.id,
1458 installedByUserId: user.id,
1459 });
1460 if (!result) return c.json({ error: "install failed" }, 409);
1461 return c.json(
1462 {
1463 installId: result.install.id,
1464 agentToken: result.agentToken,
1465 agentSessionId: result.install.agentSessionId,
1466 },
1467 201
1468 );
1469 }
1470);
1471
1472marketplaceAgentsApi.delete(
1473 "/installs/:id",
1474 requireApiAuth,
1475 requireScope("repo"),
1476 async (c) => {
1477 const user = c.get("user")!;
1478 const id = c.req.param("id");
1479 const [row] = await db
1480 .select()
1481 .from(agentMarketplaceInstalls)
1482 .where(eq(agentMarketplaceInstalls.id, id))
1483 .limit(1);
1484 if (!row || row.installedByUserId !== user.id) {
1485 return c.json({ error: "forbidden" }, 403);
1486 }
1487 const ok = await uninstallListing({ installId: id });
1488 return c.json({ ok });
1489 }
1490);
1491
1492marketplaceAgentsApi.post(
1493 "/agents/:slug/reviews",
1494 requireApiAuth,
1495 requireScope("repo"),
1496 async (c) => {
1497 const user = c.get("user")!;
1498 const slug = c.req.param("slug");
1499 const listing = await fetchListingBySlug(slug);
1500 if (!listing) return c.json({ error: "not found" }, 404);
1501 const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
1502 const rating = Number(body.rating || 0);
1503 if (rating < 1 || rating > 5) {
1504 return c.json({ error: "rating must be 1..5" }, 400);
1505 }
1506 const review = await recordReview({
1507 listingId: listing.id,
1508 reviewerUserId: user.id,
1509 rating,
1510 body: String(body.body || ""),
1511 });
1512 if (!review) return c.json({ error: "failed" }, 500);
1513 return c.json({ review }, 201);
1514 }
1515);
1516
1517// Compose both sub-apps onto one mount.
1518marketplaceAgents.route("/", marketplaceAgentsApi);
1519
1520export default marketplaceAgents;