Dynamic per-org skill library: in-app authoring, versioning, fork (+ marketplace seam)

Skills move from a global Git-only registry to a per-company library that orgs author and
version in-app — Git stays as the shared *starter* library.

Domain & persistence:
- Skill gains OrganizationId (null = shared builtin, visible to every org), Origin
  (Builtin | Authored | Installed), AuthoredByMemberId. Identity is now
  (OrganizationId, SkillKey, Version); the unique index uses NULLS NOT DISTINCT so builtins
  stay unique by key+version while each org gets its own namespace (and can fork a builtin).
  AddSkillOwnership migration backfills existing rows as Builtin.
- Owned GoldenExample rows are cloned in Skill.Index so a fork can't re-parent the source's
  tracked entities.

Authoring (tenant, dynamic):
- POST /api/skills/authored — structured fields → same indexer pipeline (embedding +
  publish gate apply identically), tagged org + author. POST /api/skills/{key}/fork copies a
  builtin/global skill into your org as an editable Authored draft. List/Get are org-scoped
  (your org + shared builtins). New Capability.ManageSkills (Owner + TeamOwner), audited.
- GET /api/skills/marketplace: read-only seam listing public skills across orgs (install is
  the next step).

Security (from adversarial review — two confirmed criticals):
- Managing shared builtins is an operator action, not a tenant one. /index (posts arbitrary
  content as a global builtin) and /sync (re-indexes the shared library) now require a
  platform admin key (X-Skills-Admin-Key, fixed-time compare, fail-closed when unset) via
  SkillAdminOptions — previously any authenticated user of any org could inject/poison global
  skills. New test asserts an authenticated Owner without the key gets 403 on both.

UI: new /skills library page — browse shared + org skills grouped by key with their versions,
create / new-version / fork, golden-test editor + body, Draft/Published badge and the
publish-gate hint (needs roles + ≥1 golden test).

Verified: ArchitectureTests 8/8, IntegrationTests 46/46 (new SkillLibraryTests: org
isolation, version coexistence, fork, publish gate, Member 403, admin-gate 403), client build
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 11:09:02 +03:30
parent 414ff44b48
commit fad476f115
24 changed files with 1398 additions and 37 deletions
+2
View File
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { SkillsPage } from '@/pages/SkillsPage'
import { StructurePage } from '@/pages/StructurePage'
import { useAuth } from '@/store/auth'
@@ -27,6 +28,7 @@ export default function App() {
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
<Route path="/skills" element={token ? <SkillsPage /> : <Navigate to="/login" replace />} />
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import {
BookMarked,
Bot,
Boxes,
ChartColumn,
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={BookMarked} label="Skills" to="/skills" />
<NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Boxes} label="Structure" to="/structure" />
<NavItem icon={Users} label="Members" to="/members" />
+523
View File
@@ -0,0 +1,523 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } 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 {
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
}
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: 'public',
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<SkillSummary[]>([])
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<SkillSummary[]>(`/api/skills/marketplace`),
])
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 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)}
/>
))}
{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">
Public skills shared by other organizations. One-click install lands in the next step.
</p>
{marketplace.map((s) => (
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
{s.name} <Badge variant="outline">{s.version}</Badge>
</CardTitle>
<CardDescription>{s.summary}</CardDescription>
</CardHeader>
<CardContent className="flex 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>
</CardContent>
</Card>
))}
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</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,
}: {
versions: SkillSummary[]
busy: boolean
onNewVersion: (version: string) => void
onEdit: (version: string) => void
onFork: (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'
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>
<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>
)}
<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>
)
}