Files
Teamup/client/src/pages/SkillsPage.tsx
T
soroush.asadi 62883ed01f 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>
2026-06-13 12:27:22 +03:30

591 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from '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'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface ActionDto {
name: string
risk: string
description?: string | null
}
interface GoldenTest {
input: string
expected: string
}
interface SkillSummary {
id: string
skillKey: string
name: string
version: string
summary: string | null
roles: string[]
visibility: string
minTier: string
status: string
origin: string
organizationId: string | null
goldenTestCount: number
actions: ActionDto[]
}
interface SkillDetail {
skill: SkillSummary
inputs: string | null
outputs: string | null
tools: string[]
context: string[]
goldenTests: GoldenTest[]
body: string
}
interface MarketplaceEntry {
skill: SkillSummary
alreadyInLibrary: boolean
}
type Mode = 'new' | 'version' | 'edit'
interface FormState {
mode: Mode
skillKey: string
name: string
version: string
summary: string
roles: string
inputs: string
outputs: string
tools: string
context: string
visibility: string
minTier: string
body: string
actions: ActionDto[]
goldenTests: GoldenTest[]
}
const COMMON_ROLES = 'product-owner, engineer, qa, designer, analyst'
const RISKS = ['read', 'draft', 'publish', 'destructive']
const VISIBILITIES = ['public', 'private']
const TIERS = ['free', 'team', 'scale', 'enterprise']
const emptyForm = (): FormState => ({
mode: 'new',
skillKey: '',
name: '',
version: '1.0.0',
summary: '',
roles: '',
inputs: '',
outputs: '',
tools: '',
context: '',
visibility: 'private',
minTier: 'free',
body: '',
actions: [],
goldenTests: [],
})
/** Bump the last numeric segment of a semver-ish string (1.0.0 → 1.0.1). */
function bumpPatch(version: string): string {
const parts = version.split('.')
for (let i = parts.length - 1; i >= 0; i--) {
const n = Number(parts[i])
if (Number.isInteger(n)) {
parts[i] = String(n + 1)
return parts.join('.')
}
}
return `${version}.1`
}
const csv = (s: string): string[] =>
s.split(',').map((x) => x.trim()).filter(Boolean)
/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */
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<MarketplaceEntry[]>([])
const [form, setForm] = useState<FormState | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
if (!organizationId) return
try {
const [lib, market] = await Promise.all([
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
])
setSkills(lib)
setMarketplace(market)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
// Group every version under its key; newest (and the org's own) first comes from the API order.
const groups = useMemo(() => {
const byKey = new Map<string, SkillSummary[]>()
for (const s of skills) {
const list = byKey.get(s.skillKey) ?? []
list.push(s)
byKey.set(s.skillKey, list)
}
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [skills])
const openForm = async (key: string, version: string, mode: Mode) => {
try {
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
const d = details.find((x) => x.skill.version === version) ?? details[0]
if (!d) return
setForm({
mode,
skillKey: d.skill.skillKey,
name: d.skill.name,
version: mode === 'version' ? bumpPatch(d.skill.version) : d.skill.version,
summary: d.skill.summary ?? '',
roles: d.skill.roles.join(', '),
inputs: d.inputs ?? '',
outputs: d.outputs ?? '',
tools: d.tools.join(', '),
context: d.context.join(', '),
visibility: d.skill.visibility === 'PrivateToOrg' ? 'private' : 'public',
minTier: d.skill.minTier.toLowerCase(),
body: d.body,
actions: d.skill.actions.map((a) => ({ name: a.name, risk: a.risk.toLowerCase(), description: a.description ?? '' })),
goldenTests: d.goldenTests.map((g) => ({ ...g })),
})
} catch (err) {
toast.error((err as Error).message)
}
}
const fork = async (key: string, version: string) => {
setBusy(true)
try {
await api.post(`/api/skills/${key}/fork`, { organizationId, version })
toast.success(`Forked ${key} into your org — edit it to customize.`)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
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)
try {
await api.post('/api/skills/authored', {
organizationId,
skillKey: form.skillKey.trim(),
name: form.name.trim(),
version: form.version.trim(),
summary: form.summary.trim() || null,
roles: csv(form.roles),
inputs: form.inputs.trim() || null,
outputs: form.outputs.trim() || null,
tools: csv(form.tools),
context: csv(form.context),
visibility: form.visibility,
minTier: form.minTier,
body: form.body,
actions: form.actions
.filter((a) => a.name.trim())
.map((a) => ({ name: a.name.trim(), risk: a.risk, description: a.description?.trim() || null })),
goldenTests: form.goldenTests.filter((g) => g.input.trim() && g.expected.trim()),
})
toast.success(`Saved ${form.skillKey}@${form.version}.`)
setForm(null)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const willPublish = !!form && csv(form.roles).length > 0 && form.goldenTests.some((g) => g.input.trim() && g.expected.trim())
return (
<AppShell>
<div className="mx-auto max-w-5xl p-6">
<header className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<BookMarked className="size-6" /> Skills
</h1>
<p className="text-sm text-muted-foreground">
Your company's skill library. Builtin starter skills are shared; author and version your own.
</p>
</div>
<Button onClick={() => setForm(emptyForm())}>
<Plus data-icon="inline-start" /> New skill
</Button>
</header>
<div className="mb-4 inline-flex rounded-lg border p-1">
<SegBtn active={tab === 'library'} onClick={() => setTab('library')} icon={BookMarked}>Library</SegBtn>
<SegBtn active={tab === 'marketplace'} onClick={() => setTab('marketplace')} icon={Store}>Marketplace</SegBtn>
</div>
{tab === 'library' ? (
<div className="flex flex-col gap-4">
{groups.map(([key, versions]) => (
<SkillGroupCard
key={key}
versions={versions}
busy={busy}
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 && (
<p className="text-sm text-muted-foreground">No skills yet. Run a Git sync for builtins, or author one.</p>
)}
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
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(({ 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 flex-wrap items-center gap-2">
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<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. Publish one of your own skills to share it here.
</p>
)}
</div>
)}
</div>
{form && (
<Sheet open onOpenChange={(o) => !o && setForm(null)}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>
{form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`}
</SheetTitle>
<SheetDescription>
{willPublish
? 'Has roles + a golden test saves as Published.'
: 'Add 1 role and 1 golden test to publish; otherwise saved as Draft.'}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4 pb-6">
<div className="grid grid-cols-2 gap-3">
<Field label="Skill key (id)">
<Input
value={form.skillKey}
disabled={form.mode !== 'new'}
onChange={(e) => setForm({ ...form, skillKey: e.target.value })}
placeholder="api-endpoint-design"
/>
</Field>
<Field label="Version">
<Input value={form.version} disabled={form.mode === 'edit'} onChange={(e) => setForm({ ...form, version: e.target.value })} />
</Field>
</div>
<Field label="Name">
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" />
</Field>
<Field label="Summary">
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} />
</Field>
<Field label={`Roles (comma-separated — e.g. ${COMMON_ROLES})`}>
<Input value={form.roles} onChange={(e) => setForm({ ...form, roles: e.target.value })} placeholder="engineer" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Inputs">
<Input value={form.inputs} onChange={(e) => setForm({ ...form, inputs: e.target.value })} />
</Field>
<Field label="Outputs">
<Input value={form.outputs} onChange={(e) => setForm({ ...form, outputs: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Visibility">
<Pick value={form.visibility} options={VISIBILITIES} onChange={(v) => setForm({ ...form, visibility: v })} />
</Field>
<Field label="Min tier">
<Pick value={form.minTier} options={TIERS} onChange={(v) => setForm({ ...form, minTier: v })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Tools (comma-separated)">
<Input value={form.tools} onChange={(e) => setForm({ ...form, tools: e.target.value })} />
</Field>
<Field label="Context docs (comma-separated)">
<Input value={form.context} onChange={(e) => setForm({ ...form, context: e.target.value })} />
</Field>
</div>
<Repeater
label="Actions (risk-tagged)"
onAdd={() => setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })}
>
{form.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input
className="flex-1" placeholder="action name" value={a.name}
onChange={(e) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })}
/>
<Pick
value={a.risk} options={RISKS} className="w-32"
onChange={(v) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })}
/>
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, actions: form.actions.filter((_, j) => j !== i) })}>
<Trash2 className="size-4" />
</Button>
</div>
))}
</Repeater>
<Repeater
label="Golden tests (gate publishing)"
onAdd={() => setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })}
>
{form.goldenTests.map((g, i) => (
<div key={i} className="flex flex-col gap-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
className="flex-1" placeholder="input" value={g.input}
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })}
/>
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, goldenTests: form.goldenTests.filter((_, j) => j !== i) })}>
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
rows={2} placeholder="expected output" value={g.expected}
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, expected: e.target.value } : x)) })}
/>
</div>
))}
</Repeater>
<Field label="Body (the prompt the agent runs)">
<Textarea rows={8} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="You are the engineer. Turn the input into…" />
</Field>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(null)}>Cancel</Button>
<Button disabled={busy || !form.skillKey.trim() || !form.name.trim() || !form.body.trim()} onClick={save}>
Save {willPublish ? '& publish' : 'draft'}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
)}
</AppShell>
)
}
function SkillGroupCard({
versions,
busy,
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>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-base">
{current.name}
<span className="font-mono text-xs text-muted-foreground">{current.skillKey}</span>
</CardTitle>
<CardDescription className="mt-1">{current.summary}</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant={current.status === 'Published' ? 'default' : 'secondary'}>{current.status}</Badge>
<Badge variant="outline">{current.origin}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
{versions.length > 1 ? (
<Pick value={selected} options={versions.map((v) => v.version)} className="w-28" onChange={setSelected} />
) : (
<Badge variant="outline">{current.version}</Badge>
)}
{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 ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
<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>
{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
</Button>
</div>
</CardContent>
</Card>
)
}
function SegBtn({ active, onClick, icon: Icon, children }: { active: boolean; onClick: () => void; icon: typeof BookMarked; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
<Icon className="size-4" /> {children}
</button>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label className="text-xs">{label}</Label>
{children}
</div>
)
}
function Pick({ value, options, onChange, className }: { value: string; options: string[]; onChange: (v: string) => void; className?: string }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className={className ?? 'w-full'}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)
}
function Repeater({ label, onAdd, children }: { label: string; onAdd: () => void; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}><Plus data-icon="inline-start" /> Add</Button>
</div>
{children}
</div>
)
}