Skill marketplace: publish, install, org-aware listing (+ adversarial-review fixes)
Orgs can now share skills across the tenant boundary — the next step after the per-org library.
Endpoints (all ManageSkills-gated + audited):
- POST /{key}/publish — list one of your published versions on the marketplace (Visibility→Public;
only a Published/golden-tested skill may be listed). POST /{key}/unpublish reverses it.
- POST /install — copy a publicly-listed skill (by row id) into your org as a private Installed
copy; rejects installing your own skill and duplicate (org+key+version) installs.
- GET /marketplace?organizationId= — other orgs' Authored+Public+Published skills (yours excluded),
each flagged whether that exact (key, version) is already in your library.
- SkillSummary now carries Id (install targets a specific source row). Authored skills default to
private — listing is an explicit publish step, never a side effect of authoring.
UI (Skills page): a Marketplace tab with Install / "In your library"; Publish / Unlist on your own
published skills; a "Listed" badge.
Fixes from the adversarial review (4 confirmed findings, all addressed):
- HIGH — Public⟹Published is now a domain invariant (Skill.Index forces PrivateToOrg whenever the
re-derived status isn't Published), so re-authoring a listed version without golden tests can no
longer leave it Public+Draft or decouple the marketplace gate from the eval gate.
- MEDIUM — install now uses an insert-only indexer path so the (org,key,version) unique index is the
source of truth: a race with a concurrent install/author becomes a clean 409, never an in-place
clobber of an existing row's content/ownership.
- MEDIUM/LOW — AlreadyInLibrary is computed per (key, version) to match the install conflict rule, so
a newer, not-yet-owned version of a key you already hold still shows as installable.
Verified: ArchitectureTests 8/8, IntegrationTests 47/47 (SkillMarketplaceTests: publish gate, own-org
exclusion, cross-org list→install→private copy, duplicate 409, per-version flag, Public⟹Published
invariant, Member 403), client build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } from 'lucide-react'
|
||||
import { BookMarked, Download, GitFork, Pencil, Plus, Store, Trash2, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -32,6 +32,7 @@ interface GoldenTest {
|
||||
}
|
||||
|
||||
interface SkillSummary {
|
||||
id: string
|
||||
skillKey: string
|
||||
name: string
|
||||
version: string
|
||||
@@ -56,6 +57,11 @@ interface SkillDetail {
|
||||
body: string
|
||||
}
|
||||
|
||||
interface MarketplaceEntry {
|
||||
skill: SkillSummary
|
||||
alreadyInLibrary: boolean
|
||||
}
|
||||
|
||||
type Mode = 'new' | 'version' | 'edit'
|
||||
|
||||
interface FormState {
|
||||
@@ -92,7 +98,7 @@ const emptyForm = (): FormState => ({
|
||||
outputs: '',
|
||||
tools: '',
|
||||
context: '',
|
||||
visibility: 'public',
|
||||
visibility: 'private',
|
||||
minTier: 'free',
|
||||
body: '',
|
||||
actions: [],
|
||||
@@ -120,7 +126,7 @@ export function SkillsPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||
const [marketplace, setMarketplace] = useState<SkillSummary[]>([])
|
||||
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -129,7 +135,7 @@ export function SkillsPage() {
|
||||
try {
|
||||
const [lib, market] = await Promise.all([
|
||||
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
|
||||
api.get<SkillSummary[]>(`/api/skills/marketplace`),
|
||||
api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
|
||||
])
|
||||
setSkills(lib)
|
||||
setMarketplace(market)
|
||||
@@ -193,6 +199,32 @@ export function SkillsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const setListed = async (key: string, version: string, listed: boolean) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post(`/api/skills/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version })
|
||||
toast.success(listed ? `Published ${key}@${version} to the marketplace.` : `Unlisted ${key}@${version}.`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const install = async (sourceSkillId: string, name: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post('/api/skills/install', { organizationId, sourceSkillId })
|
||||
toast.success(`Installed ${name} into your library.`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
setBusy(true)
|
||||
@@ -260,6 +292,8 @@ export function SkillsPage() {
|
||||
onNewVersion={(v) => openForm(key, v, 'version')}
|
||||
onEdit={(v) => openForm(key, v, 'edit')}
|
||||
onFork={(v) => fork(key, v)}
|
||||
onPublish={(v) => setListed(key, v, true)}
|
||||
onUnpublish={(v) => setListed(key, v, false)}
|
||||
/>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
@@ -269,23 +303,38 @@ export function SkillsPage() {
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Public skills shared by other organizations. One-click install lands in the next step.
|
||||
Published skills shared by other organizations. Install a copy into your library — it lands private,
|
||||
so you can edit or version it freely.
|
||||
</p>
|
||||
{marketplace.map((s) => (
|
||||
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
|
||||
{marketplace.map(({ skill: s, alreadyInLibrary }) => (
|
||||
<Card key={s.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{s.name} <Badge variant="outline">{s.version}</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">{s.skillKey}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{s.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
<CardContent className="flex flex-wrap items-center gap-2">
|
||||
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||
<Button size="sm" variant="outline" disabled className="ml-auto">Install (soon)</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.goldenTestCount} golden test{s.goldenTestCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{alreadyInLibrary ? (
|
||||
<Badge variant="secondary" className="ml-auto">In your library</Badge>
|
||||
) : (
|
||||
<Button size="sm" disabled={busy} className="ml-auto" onClick={() => install(s.id, s.name)}>
|
||||
<Download data-icon="inline-start" /> Install
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</p>}
|
||||
{marketplace.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing published yet. Publish one of your own skills to share it here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -420,16 +469,22 @@ function SkillGroupCard({
|
||||
onNewVersion,
|
||||
onEdit,
|
||||
onFork,
|
||||
onPublish,
|
||||
onUnpublish,
|
||||
}: {
|
||||
versions: SkillSummary[]
|
||||
busy: boolean
|
||||
onNewVersion: (version: string) => void
|
||||
onEdit: (version: string) => void
|
||||
onFork: (version: string) => void
|
||||
onPublish: (version: string) => void
|
||||
onUnpublish: (version: string) => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState(versions[0].version)
|
||||
const current = versions.find((v) => v.version === selected) ?? versions[0]
|
||||
const isBuiltin = current.origin === 'Builtin'
|
||||
const isListed = current.visibility === 'Public'
|
||||
const canPublish = !isBuiltin && current.status === 'Published'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -456,6 +511,7 @@ function SkillGroupCard({
|
||||
)}
|
||||
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
|
||||
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isBuiltin ? (
|
||||
@@ -463,9 +519,20 @@ function SkillGroupCard({
|
||||
<GitFork data-icon="inline-start" /> Fork to my org
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
||||
<Pencil data-icon="inline-start" /> Edit
|
||||
</Button>
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
||||
<Pencil data-icon="inline-start" /> Edit
|
||||
</Button>
|
||||
{isListed ? (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onUnpublish(current.version)}>
|
||||
Unlist
|
||||
</Button>
|
||||
) : canPublish ? (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onPublish(current.version)}>
|
||||
<Upload data-icon="inline-start" /> Publish
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
|
||||
<Plus data-icon="inline-start" /> New version
|
||||
|
||||
Reference in New Issue
Block a user