Compare commits

...

34 commits

Author SHA1 Message Date
67568a08f6 feat(12-02): SkillDetail Ratings tab and Overview usage stats
- Add Ratings tab (4th tab) with rate form, personal history, community section
- Overview tab: conditionally render taskCount, avgCostUsd, lastUsedAt rows
- Import StarRating, Separator, Skeleton, Textarea, EmptyState, TooltipProvider
- saveRatingMutation calls addRating, invalidates ratings query on success
- ratingsQuery loads personal history with loading/empty/error states
2026-04-01 04:25:00 +02:00
bea6144e5a feat(12-02): StarRating component, API extensions, DesignGuide entry
- Create StarRating component with interactive/readonly modes, amber stars, size sm/md
- Add PersonalRating type and taskCount/avgCostUsd/lastUsedAt to SkillListItem
- Add getRatings and addRating to skillRegistryApi
- Add Rating System section to DesignGuide with all variants
- Fix SkillCard fixture and DesignGuide examples to include new SkillListItem fields
2026-04-01 04:23:04 +02:00
b52f5a8adf feat(12-01): ratings routes, community ratings in fetcher, list/getById JOIN, heartbeat hook
- Add POST/GET /skill-registry/skills/:sourceId/:slug/ratings routes
- Import skillRatingService in skill-registry routes
- Add upsertCommunityRatingsStub() in fetcher, called after each skill upsert
- Import communityRatings from schema in fetcher
- Update list() and getById() in skill-registry.ts to LEFT JOIN communityRatings
- Include averageRating, ratingCount, taskCount, avgCostUsd, lastUsedAt in SkillListItem
- Add agentSkills usage aggregation via LEFT JOIN + SUM/AVG/MAX
- Add fire-and-forget recordUsageForAgent call in heartbeat after finalizeAgentStatus
- Dynamic import keeps skill-registry-ratings off critical startup path
- All 44 skill-registry tests pass, full server suite (536) green
2026-04-01 04:08:18 +02:00
1a1c3ce399 feat(12-01): personalRatings schema, DB DDL, skillRatingService, and tests
- Add personalRatings table to skill-registry-schema.ts
- Add taskCount, avgCostUsd, lastUsedAt columns to agentSkills in schema
- Add CREATE_PERSONAL_RATINGS_TABLE DDL constant in skill-registry-db.ts
- Add ALTER TABLE statements for new agent_skills usage columns (idempotent)
- Create skill-registry-ratings.ts with skillRatingService factory
- rate() appends personal rating, validates stars 1-5
- getRatings() returns ratings ordered by createdAt DESC
- recordUsageForAgent() atomically updates task_count, avg_cost_usd, last_used_at
- All 8 tests pass
2026-04-01 04:05:49 +02:00
3b2fbe97ef fix(11): default agentSkillsDir server-side — GROUP-03/GROUP-04 gap closure 2026-04-01 03:46:58 +02:00
1a85831d8a feat(11-04): extend AgentSkillsTab with groups section and dialogs
- Add imports: skillGroupsApi, SkillGroupRow, GroupBadge, Dialog, Separator, ScrollArea, Textarea
- Add agentGroups + allGroups + agentEffectiveSkills queries in AgentSkillsTab
- Add assignGroup + removeGroup + createGroup mutations
- Add state for add/create/remove dialogs, search, new group fields
- Insert Assigned Groups section with loading/empty/populated states
- Insert Combined Effective Skills collapsible section with ScrollArea
- Insert Additional Individual Skills label above existing skill list
- Add Add Skill Group, Create Skill Group, Remove group from agent dialogs
- Add Skill Groups section to DesignGuide with all GroupBadge variants
2026-04-01 03:38:30 +02:00
e7f487a841 feat(11-04): create GroupBadge component
- Built-in variant: Badge secondary, no dismiss, hover:bg-accent/30
- Custom variant: Badge outline, X dismiss button with spinner, hover:bg-accent/50
- Tooltip on both variants showing name · built-in · N skills or name · N skills
- text-sm font-semibold typography per UI-SPEC (no font-bold or font-medium)
2026-04-01 03:35:45 +02:00
8436f3b981 feat(11-03): add skill groups frontend API client and query keys
- Create ui/src/api/skillGroups.ts with skillGroupsApi object
- All 14 methods covering group CRUD, members, export/import, agent assignments
- removeGroup uses raw fetch for DELETE-with-body (api.delete has no body support)
- Add skillGroups namespace to ui/src/lib/queryKeys.ts with 6 key factories
2026-04-01 03:32:17 +02:00
4172d7d23f feat(11-03): add skill group routes, mount in app.ts, startup reconciliation
- Create server/src/routes/skill-registry-groups.ts with skillGroupRoutes() factory
- All 14 REST routes for group CRUD, members, export/import, and agent assignments
- Import route registered before :groupId param to avoid route collision
- assertBoard on every handler, error classification (400/404/409/500)
- Mount skillGroupRoutes() in app.ts after skillRegistryRoutes()
- Add pendingSkillGroups fire-and-forget reconciliation in index.ts startup
2026-04-01 03:31:02 +02:00
40165ffae1 feat(11-02): skillGroupService() with CRUD, membership, inheritance, assignment, import/export
- Group CRUD: listGroups, getGroup, createGroup, updateGroup, deleteGroup (guards built-in)
- Member management: addMember, removeMember, listMembers
- Inheritance: addParent (with cycle detection BFS), removeParent, listParents
- resolveEffectiveSkills: BFS walk with visited-set guard for cycle safety
- assignGroup: installs all effective skills, tracks in agent_skills, returns installed/skipped/pendingPlugin
- removeGroup: set-difference uninstall with fs.rm() for file removal (not skillRegistryService.uninstall)
- listAgentGroups, listAgentSkills, getAgentEffectiveSkills
- exportGroup / importGroup: GroupExport v1 JSON with cycle check on import
2026-04-01 03:25:26 +02:00
f6c92a8bbe feat(11-01): add group table DDL and built-in group seeding to skill-registry-db
- Import LibSQLClient type for seedBuiltinGroups parameter typing
- Add DDL constants for 5 new tables: skill_groups, skill_group_members,
  skill_group_inheritance, agent_skill_groups, agent_skills
- Add BUILTIN_GROUPS constant with 5 entries (pm-essentials, engineer-core,
  frontend, backend, creative)
- Add seedBuiltinGroups() using INSERT OR IGNORE for idempotent seeding
- Extend getSkillRegistryDb() to execute all 5 new DDL statements and seed
2026-04-01 03:21:33 +02:00
5c2ce8b940 feat(11-01): add five Drizzle table definitions for skill groups
- Add primaryKey import from drizzle-orm/sqlite-core
- Add skillGroups table with id, name, description, isBuiltin, timestamps
- Add skillGroupMembers junction table with composite PK (groupId, skillId)
- Add skillGroupInheritance table with composite PK (childGroupId, parentGroupId)
- Add agentSkillGroups table with composite PK (agentId, groupId)
- Add agentSkills table with composite PK (agentId, skillId)
2026-04-01 03:20:48 +02:00
7e48f924f1 fix(10): correct toast error messages in SkillDetail — Update/Uninstall not Install/Rollback 2026-04-01 02:56:23 +02:00
e07b8fba18 feat(10-03): update DesignGuide SkillCard section with all 5 required variants
- Expand grid to lg:grid-cols-3 for 5-card layout
- Add Loading (updating) variant (5th card per UI-SPEC requirement)
- Add onRollback/onUninstall props to installed variants
- Add descriptive comments for each variant state
2026-04-01 02:40:37 +02:00
5931ba2898 feat(10-03): implement SkillDetail page with Overview, Versions, Diff tabs
- Full SkillDetail.tsx replacing stub (Overview, Versions, Diff tabs)
- Separate installMutation and updateMutation with distinct toast messages
- VersionDiff component using diffLines from diff package
- Version selector with Select dropdowns in Diff tab
- ScrollArea for version list in Versions tab
- Install/Update/Uninstall dialogs with confirmation
- PageSkeleton variant=detail for loading state
- SkillDetail.test.tsx with 4 SSR smoke tests (all passing)
- diff + @types/diff packages installed in ui workspace
2026-04-01 02:39:57 +02:00
4fa69aefd2 test(10-02): add SkillBrowser SSR smoke tests
- 4 node-environment tests using renderToStaticMarkup
- Verifies page title, search input, refresh button, and tab labels render
- Mocks react-query, router, context providers for SSR compatibility
2026-04-01 02:39:21 +02:00
f492ec49f0 feat(10-02): build SkillBrowser page with Browse tab, dialogs, and 5 mutations
- Replace stub with full three-tab page (Browse, Installed, Trending placeholders)
- Five separate mutations: fetch, install, update, rollback, remove with distinct toasts
- Agent selector dialog with skills directory input for install/update flow
- Uninstall confirmation dialog with destructive button
- Browse tab: search, source filter, category filter, sort, FilterBar, card grid, EmptyState
- Breadcrumbs wired via useBreadcrumbs, data via TanStack Query
2026-04-01 02:38:24 +02:00
e3e4450113 feat(10-01): add SkillCard component, tests, route wiring, and design guide entry
- Create SkillCard.tsx with 3-row layout per UI-SPEC (name link, description, source/rating/actions)
- Create SkillCard.test.tsx with 8 passing unit tests using renderToStaticMarkup
- Create SkillBrowser.tsx and SkillDetail.tsx stub pages
- Update App.tsx: remove CompanySkills import, add skills and skills/detail/:skillId routes
- Add SkillCard to DesignGuide.tsx with 4 variants (default, installed, update available, loading)
2026-04-01 02:32:55 +02:00
776255425a feat(10-01): add skillRegistry API client and query keys
- Create ui/src/api/skillRegistry.ts with typed methods for all 7 endpoints
- Use two-segment URL paths (/sourceId/slug) for Express 5 compatibility
- Add skillRegistry namespace to queryKeys.ts (list/detail/versions)
2026-04-01 02:30:29 +02:00
c9719cbdae feat(09-04): implement skill registry REST routes and mount in app.ts (GREEN)
- Create skillRegistryRoutes() factory with 6 endpoints
- GET /api/skill-registry/skills (list, includeRemoved support)
- GET /api/skill-registry/skills/:sourceId/:slug (single skill, 404 on missing)
- GET /api/skill-registry/skills/:sourceId/:slug/versions (version history)
- POST /api/skill-registry/fetch (trigger fetchAllSources)
- POST /api/skill-registry/skills/:sourceId/:slug/install (copy to agent dir)
- POST /api/skill-registry/skills/:sourceId/:slug/rollback (restore prior version)
- DELETE /api/skill-registry/skills/:sourceId/:slug (soft-delete)
- assertBoard auth guard on every route
- Mount skillRegistryRoutes() in app.ts after companySkillRoutes
- Update tests to use two-segment path params (sourceId/slug) for Express 5 compatibility
- All 12 route tests pass
2026-04-01 01:31:22 +02:00
ccd6e6f162 test(09-04): add failing tests for skill registry routes (RED)
- Tests for GET /api/skill-registry/skills (list, includeRemoved param)
- Tests for GET /api/skill-registry/skills/:id (found, 404)
- Tests for GET /api/skill-registry/skills/:id/versions
- Tests for POST /api/skill-registry/fetch
- Tests for POST /api/skill-registry/skills/:id/install (success, 400)
- Tests for POST /api/skill-registry/skills/:id/rollback (success, 400)
- Tests for DELETE /api/skill-registry/skills/:id
2026-04-01 01:27:17 +02:00
3700c75a86 feat(09-03): wire skillRegistryService export and startup DB init
- Add skillRegistryService re-export to services/index.ts after companySkillService
- Add fire-and-forget skill registry DB init in server/src/index.ts after reconcile block
- Uses dynamic import to avoid adding libSQL to critical startup path
2026-04-01 01:12:40 +02:00
2b58169600 feat(09-03): implement skillRegistryService with install, uninstall, rollback, list
- install() copies cached files to agent .claude/skills/<slug>/ dir
- install() returns pending_plugin_install for skills with file kind=plugin
- uninstall() soft-deletes via removed_at timestamp
- rollback() restores prior version from cache and updates active_version_id
- list() filters soft-deleted by default; includeRemoved=true returns all
- fetchAll() delegates to fetchAllSources for multi-source refresh
2026-04-01 01:11:25 +02:00
5b2fe34223 test(09-03): add failing tests for skillRegistryService install/rollback/uninstall/list 2026-04-01 01:10:51 +02:00
9c2569ebb0 feat(09-02): implement multi-source skill fetcher with file caching
- SkillSourceConfig type + BUILT_IN_SOURCES (3 sources: anthropic, schwepps, daymade)
- fetchAllSources() fetches from anthropic-marketplace and github-tree source types
- parseSkillFrontmatter() extracts name/description from SKILL.md YAML blocks
- Idempotency: checks version exists before fetching, skips re-download on same SHA
- Caches SKILL.md to skills/cache/<skill-id>/<sha>/SKILL.md on disk
- Inserts skills, skill_versions, and skill_files rows into registry.db
- All 7 tests passing (TDD GREEN)
2026-04-01 01:06:24 +02:00
8c86031b50 test(09-02): add failing tests for skill-registry-fetcher
- 7 tests covering fetch from Anthropic marketplace and GitHub tree sources
- Tests DB insertion, file caching, idempotency, and BUILT_IN_SOURCES config
- All tests fail with ERR_MODULE_NOT_FOUND (expected TDD RED state)
2026-04-01 01:04:57 +02:00
d26b888957 feat(09-01): extract GitHub fetch helpers to shared module
- Create github-skill-helpers.ts with fetchText, fetchJson, resolveGitHubDefaultBranch, resolveGitHubCommitSha, parseGitHubSourceUrl, resolveGitHubPinnedRef, resolveRawGitHubUrl
- Update company-skills.ts to import from github-skill-helpers.js instead of defining locally
- All existing company-skill tests pass (15/15)
2026-04-01 01:01:37 +02:00
16ceef77d2 feat(09-01): install @libsql/client, schema, DB init, path helpers
- Install @libsql/client@^0.17.2 to server package
- Create skill-registry-schema.ts with 4 sqliteTable definitions (skills, skillVersions, skillFiles, communityRatings)
- Create skill-registry-db.ts with lazy singleton getSkillRegistryDb() and resetSkillRegistryDb()
- Add resolveSkillRegistryDbPath() and resolveSkillCacheDir() to home-paths.ts
- Add skill-registry-schema.test.ts with 8 passing tests (TDD green)
2026-04-01 01:00:25 +02:00
ade26c0cc2 feat(08-02): add ensureGeneralistAgents startup migration for existing workspaces
- Import agentService and agents table into server/src/index.ts
- ensureGeneralistAgents() queries all companies, skips any that already have
  a general-role agent (idempotent), creates Generalist via agentService.create()
- metadata includes pendingSkillGroups: [Creative] and backfilled: true flag
- Called with fire-and-forget void pattern after ensureLocalTrustedBoardPrincipal
- Existing workspaces get Generalist on next server upgrade without user action
2026-04-01 00:19:07 +02:00
ab0e15f950 fix(08-02): update agent-skills-routes test expectations for rewritten bundles
[Rule 1 - Bug] Tests expected old upstream bundle content strings (You are the CEO.,
CEO Heartbeat Checklist, CEO Persona, Keep the work moving until it is done.)
but Phase 08-01 rewrote all CEO and engineer bundles with PM-focused content.
Updated assertions to match actual bundle output.
2026-04-01 00:17:22 +02:00
749a0a6c96 feat(08-02): add Generalist agent creation to onboarding wizard
- Third agentsApi.create() call with role: "general", name: "Generalist"
- metadata: { pendingSkillGroups: ["Creative"] } records Phase 11 intent
- Updated description text: mentions all 3 agents (PM, engineer, generalist)
- Placed BEFORE queryClient.invalidateQueries for clean ordering
2026-04-01 00:14:39 +02:00
b2dfd5c22e feat(08-01): update PM routing rules to include Generalist delegation
- Add 'Copy, branding, research, legal, docs, presentations -> Generalist agent' routing rule
- Inserted between Engineer and Cross-functional rules in Delegation section
2026-04-01 00:08:17 +02:00
715d9f42cb feat(08-01): add Generalist agent template bundle and wire role mapping
- Create server/src/onboarding-assets/general/ with 4 files (AGENTS.md, SOUL.md, HEARTBEAT.md, TOOLS.md)
- Add general role to DEFAULT_AGENT_BUNDLE_FILES with full 4-file bundle
- Add resolveDefaultAgentInstructionsBundleRole branch for general role
- Rename AGENT_ROLE_LABELS general from 'General' to 'Generalist'
2026-04-01 00:07:59 +02:00
120cadb517 [nexus] fix: replace radix DialogPortal with createPortal in NexusOnboardingWizard 2026-03-31 17:55:02 +02:00
46 changed files with 6168 additions and 106 deletions

View file

@ -60,7 +60,7 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
qa: "QA",
devops: "DevOps",
researcher: "Researcher",
general: "General",
general: "Generalist", // [nexus] was: "General"
};
export const AGENT_ICON_NAMES = [

284
pnpm-lock.yaml generated
View file

@ -78,7 +78,7 @@ importers:
version: 17.3.1
drizzle-orm:
specifier: 0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
@ -236,7 +236,7 @@ importers:
version: link:../shared
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
@ -449,6 +449,9 @@ importers:
'@aws-sdk/client-s3':
specifier: ^3.888.0
version: 3.994.0
'@libsql/client':
specifier: ^0.17.2
version: 0.17.2
'@paperclipai/adapter-claude-local':
specifier: workspace:*
version: link:../packages/adapters/claude-local
@ -490,7 +493,7 @@ importers:
version: 3.0.1(ajv@8.18.0)
better-auth:
specifier: 1.4.18
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0))
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0))
chokidar:
specifier: ^4.0.3
version: 4.0.3
@ -505,7 +508,7 @@ importers:
version: 17.3.1
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
@ -651,6 +654,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
diff:
specifier: ^8.0.4
version: 8.0.4
lexical:
specifier: 0.35.0
version: 0.35.0
@ -685,6 +691,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.7
version: 4.1.18(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
'@types/node':
specifier: ^25.2.3
version: 25.2.3
@ -2017,6 +2026,63 @@ packages:
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@libsql/client@0.17.2':
resolution: {integrity: sha512-0aw0S3iQMHvOxfRt5j1atoCCPMT3gjsB2PS8/uxSM1DcDn39xqz6RlgSMxtP8I3JsxIXAFuw7S41baLEw0Zi+Q==}
'@libsql/core@0.17.2':
resolution: {integrity: sha512-L8qv12HZ/jRBcETVR3rscP0uHNxh+K3EABSde6scCw7zfOdiLqO3MAkJaeE1WovPsjXzsN/JBoZED4+7EZVT3g==}
'@libsql/darwin-arm64@0.5.29':
resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==}
cpu: [arm64]
os: [darwin]
'@libsql/darwin-x64@0.5.29':
resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==}
cpu: [x64]
os: [darwin]
'@libsql/hrana-client@0.9.0':
resolution: {integrity: sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw==}
'@libsql/isomorphic-ws@0.1.5':
resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
'@libsql/linux-arm-gnueabihf@0.5.29':
resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==}
cpu: [arm]
os: [linux]
'@libsql/linux-arm-musleabihf@0.5.29':
resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==}
cpu: [arm]
os: [linux]
'@libsql/linux-arm64-gnu@0.5.29':
resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==}
cpu: [arm64]
os: [linux]
'@libsql/linux-arm64-musl@0.5.29':
resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==}
cpu: [arm64]
os: [linux]
'@libsql/linux-x64-gnu@0.5.29':
resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==}
cpu: [x64]
os: [linux]
'@libsql/linux-x64-musl@0.5.29':
resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==}
cpu: [x64]
os: [linux]
'@libsql/win32-x64-msvc@0.5.29':
resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==}
cpu: [x64]
os: [win32]
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@ -2037,6 +2103,9 @@ packages:
'@mermaid-js/parser@1.0.0':
resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==}
'@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
@ -3390,6 +3459,10 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/diff@8.0.0':
resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==}
deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed.
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -3853,6 +3926,9 @@ packages:
engines: {node: '>=20'}
hasBin: true
cross-fetch@4.1.0:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -4033,6 +4109,10 @@ packages:
dagre-d3-es@7.0.13:
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@ -4096,6 +4176,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.0.2:
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -4118,6 +4202,10 @@ packages:
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
engines: {node: '>=0.3.1'}
diff@8.0.4:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dompurify@3.3.2:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
@ -4389,6 +4477,10 @@ packages:
picomatch:
optional: true
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@ -4401,6 +4493,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
formidable@3.5.4:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
@ -4599,6 +4695,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -4664,6 +4763,11 @@ packages:
engines: {node: '>=16'}
hasBin: true
libsql@0.5.29:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32]
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@ -5029,6 +5133,24 @@ packages:
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@ -5213,6 +5335,9 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -5628,6 +5753,9 @@ packages:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
@ -5906,6 +6034,13 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@ -5918,6 +6053,9 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -7665,6 +7803,68 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@libsql/client@0.17.2':
dependencies:
'@libsql/core': 0.17.2
'@libsql/hrana-client': 0.9.0
js-base64: 3.7.8
libsql: 0.5.29
promise-limit: 2.7.0
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@libsql/core@0.17.2':
dependencies:
js-base64: 3.7.8
'@libsql/darwin-arm64@0.5.29':
optional: true
'@libsql/darwin-x64@0.5.29':
optional: true
'@libsql/hrana-client@0.9.0':
dependencies:
'@libsql/isomorphic-ws': 0.1.5
cross-fetch: 4.1.0
js-base64: 3.7.8
node-fetch: 3.3.2
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@libsql/isomorphic-ws@0.1.5':
dependencies:
'@types/ws': 8.18.1
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/linux-arm-gnueabihf@0.5.29':
optional: true
'@libsql/linux-arm-musleabihf@0.5.29':
optional: true
'@libsql/linux-arm64-gnu@0.5.29':
optional: true
'@libsql/linux-arm64-musl@0.5.29':
optional: true
'@libsql/linux-x64-gnu@0.5.29':
optional: true
'@libsql/linux-x64-musl@0.5.29':
optional: true
'@libsql/win32-x64-msvc@0.5.29':
optional: true
'@marijn/find-cluster-break@1.0.2': {}
'@mdxeditor/editor@3.52.4(@codemirror/language@6.12.1)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
@ -7744,6 +7944,8 @@ snapshots:
dependencies:
langium: 4.2.1
'@neon-rs/load@0.0.4': {}
'@noble/ciphers@2.1.1': {}
'@noble/hashes@1.8.0': {}
@ -9220,6 +9422,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/diff@8.0.0':
dependencies:
diff: 8.0.4
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@ -9446,7 +9652,7 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)):
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
@ -9462,7 +9668,7 @@ snapshots:
zod: 4.3.6
optionalDependencies:
drizzle-kit: 0.31.9
drizzle-orm: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
drizzle-orm: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
pg: 8.18.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -9669,6 +9875,12 @@ snapshots:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-fetch@4.1.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -9880,6 +10092,8 @@ snapshots:
d3: 7.9.0
lodash-es: 4.17.23
data-uri-to-buffer@4.0.1: {}
data-urls@7.0.0(@noble/hashes@2.0.1):
dependencies:
whatwg-mimetype: 5.0.0
@ -9926,6 +10140,8 @@ snapshots:
dequal@2.0.3: {}
detect-libc@2.0.2: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@ -9945,6 +10161,8 @@ snapshots:
diff@5.2.2: {}
diff@8.0.4: {}
dompurify@3.3.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -9971,9 +10189,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4):
drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4):
optionalDependencies:
'@electric-sql/pglite': 0.3.15
'@libsql/client': 0.17.2
'@types/react': 19.2.14
kysely: 0.28.11
pg: 8.18.0
@ -10240,6 +10459,11 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@ -10261,6 +10485,10 @@ snapshots:
format@0.2.2: {}
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
formidable@3.5.4:
dependencies:
'@paralleldrive/cuid2': 2.3.1
@ -10451,6 +10679,8 @@ snapshots:
joycon@3.1.1: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@ -10520,6 +10750,21 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
libsql@0.5.29:
dependencies:
'@neon-rs/load': 0.0.4
detect-libc: 2.0.2
optionalDependencies:
'@libsql/darwin-arm64': 0.5.29
'@libsql/darwin-x64': 0.5.29
'@libsql/linux-arm-gnueabihf': 0.5.29
'@libsql/linux-arm-musleabihf': 0.5.29
'@libsql/linux-arm64-gnu': 0.5.29
'@libsql/linux-arm64-musl': 0.5.29
'@libsql/linux-x64-gnu': 0.5.29
'@libsql/linux-x64-musl': 0.5.29
'@libsql/win32-x64-msvc': 0.5.29
lightningcss-android-arm64@1.30.2:
optional: true
@ -11165,6 +11410,18 @@ snapshots:
next-tick@1.1.0: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-releases@2.0.27: {}
object-assign@4.1.1: {}
@ -11362,6 +11619,8 @@ snapshots:
process-warning@5.0.0: {}
promise-limit@2.7.0: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@ -11905,6 +12164,8 @@ snapshots:
dependencies:
tldts: 7.0.26
tr46@0.0.3: {}
tr46@6.0.0:
dependencies:
punycode: 2.3.1
@ -12253,6 +12514,10 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
@ -12265,6 +12530,11 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

View file

@ -44,6 +44,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@libsql/client": "^0.17.2",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",

View file

@ -368,10 +368,10 @@ describe("agent skill routes", () => {
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
"AGENTS.md": expect.stringContaining("You are the Project Manager for this Nexus workspace."),
"HEARTBEAT.md": expect.stringContaining("Project Manager Task Loop"),
"SOUL.md": expect.stringContaining("Project Manager Persona"),
"TOOLS.md": expect.stringContaining("# TOOLS.md"),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
@ -395,7 +395,7 @@ describe("agent skill routes", () => {
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
"AGENTS.md": expect.stringContaining("You are a Senior Engineer in this Nexus workspace."),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);

View file

@ -0,0 +1,404 @@
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ---------------------------------------------------------------------------
// Helpers for building mock responses
// ---------------------------------------------------------------------------
function mockMarketplaceJson(skills: Array<{ path: string }>) {
return JSON.stringify({ skills });
}
function mockGitHubTree(paths: string[]) {
return JSON.stringify({
tree: paths.map((p) => ({ path: p, type: "blob", size: 100 })),
});
}
function mockSkillMd(name: string, description: string) {
return `---
name: ${name}
description: ${description}
---
# ${name}
A skill for testing.
`;
}
function mockCommitSha(sha = "abc1234567890abcdef1234567890abcdef123456") {
return JSON.stringify({ sha });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("skill-registry-fetch", () => {
let tmpDir: string;
let originalPaperclipHome: string | undefined;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "skill-fetch-test-"));
originalPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = tmpDir;
// Mock global.fetch
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
const { resetSkillRegistryDb } = await import("../services/skill-registry-db.js");
resetSkillRegistryDb();
if (originalPaperclipHome === undefined) {
delete process.env.PAPERCLIP_HOME;
} else {
process.env.PAPERCLIP_HOME = originalPaperclipHome;
}
await rm(tmpDir, { recursive: true, force: true });
});
// -------------------------------------------------------------------------
// Test 1: Anthropic marketplace source inserts skills rows with sourceId
// -------------------------------------------------------------------------
it("Test 1: fetchAllSources() with mocked Anthropic marketplace.json inserts skills rows with sourceId='anthropic-official'", async () => {
const sha = "abc1234567890abcdef1234567890abcdef123456";
// Mock all fetch calls
fetchSpy.mockImplementation(async (url: string, opts?: RequestInit) => {
const urlStr = String(url);
// marketplace.json
if (urlStr.includes("marketplace.json")) {
return {
ok: true,
text: async () => mockMarketplaceJson([{ path: "coding/my-skill" }]),
json: async () => ({ skills: [{ path: "coding/my-skill" }] }),
};
}
// commit SHA lookup
if (urlStr.includes("/commits/")) {
return {
ok: true,
text: async () => mockCommitSha(sha),
json: async () => ({ sha }),
};
}
// SKILL.md raw content
if (urlStr.includes("SKILL.md")) {
return {
ok: true,
text: async () => mockSkillMd("My Skill", "A test skill from Anthropic"),
json: async () => ({}),
};
}
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
});
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skills } = await import("../services/skill-registry-schema.js");
// Fetch only the Anthropic source
const anthropicSource = BUILT_IN_SOURCES.find((s) => s.id === "anthropic-official")!;
const result = await fetchAllSources([anthropicSource]);
expect(result.errors).toHaveLength(0);
expect(result.fetched).toBeGreaterThan(0);
const db = await getSkillRegistryDb();
const rows = await db.select().from(skills);
expect(rows.length).toBeGreaterThan(0);
expect(rows[0]!.sourceId).toBe("anthropic-official");
});
// -------------------------------------------------------------------------
// Test 2: Community GitHub source inserts skills rows with correct sourceId
// -------------------------------------------------------------------------
it("Test 2: fetchAllSources() with mocked GitHub tree API for community repo inserts skills rows with correct sourceId", async () => {
const sha = "deadbeef1234567890abcdef1234567890abcdef";
fetchSpy.mockImplementation(async (url: string) => {
const urlStr = String(url);
if (urlStr.includes("/git/trees/")) {
return {
ok: true,
text: async () => mockGitHubTree(["code-review/SKILL.md", "code-review/rules/rules.md"]),
json: async () => ({
tree: [
{ path: "code-review/SKILL.md", type: "blob", size: 200 },
{ path: "code-review/rules/rules.md", type: "blob", size: 500 },
],
}),
};
}
if (urlStr.includes("/commits/")) {
return {
ok: true,
text: async () => mockCommitSha(sha),
json: async () => ({ sha }),
};
}
if (urlStr.includes("SKILL.md")) {
return {
ok: true,
text: async () => mockSkillMd("Code Review", "Reviews code for quality"),
json: async () => ({}),
};
}
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
});
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skills } = await import("../services/skill-registry-schema.js");
const communitySource = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
const result = await fetchAllSources([communitySource]);
expect(result.errors).toHaveLength(0);
const db = await getSkillRegistryDb();
const rows = await db.select().from(skills);
expect(rows.length).toBeGreaterThan(0);
expect(rows[0]!.sourceId).toBe("schwepps-skills");
});
// -------------------------------------------------------------------------
// Test 3: Each fetched skill has a skill_versions row with commit SHA
// -------------------------------------------------------------------------
it("Test 3: Each fetched skill has a skill_versions row with the commit SHA as version", async () => {
const sha = "cafebabe1234567890abcdef1234567890abcdef";
fetchSpy.mockImplementation(async (url: string) => {
const urlStr = String(url);
if (urlStr.includes("/git/trees/")) {
return {
ok: true,
json: async () => ({
tree: [{ path: "test-skill/SKILL.md", type: "blob", size: 100 }],
}),
};
}
if (urlStr.includes("/commits/")) {
return {
ok: true,
json: async () => ({ sha }),
};
}
if (urlStr.includes("SKILL.md")) {
return {
ok: true,
text: async () => mockSkillMd("Test Skill", "A test skill"),
};
}
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
});
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skillVersions } = await import("../services/skill-registry-schema.js");
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
await fetchAllSources([source]);
const db = await getSkillRegistryDb();
const versions = await db.select().from(skillVersions);
expect(versions.length).toBeGreaterThan(0);
expect(versions[0]!.version).toBe(sha);
});
// -------------------------------------------------------------------------
// Test 4: SKILL.md written to cache dir
// -------------------------------------------------------------------------
it("Test 4: Each fetched skill's SKILL.md content is written to cache dir at <instance-root>/skills/cache/<skill-id>/<sha>/SKILL.md", async () => {
const sha = "feedfeed1234567890abcdef1234567890abcdef";
const skillMdContent = mockSkillMd("Cached Skill", "Written to disk");
fetchSpy.mockImplementation(async (url: string) => {
const urlStr = String(url);
if (urlStr.includes("/git/trees/")) {
return {
ok: true,
json: async () => ({
tree: [{ path: "cached-skill/SKILL.md", type: "blob", size: 100 }],
}),
};
}
if (urlStr.includes("/commits/")) {
return {
ok: true,
json: async () => ({ sha }),
};
}
if (urlStr.includes("SKILL.md")) {
return {
ok: true,
text: async () => skillMdContent,
};
}
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
});
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
const { resolveSkillCacheDir } = await import("../home-paths.js");
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
await fetchAllSources([source]);
// The skill id should be schwepps-skills/cached-skill
const skillId = "schwepps-skills/cached-skill";
const cacheDir = resolveSkillCacheDir(skillId, sha);
const cachedPath = path.join(cacheDir, "SKILL.md");
expect(existsSync(cachedPath)).toBe(true);
const content = await readFile(cachedPath, "utf-8");
expect(content).toBe(skillMdContent);
});
// -------------------------------------------------------------------------
// Test 5: skill_files rows inserted for each cached file
// -------------------------------------------------------------------------
it("Test 5: skill_files rows are inserted for each cached file with path, kind, and size_bytes", async () => {
const sha = "aabbccdd1234567890abcdef1234567890abcdef";
fetchSpy.mockImplementation(async (url: string) => {
const urlStr = String(url);
if (urlStr.includes("/git/trees/")) {
return {
ok: true,
json: async () => ({
tree: [{ path: "files-skill/SKILL.md", type: "blob", size: 350 }],
}),
};
}
if (urlStr.includes("/commits/")) {
return {
ok: true,
json: async () => ({ sha }),
};
}
if (urlStr.includes("SKILL.md")) {
return {
ok: true,
text: async () => mockSkillMd("Files Skill", "Tests skill_files rows"),
};
}
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
});
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skillFiles } = await import("../services/skill-registry-schema.js");
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
await fetchAllSources([source]);
const db = await getSkillRegistryDb();
const files = await db.select().from(skillFiles);
expect(files.length).toBeGreaterThan(0);
const skillMdFile = files.find((f) => f.path.endsWith("SKILL.md"));
expect(skillMdFile).toBeDefined();
expect(skillMdFile!.kind).toBe("skill");
expect(skillMdFile!.sizeBytes).toBeGreaterThan(0);
});
// -------------------------------------------------------------------------
// Test 6: Re-fetching with same SHA is idempotent
// -------------------------------------------------------------------------
it("Test 6: Re-fetching with same SHA skips re-download (idempotent)", async () => {
const sha = "idemidemidem1234567890abcdef1234567890ab";
let fetchCallCount = 0;
fetchSpy.mockImplementation(async (url: string) => {
const urlStr = String(url);
fetchCallCount++;
if (urlStr.includes("/git/trees/")) {
return {
ok: true,
json: async () => ({
tree: [{ path: "idem-skill/SKILL.md", type: "blob", size: 100 }],
}),
};
}
if (urlStr.includes("/commits/")) {
return {
ok: true,
json: async () => ({ sha }),
};
}
if (urlStr.includes("SKILL.md")) {
return {
ok: true,
text: async () => mockSkillMd("Idem Skill", "Tests idempotency"),
};
}
return { ok: false, status: 404, text: async () => "Not found", json: async () => ({}) };
});
const { fetchAllSources, BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skillVersions } = await import("../services/skill-registry-schema.js");
const source = BUILT_IN_SOURCES.find((s) => s.id === "schwepps-skills")!;
// First fetch
await fetchAllSources([source]);
const firstCount = fetchCallCount;
// Second fetch with same SHA — should not re-download SKILL.md
fetchCallCount = 0;
await fetchAllSources([source]);
const db = await getSkillRegistryDb();
const versions = await db.select().from(skillVersions);
// Only 1 version row for the same SHA (idempotent insert)
const idemVersions = versions.filter((v) => v.version === sha);
expect(idemVersions.length).toBe(1);
// Second run should have fewer fetch calls (no SKILL.md re-download)
expect(fetchCallCount).toBeLessThan(firstCount);
});
// -------------------------------------------------------------------------
// Test 7: BUILT_IN_SOURCES has exactly 3 entries
// -------------------------------------------------------------------------
it("Test 7: BUILT_IN_SOURCES contains 3 entries (anthropic-official, schwepps-skills, daymade-skills)", async () => {
const { BUILT_IN_SOURCES } = await import("../services/skill-registry-fetcher.js");
expect(BUILT_IN_SOURCES).toHaveLength(3);
const ids = BUILT_IN_SOURCES.map((s) => s.id);
expect(ids).toContain("anthropic-official");
expect(ids).toContain("schwepps-skills");
expect(ids).toContain("daymade-skills");
// Verify all sources have required fields
for (const source of BUILT_IN_SOURCES) {
expect(source.id).toBeTruthy();
expect(source.type).toMatch(/^(anthropic-marketplace|github-tree)$/);
expect(source.owner).toBeTruthy();
expect(source.repo).toBeTruthy();
expect(source.ref).toBeTruthy();
expect(source.label).toBeTruthy();
}
});
});

View file

@ -0,0 +1,320 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { existsSync, readdirSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { getSkillRegistryDb, resetSkillRegistryDb } from "../services/skill-registry-db.js";
import { skills, skillVersions, skillFiles } from "../services/skill-registry-schema.js";
import { skillRegistryService } from "../services/skill-registry.js";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let tmpHome: string;
let tmpAgentSkillsDir: string;
beforeEach(async () => {
// Create isolated temp dirs
tmpHome = await mkdtemp(path.join(os.tmpdir(), "nexus-skill-registry-test-"));
tmpAgentSkillsDir = path.join(tmpHome, "agent-skills");
await mkdir(tmpAgentSkillsDir, { recursive: true });
// Point PAPERCLIP_HOME at temp dir for DB and cache
process.env.PAPERCLIP_HOME = tmpHome;
resetSkillRegistryDb();
});
afterEach(async () => {
resetSkillRegistryDb();
delete process.env.PAPERCLIP_HOME;
await rm(tmpHome, { recursive: true, force: true });
});
// Seed helpers
async function seedSkillWithVersion(opts: {
skillId: string;
sourceId: string;
versionId: string;
cacheDir?: string;
fileKind?: string;
}): Promise<void> {
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values({
id: opts.skillId,
sourceId: opts.sourceId,
name: "Test Skill",
description: "A test skill",
sourceUrl: `https://github.com/test/${opts.skillId}`,
activeVersionId: null,
removedAt: null,
createdAt: now,
updatedAt: now,
});
await db.insert(skillVersions).values({
id: opts.versionId,
skillId: opts.skillId,
version: "abc123",
fetchedAt: now,
cacheDir: opts.cacheDir ?? null,
});
await db.insert(skillFiles).values({
id: `file-${opts.versionId}`,
versionId: opts.versionId,
path: "SKILL.md",
kind: opts.fileKind ?? "skill",
sizeBytes: 100,
});
}
async function createFakeCacheDir(cacheDir: string): Promise<void> {
await mkdir(cacheDir, { recursive: true });
await writeFile(path.join(cacheDir, "SKILL.md"), "# Test Skill\n\nContent.", "utf-8");
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("skillRegistryService", () => {
const svc = skillRegistryService();
describe("install — SKILL.md-based skill", () => {
it("Test 1: copies files from cache dir to agentSkillsDir/<slug>/", async () => {
const skillId = "schwepps-skills/code-review";
const versionId = `${skillId}@abc123`;
const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123");
await createFakeCacheDir(cacheDir);
await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir });
const result = await svc.install(skillId, tmpAgentSkillsDir);
expect(result.type).toBe("installed");
if (result.type === "installed") {
const slug = "code-review";
const expectedTarget = path.join(tmpAgentSkillsDir, slug);
expect(result.targetDir).toBe(expectedTarget);
expect(existsSync(path.join(expectedTarget, "SKILL.md"))).toBe(true);
}
});
it("Test 2: updates skill active_version_id to the latest version", async () => {
const skillId = "schwepps-skills/code-review";
const versionId = `${skillId}@abc123`;
const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123");
await createFakeCacheDir(cacheDir);
await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir });
await svc.install(skillId, tmpAgentSkillsDir);
const db = await getSkillRegistryDb();
const rows = await db.select().from(skills).where(
(await import("drizzle-orm")).eq(skills.id, skillId)
);
expect(rows[0]?.activeVersionId).toBe(versionId);
});
});
describe("install — marketplace plugin", () => {
it("Test 3: returns pending_plugin_install command instead of copying files for plugin kind", async () => {
const skillId = "anthropic-official/my-plugin";
const versionId = `${skillId}@deadbeef`;
const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "deadbeef");
await createFakeCacheDir(cacheDir);
await seedSkillWithVersion({
skillId,
sourceId: "anthropic-official",
versionId,
cacheDir,
fileKind: "plugin",
});
const result = await svc.install(skillId, tmpAgentSkillsDir);
expect(result.type).toBe("pending_plugin_install");
if (result.type === "pending_plugin_install") {
expect(result.command).toContain("/plugin install");
expect(result.skillId).toBe(skillId);
expect(result.versionId).toBe(versionId);
}
// No files should be copied to agent dir
const agentFiles = readdirSync(tmpAgentSkillsDir);
expect(agentFiles).toHaveLength(0);
});
});
describe("uninstall", () => {
it("Test 4: sets removed_at timestamp on skills row", async () => {
const skillId = "schwepps-skills/code-review";
const versionId = `${skillId}@abc123`;
const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123");
await createFakeCacheDir(cacheDir);
await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir });
const before = Date.now();
await svc.uninstall(skillId);
const after = Date.now();
const db = await getSkillRegistryDb();
const { eq } = await import("drizzle-orm");
const rows = await db.select().from(skills).where(eq(skills.id, skillId));
expect(rows[0]?.removedAt).toBeGreaterThanOrEqual(before);
expect(rows[0]?.removedAt).toBeLessThanOrEqual(after);
});
it("Test 5: row still exists and is returned with includeRemoved=true", async () => {
const skillId = "schwepps-skills/code-review";
const versionId = `${skillId}@abc123`;
const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123");
await createFakeCacheDir(cacheDir);
await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir });
await svc.uninstall(skillId);
// Not visible in normal list
const normalList = await svc.list();
expect(normalList.find((s) => s.id === skillId)).toBeUndefined();
// Visible with includeRemoved
const fullList = await svc.list({ includeRemoved: true });
expect(fullList.find((s) => s.id === skillId)).toBeDefined();
});
});
describe("rollback", () => {
it("Test 6: copies prior version's cached files to agent skills dir", async () => {
const skillId = "schwepps-skills/code-review";
const slug = "code-review";
// Seed v1 (prior) and v2 (current)
const v1Id = `${skillId}@v1sha`;
const v2Id = `${skillId}@v2sha`;
const v1CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v1sha");
const v2CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v2sha");
await createFakeCacheDir(v1CacheDir);
await writeFile(path.join(v1CacheDir, "SKILL.md"), "# Version 1", "utf-8");
await createFakeCacheDir(v2CacheDir);
await writeFile(path.join(v2CacheDir, "SKILL.md"), "# Version 2", "utf-8");
// Seed both versions
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values({
id: skillId,
sourceId: "schwepps-skills",
name: "Test",
description: null,
sourceUrl: "https://github.com/test",
activeVersionId: v2Id,
removedAt: null,
createdAt: now,
updatedAt: now,
});
await db.insert(skillVersions).values({
id: v1Id, skillId, version: "v1sha", fetchedAt: now - 1000, cacheDir: v1CacheDir,
});
await db.insert(skillVersions).values({
id: v2Id, skillId, version: "v2sha", fetchedAt: now, cacheDir: v2CacheDir,
});
await db.insert(skillFiles).values({
id: "file-v1", versionId: v1Id, path: "SKILL.md", kind: "skill", sizeBytes: 12,
});
await db.insert(skillFiles).values({
id: "file-v2", versionId: v2Id, path: "SKILL.md", kind: "skill", sizeBytes: 12,
});
// Install v2 first
const targetDir = path.join(tmpAgentSkillsDir, slug);
await mkdir(targetDir, { recursive: true });
await writeFile(path.join(targetDir, "SKILL.md"), "# Version 2", "utf-8");
// Rollback to v1
await svc.rollback(skillId, v1Id, tmpAgentSkillsDir);
// Verify v1 content is in place
const { readFileSync } = await import("node:fs");
const content = readFileSync(path.join(targetDir, "SKILL.md"), "utf-8");
expect(content).toBe("# Version 1");
});
it("Test 7: updates active_version_id to the prior version", async () => {
const skillId = "schwepps-skills/code-review";
const v1Id = `${skillId}@v1sha`;
const v2Id = `${skillId}@v2sha`;
const v1CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v1sha");
await createFakeCacheDir(v1CacheDir);
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values({
id: skillId, sourceId: "test", name: "T", description: null,
sourceUrl: "u", activeVersionId: v2Id, removedAt: null, createdAt: now, updatedAt: now,
});
await db.insert(skillVersions).values({
id: v1Id, skillId, version: "v1sha", fetchedAt: now - 1000, cacheDir: v1CacheDir,
});
const agentDir = path.join(tmpAgentSkillsDir, "code-review");
await mkdir(agentDir, { recursive: true });
await writeFile(path.join(agentDir, "SKILL.md"), "current", "utf-8");
await svc.rollback(skillId, v1Id, tmpAgentSkillsDir);
const { eq } = await import("drizzle-orm");
const rows = await db.select().from(skills).where(eq(skills.id, skillId));
expect(rows[0]?.activeVersionId).toBe(v1Id);
});
});
describe("list", () => {
it("Test 8: returns only skills where removed_at IS NULL by default", async () => {
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values([
{
id: "active-skill", sourceId: "test", name: "Active", description: null,
sourceUrl: "u", activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now,
},
{
id: "removed-skill", sourceId: "test", name: "Removed", description: null,
sourceUrl: "u", activeVersionId: null, removedAt: now - 1000, createdAt: now, updatedAt: now,
},
]);
const result = await svc.list();
const ids = result.map((s) => s.id);
expect(ids).toContain("active-skill");
expect(ids).not.toContain("removed-skill");
});
it("Test 9: list({ includeRemoved: true }) returns all skills", async () => {
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values([
{
id: "active-skill", sourceId: "test", name: "Active", description: null,
sourceUrl: "u", activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now,
},
{
id: "removed-skill", sourceId: "test", name: "Removed", description: null,
sourceUrl: "u", activeVersionId: null, removedAt: now - 1000, createdAt: now, updatedAt: now,
},
]);
const result = await svc.list({ includeRemoved: true });
const ids = result.map((s) => s.id);
expect(ids).toContain("active-skill");
expect(ids).toContain("removed-skill");
});
});
});

View file

@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { getSkillRegistryDb, resetSkillRegistryDb } from "../services/skill-registry-db.js";
import { agentSkills } from "../services/skill-registry-schema.js";
import { skillRatingService } from "../services/skill-registry-ratings.js";
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
let tmpHome: string;
beforeEach(async () => {
tmpHome = await mkdtemp(path.join(os.tmpdir(), "nexus-ratings-test-"));
process.env.PAPERCLIP_HOME = tmpHome;
resetSkillRegistryDb();
});
afterEach(async () => {
resetSkillRegistryDb();
delete process.env.PAPERCLIP_HOME;
await rm(tmpHome, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("skillRatingService", () => {
const svc = skillRatingService();
it("Test 1: rate() inserts a personal_ratings row; getRatings(skillId) returns it with correct fields", async () => {
await svc.rate({ skillId: "src/skill-a", versionId: "v1", stars: 4, note: "Great skill" });
const ratings = await svc.getRatings("src/skill-a");
expect(ratings).toHaveLength(1);
expect(ratings[0]!.skillId).toBe("src/skill-a");
expect(ratings[0]!.versionId).toBe("v1");
expect(ratings[0]!.stars).toBe(4);
expect(ratings[0]!.note).toBe("Great skill");
});
it("Test 2: rate() called twice for same skill creates two separate rows (append-only)", async () => {
await svc.rate({ skillId: "src/skill-b", stars: 3 });
await svc.rate({ skillId: "src/skill-b", stars: 5, note: "Even better now" });
const ratings = await svc.getRatings("src/skill-b");
expect(ratings).toHaveLength(2);
});
it("Test 3: getRatings() returns results ordered by created_at descending (newest first)", async () => {
await svc.rate({ skillId: "src/skill-c", stars: 2, note: "First" });
// Small delay to ensure different timestamps
await new Promise((r) => setTimeout(r, 5));
await svc.rate({ skillId: "src/skill-c", stars: 5, note: "Second" });
const ratings = await svc.getRatings("src/skill-c");
expect(ratings).toHaveLength(2);
// Newest first — the second rating should be first in results
expect(ratings[0]!.note).toBe("Second");
expect(ratings[1]!.note).toBe("First");
});
it("Test 4: recordUsageForAgent() increments task_count from 0 to 1 for all agent_skills rows of the agent", async () => {
const db = await getSkillRegistryDb();
const now = Date.now();
// Insert two agent_skills rows for the same agent
await db.insert(agentSkills).values([
{ agentId: "agent-1", skillId: "src/skill-x", installedAt: now },
{ agentId: "agent-1", skillId: "src/skill-y", installedAt: now },
]);
await svc.recordUsageForAgent("agent-1", null);
const { eq } = await import("drizzle-orm");
const rows = await db.select().from(agentSkills).where(eq(agentSkills.agentId, "agent-1"));
for (const row of rows) {
expect((row as any).taskCount ?? (row as any).task_count).toBe(1);
}
});
it("Test 5: recordUsageForAgent() computes running average: after two calls with costs 0.01 and 0.03, avg_cost_usd is 0.02", async () => {
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(agentSkills).values({ agentId: "agent-2", skillId: "src/skill-z", installedAt: now });
await svc.recordUsageForAgent("agent-2", 0.01);
await svc.recordUsageForAgent("agent-2", 0.03);
const { eq } = await import("drizzle-orm");
const rows = await db.select().from(agentSkills).where(eq(agentSkills.agentId, "agent-2"));
const row = rows[0] as any;
const avgCost = row.avgCostUsd ?? row.avg_cost_usd;
expect(avgCost).toBeCloseTo(0.02, 5);
});
it("Test 6: recordUsageForAgent() sets last_used_at to approximately Date.now()", async () => {
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(agentSkills).values({ agentId: "agent-3", skillId: "src/skill-w", installedAt: now });
const before = Date.now();
await svc.recordUsageForAgent("agent-3", null);
const after = Date.now();
const { eq } = await import("drizzle-orm");
const rows = await db.select().from(agentSkills).where(eq(agentSkills.agentId, "agent-3"));
const row = rows[0] as any;
const lastUsedAt = row.lastUsedAt ?? row.last_used_at;
expect(lastUsedAt).toBeGreaterThanOrEqual(before);
expect(lastUsedAt).toBeLessThanOrEqual(after);
});
it("Test 7: recordUsageForAgent() does nothing (no throw) when agent has no agent_skills rows", async () => {
await expect(svc.recordUsageForAgent("nonexistent-agent", 0.05)).resolves.not.toThrow();
});
it("Test 8: rate() with stars=0 or stars=6 throws validation error", async () => {
await expect(svc.rate({ skillId: "src/skill-v", stars: 0 })).rejects.toThrow();
await expect(svc.rate({ skillId: "src/skill-v", stars: 6 })).rejects.toThrow();
});
});

View file

@ -0,0 +1,240 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { skillRegistryRoutes } from "../routes/skill-registry.js";
import { errorHandler } from "../middleware/index.js";
// ---------------------------------------------------------------------------
// Mock skillRegistryService
// ---------------------------------------------------------------------------
const mockSkillRegistryService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
getVersions: vi.fn(),
fetchAll: vi.fn(),
install: vi.fn(),
rollback: vi.fn(),
uninstall: vi.fn(),
}));
vi.mock("../services/skill-registry.js", () => ({
skillRegistryService: () => mockSkillRegistryService,
}));
// ---------------------------------------------------------------------------
// App factory
// ---------------------------------------------------------------------------
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: [],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", skillRegistryRoutes());
app.use(errorHandler);
return app;
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const skill1 = {
id: "anthropic-official/bash",
sourceId: "anthropic-official",
name: "Bash",
description: "A bash skill",
activeVersionId: null,
removedAt: null,
createdAt: 1000,
updatedAt: 1000,
};
const version1 = {
id: "anthropic-official/bash@abc123",
skillId: "anthropic-official/bash",
sha: "abc123",
cacheDir: "/tmp/cache/bash@abc123",
fetchedAt: 1000,
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("skill registry routes", () => {
let app: ReturnType<typeof createApp>;
beforeEach(() => {
vi.clearAllMocks();
app = createApp();
});
// ---- Test 1: GET /api/skill-registry/skills ----
describe("GET /api/skill-registry/skills", () => {
it("returns 200 with JSON array of skills", async () => {
mockSkillRegistryService.list.mockResolvedValue([skill1]);
const res = await request(app).get("/api/skill-registry/skills");
expect(res.status).toBe(200);
expect(res.body).toEqual([skill1]);
expect(mockSkillRegistryService.list).toHaveBeenCalledWith({ includeRemoved: false });
});
it("passes includeRemoved=true when query param set", async () => {
mockSkillRegistryService.list.mockResolvedValue([skill1]);
const res = await request(app).get("/api/skill-registry/skills?includeRemoved=true");
expect(res.status).toBe(200);
expect(mockSkillRegistryService.list).toHaveBeenCalledWith({ includeRemoved: true });
});
});
// ---- Test 2: GET /api/skill-registry/skills/:sourceId/:slug ----
describe("GET /api/skill-registry/skills/:sourceId/:slug", () => {
it("returns 200 with skill object when found", async () => {
mockSkillRegistryService.getById.mockResolvedValue(skill1);
const res = await request(app).get("/api/skill-registry/skills/anthropic-official/bash");
expect(res.status).toBe(200);
expect(res.body).toEqual(skill1);
expect(mockSkillRegistryService.getById).toHaveBeenCalledWith("anthropic-official/bash");
});
it("returns 404 when skill not found", async () => {
mockSkillRegistryService.getById.mockResolvedValue(undefined);
const res = await request(app).get("/api/skill-registry/skills/unknown/skill");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Skill not found" });
});
});
// ---- Test 3: GET /api/skill-registry/skills/:sourceId/:slug/versions ----
describe("GET /api/skill-registry/skills/:sourceId/:slug/versions", () => {
it("returns 200 with version array", async () => {
mockSkillRegistryService.getVersions.mockResolvedValue([version1]);
const res = await request(app).get("/api/skill-registry/skills/anthropic-official/bash/versions");
expect(res.status).toBe(200);
expect(res.body).toEqual([version1]);
expect(mockSkillRegistryService.getVersions).toHaveBeenCalledWith("anthropic-official/bash");
});
});
// ---- Test 4: POST /api/skill-registry/fetch ----
describe("POST /api/skill-registry/fetch", () => {
it("returns 200 with { fetched, errors } object", async () => {
mockSkillRegistryService.fetchAll.mockResolvedValue({ fetched: 3, errors: [] });
const res = await request(app).post("/api/skill-registry/fetch");
expect(res.status).toBe(200);
expect(res.body).toEqual({ fetched: 3, errors: [] });
});
});
// ---- Test 5: POST /api/skill-registry/skills/:sourceId/:slug/install ----
describe("POST /api/skill-registry/skills/:sourceId/:slug/install", () => {
it("returns 200 with install result when agentSkillsDir provided", async () => {
const installResult = {
type: "installed",
skillId: "anthropic-official/bash",
versionId: "anthropic-official/bash@abc123",
targetDir: "/agent/skills/bash",
};
mockSkillRegistryService.install.mockResolvedValue(installResult);
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official/bash/install")
.send({ agentSkillsDir: "/agent/skills" });
expect(res.status).toBe(200);
expect(res.body).toEqual(installResult);
expect(mockSkillRegistryService.install).toHaveBeenCalledWith(
"anthropic-official/bash",
"/agent/skills",
);
});
it("returns 400 when agentSkillsDir is missing", async () => {
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official/bash/install")
.send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "agentSkillsDir required" });
});
});
// ---- Test 6: POST /api/skill-registry/skills/:sourceId/:slug/rollback ----
describe("POST /api/skill-registry/skills/:sourceId/:slug/rollback", () => {
it("returns 200 when versionId and agentSkillsDir provided", async () => {
mockSkillRegistryService.rollback.mockResolvedValue(undefined);
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official/bash/rollback")
.send({ versionId: "anthropic-official/bash@abc123", agentSkillsDir: "/agent/skills" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockSkillRegistryService.rollback).toHaveBeenCalledWith(
"anthropic-official/bash",
"anthropic-official/bash@abc123",
"/agent/skills",
);
});
it("returns 400 when versionId is missing", async () => {
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official/bash/rollback")
.send({ agentSkillsDir: "/agent/skills" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "versionId and agentSkillsDir required" });
});
it("returns 400 when agentSkillsDir is missing", async () => {
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official/bash/rollback")
.send({ versionId: "anthropic-official/bash@abc123" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "versionId and agentSkillsDir required" });
});
});
// ---- Test 7: DELETE /api/skill-registry/skills/:sourceId/:slug ----
describe("DELETE /api/skill-registry/skills/:sourceId/:slug", () => {
it("returns 200 after soft-delete", async () => {
mockSkillRegistryService.uninstall.mockResolvedValue(undefined);
const res = await request(app).delete("/api/skill-registry/skills/anthropic-official/bash");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockSkillRegistryService.uninstall).toHaveBeenCalledWith("anthropic-official/bash");
});
});
});

View file

@ -0,0 +1,136 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
// We reset the singleton between tests by calling resetSkillRegistryDb
// and redirecting PAPERCLIP_HOME to an isolated temp dir
describe("skill-registry-schema", () => {
let tmpDir: string;
let originalPaperclipHome: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "skill-registry-test-"));
originalPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = tmpDir;
});
afterEach(async () => {
// Reset the DB singleton so next test gets a fresh instance
const { resetSkillRegistryDb } = await import("../services/skill-registry-db.js");
resetSkillRegistryDb();
if (originalPaperclipHome === undefined) {
delete process.env.PAPERCLIP_HOME;
} else {
process.env.PAPERCLIP_HOME = originalPaperclipHome;
}
await rm(tmpDir, { recursive: true, force: true });
});
it("Test 1: getSkillRegistryDb() creates registry.db at the resolved path and returns a drizzle instance", async () => {
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const db = await getSkillRegistryDb();
expect(db).toBeDefined();
expect(typeof db.select).toBe("function");
expect(typeof db.insert).toBe("function");
});
it("Test 2: skills table has correct columns", async () => {
const { skills } = await import("../services/skill-registry-schema.js");
const cols = Object.keys(skills);
// Check the table has the expected name
expect((skills as any)[Symbol.for("drizzle:Name") as any] ?? skills._.name).toBeDefined();
const colNames = Object.keys(skills);
// Drizzle table object has column accessors
expect(skills.id).toBeDefined();
expect(skills.sourceId).toBeDefined();
expect(skills.name).toBeDefined();
expect(skills.description).toBeDefined();
expect(skills.sourceUrl).toBeDefined();
expect(skills.activeVersionId).toBeDefined();
expect(skills.removedAt).toBeDefined();
expect(skills.createdAt).toBeDefined();
expect(skills.updatedAt).toBeDefined();
});
it("Test 3: skill_versions table has correct columns", async () => {
const { skillVersions } = await import("../services/skill-registry-schema.js");
expect(skillVersions.id).toBeDefined();
expect(skillVersions.skillId).toBeDefined();
expect(skillVersions.version).toBeDefined();
expect(skillVersions.fetchedAt).toBeDefined();
expect(skillVersions.cacheDir).toBeDefined();
});
it("Test 4: skill_files table has correct columns", async () => {
const { skillFiles } = await import("../services/skill-registry-schema.js");
expect(skillFiles.id).toBeDefined();
expect(skillFiles.versionId).toBeDefined();
expect(skillFiles.path).toBeDefined();
expect(skillFiles.kind).toBeDefined();
expect(skillFiles.sizeBytes).toBeDefined();
});
it("Test 5: community_ratings table has correct columns", async () => {
const { communityRatings } = await import("../services/skill-registry-schema.js");
expect(communityRatings.id).toBeDefined();
expect(communityRatings.skillId).toBeDefined();
expect(communityRatings.fetchedAt).toBeDefined();
expect(communityRatings.averageRating).toBeDefined();
expect(communityRatings.ratingCount).toBeDefined();
expect(communityRatings.source).toBeDefined();
});
it("Test 6: soft-delete — inserting a skill and setting removed_at keeps the row queryable with a WHERE filter", async () => {
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skills } = await import("../services/skill-registry-schema.js");
const { eq, isNull, isNotNull } = await import("drizzle-orm");
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values({
id: "test-skill-1",
sourceId: "src-1",
name: "Test Skill",
description: "A test skill",
sourceUrl: null,
activeVersionId: null,
removedAt: null,
createdAt: now,
updatedAt: now,
});
// Before soft-delete: row is visible
const before = await db.select().from(skills).where(isNull(skills.removedAt));
expect(before.length).toBe(1);
// Apply soft-delete
await db.update(skills).set({ removedAt: now + 1000 }).where(eq(skills.id, "test-skill-1"));
// After soft-delete: not visible via active filter
const activeAfter = await db.select().from(skills).where(isNull(skills.removedAt));
expect(activeAfter.length).toBe(0);
// But still visible via removed filter
const removedAfter = await db.select().from(skills).where(isNotNull(skills.removedAt));
expect(removedAfter.length).toBe(1);
expect(removedAfter[0]!.id).toBe("test-skill-1");
});
it("Test 7: resolveSkillRegistryDbPath() returns path ending in skills/registry.db under instance root", async () => {
const { resolveSkillRegistryDbPath } = await import("../home-paths.js");
const dbPath = resolveSkillRegistryDbPath();
expect(dbPath).toMatch(/skills[/\\]registry\.db$/);
expect(dbPath.startsWith(tmpDir)).toBe(true);
});
it("Test 8: resolveSkillCacheDir returns path ending in skills/cache/<skillId>/<versionId>", async () => {
const { resolveSkillCacheDir } = await import("../home-paths.js");
const cacheDir = resolveSkillCacheDir("my-skill", "abc123");
expect(cacheDir).toMatch(/skills[/\\]cache[/\\]my-skill[/\\]abc123$/);
expect(cacheDir.startsWith(tmpDir)).toBe(true);
});
});

View file

@ -12,6 +12,8 @@ import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middlewa
import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.js";
import { skillRegistryRoutes } from "./routes/skill-registry.js";
import { skillGroupRoutes } from "./routes/skill-registry-groups.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js";
@ -141,6 +143,8 @@ export async function createApp(
);
api.use("/companies", companyRoutes(db, opts.storageService));
api.use(companySkillRoutes(db));
api.use(skillRegistryRoutes());
api.use(skillGroupRoutes());
api.use(agentRoutes(db));
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));

View file

@ -113,3 +113,12 @@ export function resolveManagedProjectWorkspaceDir(input: {
export function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
// [nexus] Skill registry paths
export function resolveSkillRegistryDbPath(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "skills", "registry.db");
}
export function resolveSkillCacheDir(skillId: string, versionId: string): string {
return path.resolve(resolvePaperclipInstanceRoot(), "skills", "cache", skillId, versionId);
}

View file

@ -19,6 +19,7 @@ import {
formatDatabaseBackupResult,
runDatabaseBackup,
authUsers,
agents,
companies,
companyMemberships,
instanceUserRoles,
@ -28,7 +29,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import { agentService, heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@ -243,6 +244,32 @@ export async function startServer(): Promise<StartedServer> {
}
}
// [nexus] Backfill Generalist agent for existing workspaces that pre-date Phase 8
async function ensureGeneralistAgents(db: any): Promise<{ backfilled: number }> {
const companyRows = await db.select({ id: companies.id }).from(companies);
let backfilled = 0;
for (const company of companyRows) {
const existing = await db
.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, company.id), eq(agents.role, "general")))
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
if (existing) continue;
const agentSvc = agentService(db);
await agentSvc.create(company.id, {
name: "Generalist",
role: "general",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
metadata: { pendingSkillGroups: ["Creative"], backfilled: true },
});
logger.info({ companyId: company.id }, "backfilled Generalist agent for existing workspace");
backfilled++;
}
return { backfilled };
}
let db;
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
let embeddedPostgresStartedByThisProcess = false;
@ -459,6 +486,18 @@ export async function startServer(): Promise<StartedServer> {
if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any);
}
// [nexus] Backfill Generalist agents for any workspace that pre-dates Phase 8
void ensureGeneralistAgents(db as any)
.then((result) => {
if (result.backfilled > 0) {
logger.info({ backfilled: result.backfilled }, "backfilled Generalist agents for existing workspaces");
}
})
.catch((err) => {
logger.error({ err }, "failed to backfill Generalist agents");
});
if (config.deploymentMode === "authenticated") {
const {
createBetterAuthHandler,
@ -561,6 +600,50 @@ export async function startServer(): Promise<StartedServer> {
.catch((err) => {
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
});
// [nexus] Initialize skill registry database (fire-and-forget)
void (async () => {
try {
const { getSkillRegistryDb } = await import("./services/skill-registry-db.js");
await getSkillRegistryDb();
logger.info("skill registry database initialized");
} catch (err) {
logger.error({ err }, "skill registry init failed");
}
})();
// [nexus] Reconcile pendingSkillGroups metadata on agents (fire-and-forget)
void (async () => {
try {
const { join } = await import("node:path");
const { skillGroupService } = await import("./services/skill-registry-groups.js");
const { resolveDefaultAgentWorkspaceDir } = await import("./home-paths.js");
const svc = skillGroupService();
const GROUP_NAME_MAP: Record<string, string> = {
"Creative": "builtin/creative",
"PM Essentials": "builtin/pm-essentials",
"Engineer Core": "builtin/engineer-core",
"Frontend": "builtin/frontend",
"Backend": "builtin/backend",
};
const allAgents = await (db as any).select().from(agents);
for (const agent of allAgents) {
const pending = (agent.metadata as any)?.pendingSkillGroups;
if (!Array.isArray(pending) || pending.length === 0) continue;
const agentSkillsDir = join(resolveDefaultAgentWorkspaceDir(agent), ".claude", "skills");
for (const groupName of pending) {
const groupId = GROUP_NAME_MAP[groupName as string];
if (!groupId) continue;
const existing = await svc.listAgentGroups(agent.id);
if (existing.some((g) => g.id === groupId)) continue;
await svc.assignGroup(groupId, agent.id, agentSkillsDir);
logger.info({ agentId: agent.id, groupId }, "reconciled pendingSkillGroups assignment");
}
}
} catch (err) {
logger.warn({ err }, "Failed to reconcile pendingSkillGroups");
}
})();
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any);

View file

@ -0,0 +1,46 @@
<!-- [nexus] rewritten -->
You are the Generalist for this Nexus workspace.
Your home directory is $AGENT_HOME. Everything personal to you — memory, notes, drafts — lives there.
Workspace-wide artifacts (plans, shared docs, project materials) live in the project root.
## Your Role
You handle non-code work assigned to you by the Project Manager. Your scope includes:
- **Copy and content**: Marketing copy, blog posts, email drafts, social media content
- **Branding**: Brand guidelines, naming, messaging frameworks, style guides
- **Legal research**: Summarize terms, licenses, compliance requirements (not legal advice)
- **Research**: Market research, competitive analysis, technology evaluations, summaries
- **Documentation**: User guides, process docs, runbooks, onboarding materials
- **Presentations**: Slide outlines, pitch decks, demo scripts, talking points
You do NOT write code, fix bugs, or make technical implementation decisions — that is the Engineer's job. You do NOT set priorities or delegate work — that is the PM's job.
## When You Receive a Task
1. **Read it carefully** — understand the deliverable, audience, and any linked context.
2. **Ask if unclear** — comment on the task with specific questions before starting.
3. **Checkout before starting**`POST /api/issues/{id}/checkout` to claim the task.
4. **Produce the deliverable** — write the document, research summary, or content piece.
5. **Verify quality** — proofread, check facts, confirm acceptance criteria are met.
6. **Report completion** — comment on the task with what was produced and where to find it.
7. **Update status** — mark the task complete when done.
## Escalation
If you hit a blocker:
- Identify exactly what is blocking you (missing info, unclear audience, missing context).
- Comment on the task with the specific blocker and what you need.
- Assign the task back to the PM if you need a decision or new information.
- Don't stay blocked silently.
## References
Read these files on every heartbeat:
- `$AGENT_HOME/HEARTBEAT.md` — task loop checklist
- `$AGENT_HOME/SOUL.md` — your identity and how to act
- `$AGENT_HOME/TOOLS.md` — tools you have access to

View file

@ -0,0 +1,61 @@
<!-- [nexus] rewritten -->
# HEARTBEAT.md -- Generalist Task Loop
Run this checklist on every heartbeat.
## 1. Identity and Context
- `GET /api/agents/me` — confirm your id, role, and budget.
- Check wake context: `PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`.
## 2. Get Assignments
- `GET /api/companies/{workspaceId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked`
- Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
- If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task.
- If there is already an active run on an `in_progress` task, move to the next one.
## 3. Checkout and Produce
1. Checkout before starting: `POST /api/issues/{id}/checkout`
2. Never retry a 409 — that task belongs to another run.
3. Read the task description, acceptance criteria, and any linked context carefully.
4. If requirements are unclear, comment with specific questions before producing content.
5. Produce the deliverable: write the document, research summary, or content piece.
6. Review your output for accuracy, clarity, and completeness.
7. Confirm all acceptance criteria are met.
## 4. Report Progress
- Comment on the task with what was produced, where to find the output, and key decisions made.
- Update task status to reflect current state (in_progress, done).
- If blocked, comment with the specific blocker and assign back to the PM.
## 5. Approval Follow-Up
If `PAPERCLIP_APPROVAL_ID` is set:
- Review the approval request and act on it.
- Comment with outcome and close or update the linked task.
## 6. Exit
- Comment on any in_progress work before exiting.
- If no assignments, exit cleanly — do not look for unassigned work.
## Rules
- Always checkout before working: `POST /api/issues/{id}/checkout`
- Never retry a 409 — that task belongs to someone else.
- Always include `X-Paperclip-Run-Id` header on mutating API calls.
- Comment in concise markdown: status line + bullets + links.
- Self-assign via checkout only when explicitly @-mentioned.
- Never look for unassigned work — only work on what is assigned to you.
## Generalist Responsibilities
- Content: Produce clear, well-structured written deliverables.
- Research: Summarize findings with sources and key takeaways.
- Quality: Proofread, fact-check, and confirm acceptance criteria before marking done.
- Communication: Report progress and blockers clearly and promptly.
- Budget awareness: Above 80% budget spend, focus only on the current task.

View file

@ -0,0 +1,26 @@
<!-- [nexus] rewritten -->
# SOUL.md -- Generalist Persona
You are the Generalist for this Nexus workspace.
## Purpose
Your job is to produce non-code deliverables — written content, research, documentation, and presentations. You are the workspace's versatile writer and researcher. When the PM needs something that is not code, it comes to you.
## Voice and Tone
- Adapt your voice to the task type:
- **Formal** for legal summaries, compliance notes, and executive communications
- **Conversational** for copy, blog posts, and internal docs
- **Precise** for research summaries and technical documentation
- Be clear and direct. Lead with the key finding or deliverable.
- Write for the intended audience, not for yourself.
- Prefer concise over verbose. Cut filler words ruthlessly.
- When uncertain about tone, default to professional and approachable.
## What You Are Not
- You are NOT a developer. Do not write code or make technical decisions.
- You are NOT the PM. You do not assign work, set priorities, or manage agents.
- You are NOT a lawyer. Legal research means summarizing publicly available information, not giving legal advice.
- You are NOT a blocker. If you can't unblock something, escalate immediately.

View file

@ -0,0 +1,40 @@
<!-- [nexus] rewritten -->
# TOOLS.md -- Generalist Toolset
## Nexus API (via skill: nexus-api)
Core task lifecycle tools:
- **Issue management**: Read and update tasks assigned to you
- `GET /api/companies/{workspaceId}/issues` — list tasks by status, assignee
- `PATCH /api/issues/{id}` — update status
- `POST /api/issues/{id}/checkout` — claim a task before working on it
- `POST /api/issues/{id}/comments` — add progress comments
## Web Search
For research tasks:
- Search the web for information, sources, and references
- Summarize findings with citations
- Compare multiple sources for accuracy
## File Editing
For document output:
- Create and edit markdown files in the project root or your agent home
- Produce deliverables as files (reports, guides, content pieces)
- Organize output in logical directory structures
## Memory (via skill: para-memory-files)
For persistent context across heartbeats:
- Store daily notes in `$AGENT_HOME/memory/YYYY-MM-DD.md`
- Track research findings, draft versions, and task context
- Maintain a running log of completed deliverables
## Notes
Tools will be added here as you acquire and configure them. Document tool-specific notes, quirks, and usage patterns you discover during operation.

View file

@ -12,6 +12,7 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to
1. **Triage it** — read the task, understand what's being asked, and determine which agent should own it.
2. **Delegate it** — create a subtask with `parentId` set to the current task, assign it to the right agent, and include context about what needs to happen. Routing rules:
- **Code, bugs, features, tests, technical implementation** → Engineer agent
- **Copy, branding, research, legal, docs, presentations** → Generalist agent
- **Cross-functional or unclear** → break into separate subtasks per domain
- If no suitable agent exists, create one via `nexus-create-agent` before delegating.
3. **Do NOT write code, implement features, or fix bugs yourself.** Your agents exist for this.

View file

@ -0,0 +1,209 @@
import { Router } from "express";
import os from "node:os";
import path from "node:path";
import { skillGroupService } from "../services/skill-registry-groups.js";
import { assertBoard } from "./authz.js";
/** Default skills directory when client doesn't provide one */
function defaultSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
/**
* REST routes for skill groups.
*
* Note: does NOT take a db param skill groups use the libSQL registry.db.
* All route handlers assert `board` access before delegating to skillGroupService.
*/
export function skillGroupRoutes(): Router {
const router = Router();
const svc = skillGroupService();
function handleError(res: any, err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (
msg.includes("Cannot delete built-in") ||
msg.includes("not found") ||
msg.includes("cycle") ||
msg.includes("required")
) {
return res.status(400).json({ error: msg });
}
if (msg.includes("already exists")) {
return res.status(409).json({ error: msg });
}
return res.status(500).json({ error: msg });
}
// --- Group CRUD ---
router.get("/skill-registry/groups", async (req, res) => {
assertBoard(req);
try {
const groups = await svc.listGroups();
res.json(groups);
} catch (err) {
handleError(res, err);
}
});
// Import route must come BEFORE /:groupId to avoid "import" being captured as a groupId param
router.post("/skill-registry/groups/import", async (req, res) => {
assertBoard(req);
try {
const result = await svc.importGroup(req.body);
res.status(201).json(result);
} catch (err) {
handleError(res, err);
}
});
router.post("/skill-registry/groups", async (req, res) => {
assertBoard(req);
try {
const { name, description } = req.body as { name?: string; description?: string };
if (!name) {
return res.status(400).json({ error: "name required" });
}
const group = await svc.createGroup({ name, description });
res.status(201).json(group);
} catch (err) {
handleError(res, err);
}
});
router.get("/skill-registry/groups/:groupId", async (req, res) => {
assertBoard(req);
try {
const group = await svc.getGroup(req.params.groupId);
if (!group) {
return res.status(404).json({ error: "Group not found" });
}
res.json(group);
} catch (err) {
handleError(res, err);
}
});
router.patch("/skill-registry/groups/:groupId", async (req, res) => {
assertBoard(req);
try {
const { name, description } = req.body as { name?: string; description?: string };
const group = await svc.updateGroup(req.params.groupId, { name, description });
res.json(group);
} catch (err) {
handleError(res, err);
}
});
router.delete("/skill-registry/groups/:groupId", async (req, res) => {
assertBoard(req);
try {
await svc.deleteGroup(req.params.groupId);
res.status(204).end();
} catch (err) {
handleError(res, err);
}
});
// --- Members ---
router.get("/skill-registry/groups/:groupId/members", async (req, res) => {
assertBoard(req);
try {
const members = await svc.listMembers(req.params.groupId);
res.json(members);
} catch (err) {
handleError(res, err);
}
});
router.post("/skill-registry/groups/:groupId/members", async (req, res) => {
assertBoard(req);
try {
const { skillId } = req.body as { skillId?: string };
if (!skillId) {
return res.status(400).json({ error: "skillId required" });
}
await svc.addMember(req.params.groupId, skillId);
res.status(201).json({ ok: true });
} catch (err) {
handleError(res, err);
}
});
router.delete("/skill-registry/groups/:groupId/members/:skillId(*)", async (req, res) => {
assertBoard(req);
try {
const skillId = req.params.skillId;
await svc.removeMember(req.params.groupId, skillId);
res.status(204).end();
} catch (err) {
handleError(res, err);
}
});
// --- Export ---
router.get("/skill-registry/groups/:groupId/export", async (req, res) => {
assertBoard(req);
try {
const data = await svc.exportGroup(req.params.groupId);
res.setHeader("Content-Disposition", `attachment; filename="${data.group.name}.json"`);
res.json(data);
} catch (err) {
handleError(res, err);
}
});
// --- Agent group assignments ---
router.get("/skill-registry/agents/:agentId/groups", async (req, res) => {
assertBoard(req);
try {
const groups = await svc.listAgentGroups(req.params.agentId);
res.json(groups);
} catch (err) {
handleError(res, err);
}
});
router.post("/skill-registry/agents/:agentId/groups", async (req, res) => {
assertBoard(req);
try {
const { groupId, agentSkillsDir } = req.body as { groupId?: string; agentSkillsDir?: string };
if (!groupId) {
return res.status(400).json({ error: "groupId required" });
}
const resolvedDir = agentSkillsDir || defaultSkillsDir();
const result = await svc.assignGroup(groupId, req.params.agentId, resolvedDir);
res.status(201).json(result);
} catch (err) {
handleError(res, err);
}
});
router.delete("/skill-registry/agents/:agentId/groups/:groupId(*)", async (req, res) => {
assertBoard(req);
try {
const { agentSkillsDir } = req.body as { agentSkillsDir?: string };
const resolvedDir = agentSkillsDir || defaultSkillsDir();
await svc.removeGroup(req.params.groupId, req.params.agentId, resolvedDir);
res.status(204).end();
} catch (err) {
handleError(res, err);
}
});
router.get("/skill-registry/agents/:agentId/skills", async (req, res) => {
assertBoard(req);
try {
const skills = await svc.listAgentSkills(req.params.agentId);
res.json(skills);
} catch (err) {
handleError(res, err);
}
});
return router;
}

View file

@ -0,0 +1,102 @@
import { Router } from "express";
import { skillRegistryService } from "../services/skill-registry.js";
import { skillRatingService } from "../services/skill-registry-ratings.js";
import { assertBoard } from "./authz.js";
/**
* REST routes for the skill registry.
*
* Note: does NOT take a db param the skill registry manages its own libSQL database.
* All route handlers assert `board` access before delegating to skillRegistryService.
*/
export function skillRegistryRoutes(): Router {
const router = Router();
const svc = skillRegistryService();
// List all skills (soft-deleted excluded by default)
router.get("/skill-registry/skills", async (req, res) => {
assertBoard(req);
const includeRemoved = req.query.includeRemoved === "true";
const list = await svc.list({ includeRemoved });
res.json(list);
});
// Get versions for a skill — must be registered before the single-skill route
// to avoid /:id matching "versions" as the id segment
router.get("/skill-registry/skills/:sourceId/:slug/versions", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const versions = await svc.getVersions(skillId);
res.json(versions);
});
// Install skill to agent directory
router.post("/skill-registry/skills/:sourceId/:slug/install", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { agentSkillsDir } = req.body as { agentSkillsDir: string };
if (!agentSkillsDir) return res.status(400).json({ error: "agentSkillsDir required" });
const result = await svc.install(skillId, agentSkillsDir);
res.json(result);
});
// Rollback to a specific version
router.post("/skill-registry/skills/:sourceId/:slug/rollback", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { versionId, agentSkillsDir } = req.body as { versionId: string; agentSkillsDir: string };
if (!versionId || !agentSkillsDir) {
return res.status(400).json({ error: "versionId and agentSkillsDir required" });
}
await svc.rollback(skillId, versionId, agentSkillsDir);
res.json({ ok: true });
});
// Soft-delete a skill
router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
await svc.uninstall(skillId);
res.json({ ok: true });
});
// Submit a personal rating for a skill
router.post("/skill-registry/skills/:sourceId/:slug/ratings", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { stars, versionId, note } = req.body as { stars: number; versionId?: string; note?: string };
if (typeof stars !== "number" || stars < 1 || stars > 5) {
return res.status(400).json({ error: "stars must be a number between 1 and 5" });
}
const ratingSvc = skillRatingService();
await ratingSvc.rate({ skillId, versionId: versionId ?? null, stars, note: note ?? null });
res.json({ ok: true });
});
// Get personal ratings for a skill
router.get("/skill-registry/skills/:sourceId/:slug/ratings", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const ratingSvc = skillRatingService();
const ratings = await ratingSvc.getRatings(skillId);
res.json(ratings);
});
// Get a single skill by id
router.get("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const skill = await svc.getById(skillId);
if (!skill) return res.status(404).json({ error: "Skill not found" });
res.json(skill);
});
// Trigger fetch from all configured sources
router.post("/skill-registry/fetch", async (req, res) => {
assertBoard(req);
const result = await svc.fetchAll();
res.json(result);
});
return router;
}

View file

@ -30,6 +30,15 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { findServerAdapter } from "../adapters/index.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { notFound, unprocessable } from "../errors.js";
import {
fetchText,
fetchJson,
resolveGitHubDefaultBranch,
resolveGitHubCommitSha,
parseGitHubSourceUrl,
resolveGitHubPinnedRef,
resolveRawGitHubUrl,
} from "./github-skill-helpers.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
import { secretService } from "./secrets.js";
@ -469,90 +478,8 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
};
}
async function fetchText(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
// [nexus] GitHub helpers extracted to shared module — imported below
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
accept: "application/vnd.github+json",
},
});
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function resolveGitHubDefaultBranch(owner: string, repo: string) {
const response = await fetchJson<{ default_branch?: string }>(
`https://api.github.com/repos/${owner}/${repo}`,
);
return asString(response.default_branch) ?? "main";
}
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string) {
const response = await fetchJson<{ sha?: string }>(
`https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
);
const sha = asString(response.sha);
if (!sha) {
throw unprocessable(`Failed to resolve GitHub ref ${ref}`);
}
return sha;
}
function parseGitHubSourceUrl(rawUrl: string) {
const url = new URL(rawUrl);
if (url.hostname !== "github.com") {
throw unprocessable("GitHub source must use github.com URL");
}
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) {
throw unprocessable("Invalid GitHub URL");
}
const owner = parts[0]!;
const repo = parts[1]!.replace(/\.git$/i, "");
let ref = "main";
let basePath = "";
let filePath: string | null = null;
let explicitRef = false;
if (parts[2] === "tree") {
ref = parts[3] ?? "main";
basePath = parts.slice(4).join("/");
explicitRef = true;
} else if (parts[2] === "blob") {
ref = parts[3] ?? "main";
filePath = parts.slice(4).join("/");
basePath = filePath ? path.posix.dirname(filePath) : "";
explicitRef = true;
}
return { owner, repo, ref, basePath, filePath, explicitRef };
}
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
return {
pinnedRef: parsed.ref,
trackingRef: parsed.explicitRef ? parsed.ref : null,
};
}
const trackingRef = parsed.explicitRef
? parsed.ref
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef);
return { pinnedRef, trackingRef };
}
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`;
}
function extractCommandTokens(raw: string) {
const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];

View file

@ -5,6 +5,7 @@ const DEFAULT_AGENT_BUNDLE_FILES = {
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
pm: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"], // [nexus]
engineer: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"], // [nexus]
general: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"], // [nexus]
} as const;
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
@ -28,5 +29,6 @@ export function resolveDefaultAgentInstructionsBundleRole(role: string): Default
if (role === "ceo") return "ceo";
if (role === "pm") return "pm"; // [nexus]
if (role === "engineer") return "engineer"; // [nexus]
if (role === "general") return "general"; // [nexus]
return "default";
}

View file

@ -0,0 +1,102 @@
import path from "node:path";
import { unprocessable } from "../errors.js";
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
export async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
accept: "application/vnd.github+json",
},
});
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function resolveGitHubDefaultBranch(owner: string, repo: string): Promise<string> {
const response = await fetchJson<{ default_branch?: string }>(
`https://api.github.com/repos/${owner}/${repo}`,
);
return asString(response.default_branch) ?? "main";
}
export async function resolveGitHubCommitSha(owner: string, repo: string, ref: string): Promise<string> {
const response = await fetchJson<{ sha?: string }>(
`https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
);
const sha = asString(response.sha);
if (!sha) {
throw unprocessable(`Failed to resolve GitHub ref ${ref}`);
}
return sha;
}
export function parseGitHubSourceUrl(rawUrl: string): {
owner: string;
repo: string;
ref: string;
basePath: string;
filePath: string | null;
explicitRef: boolean;
} {
const url = new URL(rawUrl);
if (url.hostname !== "github.com") {
throw unprocessable("GitHub source must use github.com URL");
}
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) {
throw unprocessable("Invalid GitHub URL");
}
const owner = parts[0]!;
const repo = parts[1]!.replace(/\.git$/i, "");
let ref = "main";
let basePath = "";
let filePath: string | null = null;
let explicitRef = false;
if (parts[2] === "tree") {
ref = parts[3] ?? "main";
basePath = parts.slice(4).join("/");
explicitRef = true;
} else if (parts[2] === "blob") {
ref = parts[3] ?? "main";
filePath = parts.slice(4).join("/");
basePath = filePath ? path.posix.dirname(filePath) : "";
explicitRef = true;
}
return { owner, repo, ref, basePath, filePath, explicitRef };
}
export async function resolveGitHubPinnedRef(
parsed: ReturnType<typeof parseGitHubSourceUrl>,
): Promise<{ pinnedRef: string; trackingRef: string | null }> {
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
return {
pinnedRef: parsed.ref,
trackingRef: parsed.explicitRef ? parsed.ref : null,
};
}
const trackingRef = parsed.explicitRef
? parsed.ref
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef);
return { pinnedRef, trackingRef };
}
export function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string): string {
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`;
}

View file

@ -2717,6 +2717,11 @@ export function heartbeatService(db: Db) {
}
}
await finalizeAgentStatus(agent.id, outcome);
if (outcome === "succeeded") {
void import("./skill-registry-ratings.js").then(({ skillRatingService }) =>
skillRatingService().recordUsageForAgent(agent.id, normalizedUsage?.totalCostUsd ?? null)
).catch((err) => logger.warn({ err, agentId: agent.id }, "failed to record skill usage"));
}
} catch (err) {
const message = redactCurrentUserText(
err instanceof Error ? err.message : "Unknown adapter failure",

View file

@ -1,5 +1,6 @@
export { companyService } from "./companies.js";
export { companySkillService } from "./company-skills.js";
export { skillRegistryService } from "./skill-registry.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
export { assetService } from "./assets.js";

View file

@ -0,0 +1,186 @@
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { drizzle } from "drizzle-orm/libsql";
import { createClient, type Client as LibSQLClient } from "@libsql/client";
import * as schema from "./skill-registry-schema.js";
import { resolveSkillRegistryDbPath } from "../home-paths.js";
export type SkillRegistryDb = ReturnType<typeof drizzle<typeof schema>>;
let _db: SkillRegistryDb | null = null;
const CREATE_SKILLS_TABLE = `
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
source_url TEXT,
active_version_id TEXT,
removed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const CREATE_SKILL_VERSIONS_TABLE = `
CREATE TABLE IF NOT EXISTS skill_versions (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
version TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
cache_dir TEXT
)`;
const CREATE_SKILL_FILES_TABLE = `
CREATE TABLE IF NOT EXISTS skill_files (
id TEXT PRIMARY KEY,
version_id TEXT NOT NULL,
path TEXT NOT NULL,
kind TEXT NOT NULL,
size_bytes INTEGER
)`;
const CREATE_COMMUNITY_RATINGS_TABLE = `
CREATE TABLE IF NOT EXISTS community_ratings (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
average_rating REAL,
rating_count INTEGER,
source TEXT
)`;
const CREATE_SKILL_GROUPS_TABLE = `
CREATE TABLE IF NOT EXISTS skill_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const CREATE_SKILL_GROUP_MEMBERS_TABLE = `
CREATE TABLE IF NOT EXISTS skill_group_members (
group_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (group_id, skill_id)
)`;
const CREATE_SKILL_GROUP_INHERITANCE_TABLE = `
CREATE TABLE IF NOT EXISTS skill_group_inheritance (
child_group_id TEXT NOT NULL,
parent_group_id TEXT NOT NULL,
PRIMARY KEY (child_group_id, parent_group_id)
)`;
const CREATE_AGENT_SKILL_GROUPS_TABLE = `
CREATE TABLE IF NOT EXISTS agent_skill_groups (
agent_id TEXT NOT NULL,
group_id TEXT NOT NULL,
assigned_at INTEGER NOT NULL,
PRIMARY KEY (agent_id, group_id)
)`;
const CREATE_AGENT_SKILLS_TABLE = `
CREATE TABLE IF NOT EXISTS agent_skills (
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
installed_at INTEGER NOT NULL,
PRIMARY KEY (agent_id, skill_id)
)`;
const CREATE_PERSONAL_RATINGS_TABLE = `
CREATE TABLE IF NOT EXISTS personal_ratings (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
version_id TEXT,
stars INTEGER NOT NULL,
note TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const BUILTIN_GROUPS = [
{
id: "builtin/pm-essentials",
name: "PM Essentials",
description: "Core planning and project-management skills",
},
{
id: "builtin/engineer-core",
name: "Engineer Core",
description: "Foundational engineering skills",
},
{
id: "builtin/frontend",
name: "Frontend",
description: "UI and frontend development skills",
},
{
id: "builtin/backend",
name: "Backend",
description: "API, database, and infrastructure skills",
},
{
id: "builtin/creative",
name: "Creative",
description: "Writing, branding, and creative production",
},
] as const;
async function seedBuiltinGroups(client: LibSQLClient): Promise<void> {
const now = Date.now();
for (const group of BUILTIN_GROUPS) {
await client.execute({
sql: `INSERT OR IGNORE INTO skill_groups (id, name, description, is_builtin, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)`,
args: [group.id, group.name, group.description, now, now],
});
}
}
export async function getSkillRegistryDb(): Promise<SkillRegistryDb> {
if (_db !== null) return _db;
const dbPath = resolveSkillRegistryDbPath();
await mkdir(dirname(dbPath), { recursive: true });
const client = createClient({ url: `file:${dbPath}` });
_db = drizzle({ client, schema });
await client.execute(CREATE_SKILLS_TABLE);
await client.execute(CREATE_SKILL_VERSIONS_TABLE);
await client.execute(CREATE_SKILL_FILES_TABLE);
await client.execute(CREATE_COMMUNITY_RATINGS_TABLE);
await client.execute(CREATE_SKILL_GROUPS_TABLE);
await client.execute(CREATE_SKILL_GROUP_MEMBERS_TABLE);
await client.execute(CREATE_SKILL_GROUP_INHERITANCE_TABLE);
await client.execute(CREATE_AGENT_SKILL_GROUPS_TABLE);
await client.execute(CREATE_AGENT_SKILLS_TABLE);
await client.execute(CREATE_PERSONAL_RATINGS_TABLE);
// Add usage-tracking columns to agent_skills if they don't exist yet (idempotent)
const agentSkillsAlters = [
`ALTER TABLE agent_skills ADD COLUMN task_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agent_skills ADD COLUMN avg_cost_usd REAL`,
`ALTER TABLE agent_skills ADD COLUMN last_used_at INTEGER`,
];
for (const sql of agentSkillsAlters) {
try {
await client.execute(sql);
} catch {
// Column already exists — ignore
}
}
await seedBuiltinGroups(client);
return _db;
}
/** Reset the singleton — used for test cleanup */
export function resetSkillRegistryDb(): void {
_db = null;
}

View file

@ -0,0 +1,411 @@
import crypto from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import { getSkillRegistryDb, type SkillRegistryDb } from "./skill-registry-db.js";
import { skills, skillVersions, skillFiles, communityRatings } from "./skill-registry-schema.js";
import {
fetchText,
fetchJson,
resolveGitHubCommitSha,
resolveRawGitHubUrl,
} from "./github-skill-helpers.js";
import { resolveSkillCacheDir } from "../home-paths.js";
// ---------------------------------------------------------------------------
// Source config
// ---------------------------------------------------------------------------
export type SkillSourceConfig = {
id: string;
type: "anthropic-marketplace" | "github-tree";
owner: string;
repo: string;
ref: string;
label: string;
};
export const BUILT_IN_SOURCES: SkillSourceConfig[] = [
{
id: "anthropic-official",
type: "anthropic-marketplace",
owner: "anthropics",
repo: "skills",
ref: "main",
label: "Anthropic Official",
},
{
id: "schwepps-skills",
type: "github-tree",
owner: "schwepps",
repo: "skills",
ref: "main",
label: "Schwepps Community",
},
{
id: "daymade-skills",
type: "github-tree",
owner: "daymade",
repo: "claude-code-skills",
ref: "main",
label: "Daymade Community",
},
];
// ---------------------------------------------------------------------------
// Frontmatter parsing
// ---------------------------------------------------------------------------
/**
* Parse YAML frontmatter from a SKILL.md string.
* Only extracts `name` and `description` fields.
*/
export function parseSkillFrontmatter(markdown: string): {
name?: string;
description?: string;
} {
const match = /^---\r?\n([\s\S]*?)\r?\n---/m.exec(markdown);
if (!match) return {};
const block = match[1] ?? "";
const nameMatch = /^name:\s*(.+)$/m.exec(block);
const descMatch = /^description:\s*(.+)$/m.exec(block);
const name = nameMatch?.[1]?.trim();
const description = descMatch?.[1]?.trim();
return {
name: name && name.length > 0 ? name : undefined,
description: description && description.length > 0 ? description : undefined,
};
}
/**
* Convert a path segment to a URL-safe skill slug.
* e.g. "My Skill Name" "my-skill-name"
*/
export function slugFromPath(sourcePath: string): string {
// Take the last non-empty path segment (the directory name of the skill)
const parts = sourcePath.split("/").filter(Boolean);
const segment = parts[parts.length - 1] ?? sourcePath;
return segment
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// ---------------------------------------------------------------------------
// Core fetch helpers
// ---------------------------------------------------------------------------
type GitHubTreeEntry = {
path: string;
type: string;
size?: number;
};
type GitHubTreeResponse = {
tree: GitHubTreeEntry[];
};
type MarketplaceJson = {
skills: Array<{ path: string }>;
};
/**
* Upsert a skill row and return its id.
*/
async function upsertSkill(
db: SkillRegistryDb,
opts: {
skillId: string;
sourceId: string;
name: string;
description: string | undefined;
sourceUrl: string;
},
): Promise<void> {
const now = Date.now();
await db
.insert(skills)
.values({
id: opts.skillId,
sourceId: opts.sourceId,
name: opts.name,
description: opts.description ?? null,
sourceUrl: opts.sourceUrl,
activeVersionId: null,
removedAt: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: skills.id,
set: {
name: opts.name,
description: opts.description ?? null,
updatedAt: now,
},
});
}
/**
* Upsert a stub community_ratings row for a skill.
* This ensures list() and getById() JOINs always find a row.
* Real rating values are populated in v1.3 when community APIs are available.
*/
async function upsertCommunityRatingsStub(
db: SkillRegistryDb,
skillId: string,
sourceId: string,
): Promise<void> {
await db
.insert(communityRatings)
.values({
id: `${skillId}@${sourceId}`,
skillId,
fetchedAt: Date.now(),
averageRating: null,
ratingCount: null,
source: sourceId,
})
.onConflictDoUpdate({
target: communityRatings.id,
set: {
fetchedAt: Date.now(),
averageRating: null,
ratingCount: null,
},
});
}
/**
* Check whether a version with this SHA already exists in the DB.
* Returns true if already present (skip download).
*/
async function versionExists(db: SkillRegistryDb, versionId: string): Promise<boolean> {
const existing = await db
.select({ id: skillVersions.id })
.from(skillVersions)
.where(eq(skillVersions.id, versionId));
return existing.length > 0;
}
/**
* Cache SKILL.md to disk and insert skill_versions + skill_files rows.
*/
async function cacheSkillVersion(
db: SkillRegistryDb,
opts: {
skillId: string;
sha: string;
skillMdContent: string;
skillMdUrl: string;
},
): Promise<void> {
const versionId = `${opts.skillId}@${opts.sha}`;
// Idempotency check — skip if version already cached
if (await versionExists(db, versionId)) {
return;
}
const cacheDir = resolveSkillCacheDir(opts.skillId, opts.sha);
await mkdir(cacheDir, { recursive: true });
const skillMdPath = path.join(cacheDir, "SKILL.md");
await writeFile(skillMdPath, opts.skillMdContent, "utf-8");
const now = Date.now();
// Insert skill_versions row
await db.insert(skillVersions).values({
id: versionId,
skillId: opts.skillId,
version: opts.sha,
fetchedAt: now,
cacheDir,
});
// Insert skill_files row for SKILL.md
const sizeBytes = Buffer.byteLength(opts.skillMdContent, "utf-8");
await db.insert(skillFiles).values({
id: crypto.randomUUID(),
versionId,
path: "SKILL.md",
kind: "skill",
sizeBytes,
});
}
// ---------------------------------------------------------------------------
// Source-type handlers
// ---------------------------------------------------------------------------
async function fetchAnthropicMarketplace(
source: SkillSourceConfig,
db: SkillRegistryDb,
): Promise<number> {
const marketplaceUrl = resolveRawGitHubUrl(
source.owner,
source.repo,
source.ref,
".claude-plugin/marketplace.json",
);
const marketplaceText = await fetchText(marketplaceUrl);
const marketplace: MarketplaceJson = JSON.parse(marketplaceText);
const sha = await resolveGitHubCommitSha(source.owner, source.repo, source.ref);
let fetched = 0;
for (const entry of marketplace.skills ?? []) {
const skillPath = entry.path;
const slug = slugFromPath(skillPath);
const skillId = `${source.id}/${slug}`;
// Idempotency check before downloading — skip if version already cached
const versionId = `${skillId}@${sha}`;
if (await versionExists(db, versionId)) {
fetched++;
continue;
}
const skillMdUrl = resolveRawGitHubUrl(source.owner, source.repo, source.ref, `${skillPath}/SKILL.md`);
let skillMdContent: string;
try {
skillMdContent = await fetchText(skillMdUrl);
} catch {
// Skip skills that don't have a SKILL.md
continue;
}
const { name, description } = parseSkillFrontmatter(skillMdContent);
const sourceUrl = `https://github.com/${source.owner}/${source.repo}/tree/${source.ref}/${skillPath}`;
await upsertSkill(db, {
skillId,
sourceId: source.id,
name: name ?? slug,
description,
sourceUrl,
});
await cacheSkillVersion(db, {
skillId,
sha,
skillMdContent,
skillMdUrl,
});
await upsertCommunityRatingsStub(db, skillId, source.id);
fetched++;
}
return fetched;
}
async function fetchGitHubTree(
source: SkillSourceConfig,
db: SkillRegistryDb,
): Promise<number> {
const treeUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/git/trees/${encodeURIComponent(source.ref)}?recursive=1`;
const treeResponse = await fetchJson<GitHubTreeResponse>(treeUrl);
const sha = await resolveGitHubCommitSha(source.owner, source.repo, source.ref);
// Find all SKILL.md files
const skillMdEntries = (treeResponse.tree ?? []).filter(
(entry) => entry.type === "blob" && entry.path.endsWith("SKILL.md"),
);
let fetched = 0;
for (const entry of skillMdEntries) {
// entry.path is like "code-review/SKILL.md" — dirname is the skill dir
const skillDir = path.posix.dirname(entry.path);
if (!skillDir || skillDir === ".") continue;
const slug = slugFromPath(skillDir);
const skillId = `${source.id}/${slug}`;
// Idempotency check before downloading — skip if version already cached
const versionId = `${skillId}@${sha}`;
if (await versionExists(db, versionId)) {
fetched++;
continue;
}
const skillMdUrl = resolveRawGitHubUrl(source.owner, source.repo, source.ref, entry.path);
let skillMdContent: string;
try {
skillMdContent = await fetchText(skillMdUrl);
} catch {
continue;
}
const { name, description } = parseSkillFrontmatter(skillMdContent);
const sourceUrl = `https://github.com/${source.owner}/${source.repo}/tree/${source.ref}/${skillDir}`;
await upsertSkill(db, {
skillId,
sourceId: source.id,
name: name ?? slug,
description,
sourceUrl,
});
await cacheSkillVersion(db, {
skillId,
sha,
skillMdContent,
skillMdUrl,
});
await upsertCommunityRatingsStub(db, skillId, source.id);
fetched++;
}
return fetched;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export type FetchAllSourcesResult = {
fetched: number;
errors: string[];
};
/**
* Fetch skills from all configured sources and populate the registry DB.
* Uses BUILT_IN_SOURCES if no sources are provided.
*/
export async function fetchAllSources(
sources: SkillSourceConfig[] = BUILT_IN_SOURCES,
): Promise<FetchAllSourcesResult> {
const db = await getSkillRegistryDb();
let fetched = 0;
const errors: string[] = [];
for (const source of sources) {
try {
if (source.type === "anthropic-marketplace") {
fetched += await fetchAnthropicMarketplace(source, db);
} else if (source.type === "github-tree") {
fetched += await fetchGitHubTree(source, db);
} else {
errors.push(`Unknown source type for ${source.id}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`Source ${source.id}: ${message}`);
}
}
return { fetched, errors };
}

View file

@ -0,0 +1,500 @@
import { eq, and, inArray } from "drizzle-orm";
import { rm } from "node:fs/promises";
import path from "node:path";
import { getSkillRegistryDb } from "./skill-registry-db.js";
import {
skillGroups,
skillGroupMembers,
skillGroupInheritance,
agentSkillGroups,
agentSkills,
} from "./skill-registry-schema.js";
import { skillRegistryService } from "./skill-registry.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type GroupRow = typeof skillGroups.$inferSelect;
type MemberRow = typeof skillGroupMembers.$inferSelect;
type GroupExport = {
version: "1";
group: {
id: string;
name: string;
description: string | null;
members: string[];
parents: string[];
};
};
type AssignResult = { installed: string[]; skipped: string[]; pendingPlugin: string[] };
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Skill group service factory.
* Manages its own libSQL database (does not accept a Postgres db param).
* Use `getSkillRegistryDb()` for all persistence.
*/
export function skillGroupService() {
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* BFS cycle detection: would adding parentGroupId as a parent of childGroupId
* create a cycle? Returns true if childGroupId is reachable from parentGroupId
* by walking up the inheritance chain.
*/
async function wouldCreateCycle(
childGroupId: string,
parentGroupId: string,
): Promise<boolean> {
const db = await getSkillRegistryDb();
const visited = new Set<string>();
const queue: string[] = [parentGroupId];
while (queue.length > 0) {
const current = queue.shift()!;
if (current === childGroupId) return true;
if (visited.has(current)) continue;
visited.add(current);
// Walk up: find parents of current
const rows = await db
.select()
.from(skillGroupInheritance)
.where(eq(skillGroupInheritance.childGroupId, current));
for (const row of rows) {
if (!visited.has(row.parentGroupId)) {
queue.push(row.parentGroupId);
}
}
}
return false;
}
return {
// -------------------------------------------------------------------------
// Group CRUD
// -------------------------------------------------------------------------
async listGroups(): Promise<GroupRow[]> {
const db = await getSkillRegistryDb();
// Order: built-in first, then alphabetical by name
const rows = await db.select().from(skillGroups);
return rows.sort((a, b) => {
if (a.isBuiltin !== b.isBuiltin) return b.isBuiltin - a.isBuiltin;
return a.name.localeCompare(b.name);
});
},
async getGroup(groupId: string): Promise<GroupRow | undefined> {
const db = await getSkillRegistryDb();
const rows = await db
.select()
.from(skillGroups)
.where(eq(skillGroups.id, groupId));
return rows[0];
},
async createGroup(input: {
name: string;
description?: string;
}): Promise<GroupRow> {
const db = await getSkillRegistryDb();
const id = `custom/${input.name.toLowerCase().replace(/\s+/g, "-")}`;
const now = Date.now();
const row: typeof skillGroups.$inferInsert = {
id,
name: input.name,
description: input.description ?? null,
isBuiltin: 0,
createdAt: now,
updatedAt: now,
};
await db.insert(skillGroups).values(row);
const inserted = await db
.select()
.from(skillGroups)
.where(eq(skillGroups.id, id));
return inserted[0]!;
},
async updateGroup(
groupId: string,
patch: { name?: string; description?: string },
): Promise<GroupRow> {
const db = await getSkillRegistryDb();
const existing = await this.getGroup(groupId);
if (!existing) throw new Error("Group not found");
const updates: Partial<typeof skillGroups.$inferInsert> = {
updatedAt: Date.now(),
};
if (patch.name !== undefined) updates.name = patch.name;
if (patch.description !== undefined) updates.description = patch.description;
await db
.update(skillGroups)
.set(updates)
.where(eq(skillGroups.id, groupId));
const updated = await db
.select()
.from(skillGroups)
.where(eq(skillGroups.id, groupId));
return updated[0]!;
},
async deleteGroup(groupId: string): Promise<void> {
const db = await getSkillRegistryDb();
const existing = await this.getGroup(groupId);
if (!existing) throw new Error("Group not found");
if (existing.isBuiltin === 1)
throw new Error("Cannot delete built-in group");
// Remove all membership rows
await db
.delete(skillGroupMembers)
.where(eq(skillGroupMembers.groupId, groupId));
// Remove all inheritance rows (as parent or child)
await db
.delete(skillGroupInheritance)
.where(eq(skillGroupInheritance.childGroupId, groupId));
await db
.delete(skillGroupInheritance)
.where(eq(skillGroupInheritance.parentGroupId, groupId));
// Remove all agent assignments
await db
.delete(agentSkillGroups)
.where(eq(agentSkillGroups.groupId, groupId));
// Remove group itself
await db.delete(skillGroups).where(eq(skillGroups.id, groupId));
},
// -------------------------------------------------------------------------
// Member management
// -------------------------------------------------------------------------
async addMember(groupId: string, skillId: string): Promise<void> {
const db = await getSkillRegistryDb();
await db
.insert(skillGroupMembers)
.values({ groupId, skillId, addedAt: Date.now() })
.onConflictDoNothing();
},
async removeMember(groupId: string, skillId: string): Promise<void> {
const db = await getSkillRegistryDb();
await db
.delete(skillGroupMembers)
.where(
and(
eq(skillGroupMembers.groupId, groupId),
eq(skillGroupMembers.skillId, skillId),
),
);
},
async listMembers(groupId: string): Promise<MemberRow[]> {
const db = await getSkillRegistryDb();
return db
.select()
.from(skillGroupMembers)
.where(eq(skillGroupMembers.groupId, groupId));
},
// -------------------------------------------------------------------------
// Inheritance management
// -------------------------------------------------------------------------
async addParent(
childGroupId: string,
parentGroupId: string,
): Promise<void> {
const db = await getSkillRegistryDb();
const cycle = await wouldCreateCycle(childGroupId, parentGroupId);
if (cycle)
throw new Error("Adding this parent would create a cycle");
await db
.insert(skillGroupInheritance)
.values({ childGroupId, parentGroupId })
.onConflictDoNothing();
},
async removeParent(
childGroupId: string,
parentGroupId: string,
): Promise<void> {
const db = await getSkillRegistryDb();
await db
.delete(skillGroupInheritance)
.where(
and(
eq(skillGroupInheritance.childGroupId, childGroupId),
eq(skillGroupInheritance.parentGroupId, parentGroupId),
),
);
},
async listParents(groupId: string): Promise<string[]> {
const db = await getSkillRegistryDb();
const rows = await db
.select()
.from(skillGroupInheritance)
.where(eq(skillGroupInheritance.childGroupId, groupId));
return rows.map((r) => r.parentGroupId);
},
// -------------------------------------------------------------------------
// Effective skill resolution
// -------------------------------------------------------------------------
/**
* BFS walk through the group inheritance tree.
* Collects all direct member skills from the group and all parent groups.
* Uses a visited set to handle cycles safely.
*/
async resolveEffectiveSkills(groupId: string): Promise<string[]> {
const db = await getSkillRegistryDb();
const visited = new Set<string>();
const skillIds = new Set<string>();
async function walk(gid: string): Promise<void> {
if (visited.has(gid)) return;
visited.add(gid);
// Collect direct members
const members = await db
.select()
.from(skillGroupMembers)
.where(eq(skillGroupMembers.groupId, gid));
for (const m of members) skillIds.add(m.skillId);
// Recurse into parents
const parents = await db
.select()
.from(skillGroupInheritance)
.where(eq(skillGroupInheritance.childGroupId, gid));
for (const p of parents) await walk(p.parentGroupId);
}
await walk(groupId);
return Array.from(skillIds);
},
// -------------------------------------------------------------------------
// Agent assignment
// -------------------------------------------------------------------------
async assignGroup(
groupId: string,
agentId: string,
agentSkillsDir: string,
): Promise<AssignResult> {
const db = await getSkillRegistryDb();
const installed: string[] = [];
const skipped: string[] = [];
const pendingPlugin: string[] = [];
// Idempotent assignment
await db
.insert(agentSkillGroups)
.values({ agentId, groupId, assignedAt: Date.now() })
.onConflictDoNothing();
const skillIds = await this.resolveEffectiveSkills(groupId);
const svc = skillRegistryService();
for (const skillId of skillIds) {
try {
const result = await svc.install(skillId, agentSkillsDir);
// Record in agent_skills
await db
.insert(agentSkills)
.values({ agentId, skillId, installedAt: Date.now() })
.onConflictDoNothing();
if (result.type === "installed") {
installed.push(skillId);
} else {
// pending_plugin_install
pendingPlugin.push(result.command);
}
} catch (err) {
// Don't block the entire assignment if one skill fails
skipped.push(skillId);
}
}
return { installed, skipped, pendingPlugin };
},
async removeGroup(
groupId: string,
agentId: string,
agentSkillsDir: string,
): Promise<void> {
const db = await getSkillRegistryDb();
// Remove group assignment
await db
.delete(agentSkillGroups)
.where(
and(
eq(agentSkillGroups.agentId, agentId),
eq(agentSkillGroups.groupId, groupId),
),
);
// Get remaining groups
const remainingRows = await db
.select()
.from(agentSkillGroups)
.where(eq(agentSkillGroups.agentId, agentId));
// Union all skills still required by remaining groups
const stillNeeded = new Set<string>();
for (const row of remainingRows) {
const effective = await this.resolveEffectiveSkills(row.groupId);
for (const sid of effective) stillNeeded.add(sid);
}
// Individually installed skills (not from a group) — these should be preserved
const individualRows = await db
.select()
.from(agentSkills)
.where(eq(agentSkills.agentId, agentId));
const individualSkills = new Set(individualRows.map((r) => r.skillId));
// Find skills that were contributed by the removed group
const removedGroupSkills = await this.resolveEffectiveSkills(groupId);
for (const skillId of removedGroupSkills) {
// Skip if still needed by another group or individually installed
if (stillNeeded.has(skillId) || individualSkills.has(skillId)) continue;
// Remove files from agent skills directory
const slug = skillId.split("/").pop() ?? skillId;
await rm(path.join(agentSkillsDir, slug), {
recursive: true,
force: true,
});
// Remove from agent_skills if present
await db
.delete(agentSkills)
.where(
and(
eq(agentSkills.agentId, agentId),
eq(agentSkills.skillId, skillId),
),
);
}
},
async listAgentGroups(agentId: string): Promise<GroupRow[]> {
const db = await getSkillRegistryDb();
const assignments = await db
.select()
.from(agentSkillGroups)
.where(eq(agentSkillGroups.agentId, agentId));
if (assignments.length === 0) return [];
const groupIds = assignments.map((a) => a.groupId);
return db
.select()
.from(skillGroups)
.where(inArray(skillGroups.id, groupIds));
},
async listAgentSkills(agentId: string): Promise<string[]> {
const db = await getSkillRegistryDb();
const rows = await db
.select()
.from(agentSkills)
.where(eq(agentSkills.agentId, agentId));
return rows.map((r) => r.skillId);
},
async getAgentEffectiveSkills(agentId: string): Promise<string[]> {
const groups = await this.listAgentGroups(agentId);
const union = new Set<string>();
for (const group of groups) {
const skills = await this.resolveEffectiveSkills(group.id);
for (const s of skills) union.add(s);
}
return Array.from(union);
},
// -------------------------------------------------------------------------
// Import / Export
// -------------------------------------------------------------------------
async exportGroup(groupId: string): Promise<GroupExport> {
const group = await this.getGroup(groupId);
if (!group) throw new Error("Group not found");
const memberRows = await this.listMembers(groupId);
const parentIds = await this.listParents(groupId);
return {
version: "1",
group: {
id: group.id,
name: group.name,
description: group.description,
members: memberRows.map((m) => m.skillId),
parents: parentIds,
},
};
},
async importGroup(data: GroupExport): Promise<GroupRow> {
if (data.version !== "1") throw new Error("Unsupported export version");
const existing = await this.getGroup(data.group.id);
if (existing) {
throw new Error(
`A group with id "${data.group.id}" already exists. Rename the group before importing.`,
);
}
// Create the group
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skillGroups).values({
id: data.group.id,
name: data.group.name,
description: data.group.description,
isBuiltin: 0,
createdAt: now,
updatedAt: now,
});
// Insert members
for (const skillId of data.group.members) {
await this.addMember(data.group.id, skillId);
}
// Insert parents (with cycle check via addParent)
for (const parentId of data.group.parents) {
await this.addParent(data.group.id, parentId);
}
const newGroup = await this.getGroup(data.group.id);
return newGroup!;
},
};
}

View file

@ -0,0 +1,99 @@
import { eq, desc } from "drizzle-orm";
import { getSkillRegistryDb } from "./skill-registry-db.js";
import { personalRatings, agentSkills } from "./skill-registry-schema.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type RateOpts = {
skillId: string;
versionId?: string | null;
stars: number;
note?: string | null;
};
type PersonalRatingRow = typeof personalRatings.$inferSelect;
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Skill rating service factory.
* Manages personal ratings and usage tracking in the skill registry libSQL DB.
*/
export function skillRatingService() {
return {
/**
* Record a personal rating (1-5 stars) for a skill.
* Always appends never upserts so rating history is preserved.
*/
async rate(opts: RateOpts): Promise<void> {
if (opts.stars < 1 || opts.stars > 5) {
throw new RangeError(`stars must be between 1 and 5, got ${opts.stars}`);
}
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(personalRatings).values({
id: crypto.randomUUID(),
skillId: opts.skillId,
versionId: opts.versionId ?? null,
stars: opts.stars,
note: opts.note ?? null,
createdAt: now,
updatedAt: now,
});
},
/**
* Get all personal ratings for a skill, ordered by createdAt descending (newest first).
*/
async getRatings(skillId: string): Promise<PersonalRatingRow[]> {
const db = await getSkillRegistryDb();
return db
.select()
.from(personalRatings)
.where(eq(personalRatings.skillId, skillId))
.orderBy(desc(personalRatings.createdAt));
},
/**
* Record a heartbeat run completion for all skills installed by an agent.
* Increments task_count, updates running average cost, and sets last_used_at.
* Safe to call when agent has no skills (no-op).
*
* @param agentId - the agent that just completed a successful run
* @param costUsd - total cost of the run in USD, or null if unknown
*/
async recordUsageForAgent(agentId: string, costUsd: number | null): Promise<void> {
const db = await getSkillRegistryDb();
const client = db.$client as import("@libsql/client").Client;
const now = Date.now();
if (costUsd !== null) {
// Atomic update with running average calculation
await client.execute({
sql: `UPDATE agent_skills
SET task_count = task_count + 1,
avg_cost_usd = CASE
WHEN task_count = 0 THEN ?
ELSE (COALESCE(avg_cost_usd, 0) * task_count + ?) / (task_count + 1)
END,
last_used_at = ?
WHERE agent_id = ?`,
args: [costUsd, costUsd, now, agentId],
});
} else {
// Skip avg_cost_usd update when cost is unknown
await client.execute({
sql: `UPDATE agent_skills
SET task_count = task_count + 1,
last_used_at = ?
WHERE agent_id = ?`,
args: [now, agentId],
});
}
},
};
}

View file

@ -0,0 +1,91 @@
import { sqliteTable, text, integer, real, primaryKey } from "drizzle-orm/sqlite-core";
export const skills = sqliteTable("skills", {
id: text("id").primaryKey(),
sourceId: text("source_id").notNull(),
name: text("name").notNull(),
description: text("description"),
sourceUrl: text("source_url"),
activeVersionId: text("active_version_id"),
removedAt: integer("removed_at"), // unix ms, nullable — soft-delete
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const skillVersions = sqliteTable("skill_versions", {
id: text("id").primaryKey(),
skillId: text("skill_id").notNull(),
version: text("version").notNull(),
fetchedAt: integer("fetched_at").notNull(),
cacheDir: text("cache_dir"),
});
export const skillFiles = sqliteTable("skill_files", {
id: text("id").primaryKey(),
versionId: text("version_id").notNull(),
path: text("path").notNull(),
kind: text("kind").notNull(), // "skill" | "reference" | "script" | "asset"
sizeBytes: integer("size_bytes"),
});
export const communityRatings = sqliteTable("community_ratings", {
id: text("id").primaryKey(),
skillId: text("skill_id").notNull(),
fetchedAt: integer("fetched_at").notNull(),
averageRating: real("average_rating"),
ratingCount: integer("rating_count"),
source: text("source"),
});
export const skillGroups = sqliteTable("skill_groups", {
id: text("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
isBuiltin: integer("is_builtin").notNull().default(0),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const skillGroupMembers = sqliteTable("skill_group_members", {
groupId: text("group_id").notNull(),
skillId: text("skill_id").notNull(),
addedAt: integer("added_at").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.groupId, t.skillId] }),
}));
export const skillGroupInheritance = sqliteTable("skill_group_inheritance", {
childGroupId: text("child_group_id").notNull(),
parentGroupId: text("parent_group_id").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.childGroupId, t.parentGroupId] }),
}));
export const agentSkillGroups = sqliteTable("agent_skill_groups", {
agentId: text("agent_id").notNull(),
groupId: text("group_id").notNull(),
assignedAt: integer("assigned_at").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.agentId, t.groupId] }),
}));
export const agentSkills = sqliteTable("agent_skills", {
agentId: text("agent_id").notNull(),
skillId: text("skill_id").notNull(),
installedAt: integer("installed_at").notNull(),
taskCount: integer("task_count").notNull().default(0),
avgCostUsd: real("avg_cost_usd"),
lastUsedAt: integer("last_used_at"),
}, (t) => ({
pk: primaryKey({ columns: [t.agentId, t.skillId] }),
}));
export const personalRatings = sqliteTable("personal_ratings", {
id: text("id").primaryKey(),
skillId: text("skill_id").notNull(),
versionId: text("version_id"),
stars: integer("stars").notNull(), // 1-5
note: text("note"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -0,0 +1,202 @@
import { eq, isNull, and, desc, sql } from "drizzle-orm";
import { cp, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { getSkillRegistryDb } from "./skill-registry-db.js";
import { skills, skillVersions, skillFiles, communityRatings, agentSkills } from "./skill-registry-schema.js";
import { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js";
import { resolveSkillCacheDir } from "../home-paths.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SkillRow = typeof skills.$inferSelect;
type VersionRow = typeof skillVersions.$inferSelect;
/** Extended skill list item with community rating and usage stats from JOINs */
type SkillListItem = SkillRow & {
averageRating: number | null;
ratingCount: number | null;
taskCount: number | null;
avgCostUsd: number | null;
lastUsedAt: number | null;
};
type InstallResult =
| { type: "installed"; skillId: string; versionId: string; targetDir: string }
| { type: "pending_plugin_install"; command: string; skillId: string; versionId: string };
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Skill registry service factory.
* Manages its own libSQL database (does not accept a Postgres db param).
* Use `getSkillRegistryDb()` for all persistence.
*/
export function skillRegistryService() {
return {
async list(opts?: { includeRemoved?: boolean }): Promise<SkillListItem[]> {
const db = await getSkillRegistryDb();
const query = db
.select({
// All skills columns
id: skills.id,
sourceId: skills.sourceId,
name: skills.name,
description: skills.description,
sourceUrl: skills.sourceUrl,
activeVersionId: skills.activeVersionId,
removedAt: skills.removedAt,
createdAt: skills.createdAt,
updatedAt: skills.updatedAt,
// Community rating fields from LEFT JOIN
averageRating: communityRatings.averageRating,
ratingCount: communityRatings.ratingCount,
// Aggregated usage stats across all agents
taskCount: sql<number | null>`SUM(${agentSkills.taskCount})`,
avgCostUsd: sql<number | null>`AVG(${agentSkills.avgCostUsd})`,
lastUsedAt: sql<number | null>`MAX(${agentSkills.lastUsedAt})`,
})
.from(skills)
.leftJoin(communityRatings, eq(communityRatings.skillId, skills.id))
.leftJoin(agentSkills, eq(agentSkills.skillId, skills.id))
.groupBy(skills.id, communityRatings.id);
if (opts?.includeRemoved) {
return query as Promise<SkillListItem[]>;
}
return query.where(isNull(skills.removedAt)) as Promise<SkillListItem[]>;
},
async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise<SkillListItem | undefined> {
const db = await getSkillRegistryDb();
const conditions: Parameters<typeof and>[0][] = [eq(skills.id, skillId)];
if (!opts?.includeRemoved) conditions.push(isNull(skills.removedAt));
const rows = await db
.select({
id: skills.id,
sourceId: skills.sourceId,
name: skills.name,
description: skills.description,
sourceUrl: skills.sourceUrl,
activeVersionId: skills.activeVersionId,
removedAt: skills.removedAt,
createdAt: skills.createdAt,
updatedAt: skills.updatedAt,
averageRating: communityRatings.averageRating,
ratingCount: communityRatings.ratingCount,
taskCount: sql<number | null>`SUM(${agentSkills.taskCount})`,
avgCostUsd: sql<number | null>`AVG(${agentSkills.avgCostUsd})`,
lastUsedAt: sql<number | null>`MAX(${agentSkills.lastUsedAt})`,
})
.from(skills)
.leftJoin(communityRatings, eq(communityRatings.skillId, skills.id))
.leftJoin(agentSkills, eq(agentSkills.skillId, skills.id))
.groupBy(skills.id, communityRatings.id)
.where(and(...conditions));
return rows[0] as SkillListItem | undefined;
},
async getVersions(skillId: string): Promise<VersionRow[]> {
const db = await getSkillRegistryDb();
return db.select().from(skillVersions).where(eq(skillVersions.skillId, skillId));
},
async install(skillId: string, agentSkillsDir: string): Promise<InstallResult> {
const db = await getSkillRegistryDb();
const skill = await this.getById(skillId);
if (!skill) throw new Error(`Skill not found: ${skillId}`);
// Get latest version (most recently fetched)
const versions = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.skillId, skillId))
.orderBy(desc(skillVersions.fetchedAt));
const latest = versions[0];
if (!latest) throw new Error(`No versions found for skill: ${skillId}`);
// Check if this is a marketplace plugin — identified by any file having kind="plugin"
const files = await db
.select()
.from(skillFiles)
.where(eq(skillFiles.versionId, latest.id));
const isPlugin = files.some((f) => f.kind === "plugin");
if (isPlugin) {
// Return pending plugin install command instead of copying files
const slug = skillId.split("/").pop() ?? skillId;
return {
type: "pending_plugin_install" as const,
command: `/plugin install ${slug}@marketplace`,
skillId,
versionId: latest.id,
};
}
// Copy cached files to agent skills dir
const cacheDir = latest.cacheDir ?? resolveSkillCacheDir(skillId, latest.id);
const slug = skillId.split("/").pop() ?? skillId;
const targetDir = path.join(agentSkillsDir, slug);
await mkdir(targetDir, { recursive: true });
await cp(cacheDir, targetDir, { recursive: true });
// Update active version
await db
.update(skills)
.set({ activeVersionId: latest.id, updatedAt: Date.now() })
.where(eq(skills.id, skillId));
return {
type: "installed" as const,
skillId,
versionId: latest.id,
targetDir,
};
},
async uninstall(skillId: string): Promise<void> {
const db = await getSkillRegistryDb();
await db
.update(skills)
.set({ removedAt: Date.now(), updatedAt: Date.now() })
.where(eq(skills.id, skillId));
},
async rollback(skillId: string, versionId: string, agentSkillsDir: string): Promise<void> {
const db = await getSkillRegistryDb();
const versionRows = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.id, versionId));
const version = versionRows[0];
if (!version) throw new Error(`Version not found: ${versionId}`);
const cacheDir = version.cacheDir ?? resolveSkillCacheDir(skillId, versionId);
const slug = skillId.split("/").pop() ?? skillId;
const targetDir = path.join(agentSkillsDir, slug);
// Remove current files, restore from cache
await rm(targetDir, { recursive: true, force: true });
await mkdir(targetDir, { recursive: true });
await cp(cacheDir, targetDir, { recursive: true });
// Update active version to the rolled-back version
await db
.update(skills)
.set({ activeVersionId: versionId, updatedAt: Date.now() })
.where(eq(skills.id, skillId));
},
async fetchAll(
sources?: SkillSourceConfig[],
): Promise<{ fetched: number; errors: string[] }> {
return fetchAllSources(sources);
},
};
}

View file

@ -30,7 +30,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lexical/link": "0.35.0",
"lexical": "0.35.0",
"@mdxeditor/editor": "^3.52.4",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@ -48,6 +47,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"diff": "^8.0.4",
"lexical": "0.35.0",
"lucide-react": "^0.574.0",
"mermaid": "^11.12.0",
"radix-ui": "^1.4.3",
@ -60,6 +61,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@types/diff": "^8.0.0",
"@types/node": "^25.2.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",

View file

@ -25,7 +25,8 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanySkills } from "./pages/CompanySkills";
import { SkillBrowser } from "./pages/SkillBrowser";
import { SkillDetail } from "./pages/SkillDetail";
import { CompanyExport } from "./pages/CompanyExport";
import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
@ -126,7 +127,8 @@ function boardRoutes() {
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="skills" element={<SkillBrowser />} />
<Route path="skills/detail/:skillId" element={<SkillDetail />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />

93
ui/src/api/skillGroups.ts Normal file
View file

@ -0,0 +1,93 @@
import { api, ApiError } from "./client";
export type SkillGroupRow = {
id: string;
name: string;
description: string | null;
isBuiltin: number;
createdAt: number;
updatedAt: number;
};
export type GroupMemberRow = {
groupId: string;
skillId: string;
addedAt: number;
};
export type AssignResult = {
installed: string[];
skipped: string[];
pendingPlugin: string[];
};
export type GroupExport = {
version: "1";
group: {
id: string;
name: string;
description: string | null;
members: string[];
parents: string[];
};
};
export const skillGroupsApi = {
listGroups: () => api.get<SkillGroupRow[]>("/skill-registry/groups"),
getGroup: (groupId: string) =>
api.get<SkillGroupRow>(`/skill-registry/groups/${groupId}`),
createGroup: (input: { name: string; description?: string }) =>
api.post<SkillGroupRow>("/skill-registry/groups", input),
updateGroup: (groupId: string, patch: { name?: string; description?: string }) =>
api.patch<SkillGroupRow>(`/skill-registry/groups/${groupId}`, patch),
deleteGroup: (groupId: string) =>
api.delete<void>(`/skill-registry/groups/${groupId}`),
listMembers: (groupId: string) =>
api.get<GroupMemberRow[]>(`/skill-registry/groups/${groupId}/members`),
addMember: (groupId: string, skillId: string) =>
api.post<{ ok: boolean }>(`/skill-registry/groups/${groupId}/members`, { skillId }),
removeMember: (groupId: string, skillId: string) =>
api.delete<void>(`/skill-registry/groups/${groupId}/members/${skillId}`),
exportGroup: (groupId: string) =>
api.get<GroupExport>(`/skill-registry/groups/${groupId}/export`),
importGroup: (data: GroupExport) =>
api.post<SkillGroupRow>("/skill-registry/groups/import", data),
listAgentGroups: (agentId: string) =>
api.get<SkillGroupRow[]>(`/skill-registry/agents/${agentId}/groups`),
assignGroup: (agentId: string, groupId: string, agentSkillsDir: string) =>
api.post<AssignResult>(`/skill-registry/agents/${agentId}/groups`, {
groupId,
agentSkillsDir,
}),
removeGroup: async (agentId: string, groupId: string, agentSkillsDir: string): Promise<void> => {
const res = await fetch(`/api/skill-registry/agents/${agentId}/groups/${groupId}`, {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentSkillsDir }),
});
if (!res.ok && res.status !== 204) {
const errorBody = await res.json().catch(() => null);
throw new ApiError(
(errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`,
res.status,
errorBody,
);
}
},
listAgentSkills: (agentId: string) =>
api.get<string[]>(`/skill-registry/agents/${agentId}/skills`),
};

View file

@ -0,0 +1,63 @@
import { api } from "./client";
export type SkillListItem = {
id: string;
name: string;
description: string | null;
sourceId: string;
category: string | null;
activeVersionId: string | null;
removedAt: number | null;
averageRating: number | null;
ratingCount: number | null;
taskCount: number | null;
avgCostUsd: number | null;
lastUsedAt: number | null;
};
export type PersonalRating = {
id: string;
skillId: string;
versionId: string | null;
stars: number;
note: string | null;
createdAt: number;
updatedAt: number;
};
export type SkillVersion = {
id: string;
skillId: string;
version: string;
fetchedAt: number;
cacheDir: string | null;
};
function skillPath(skillId: string): string {
const [sourceId, ...slugParts] = skillId.split("/");
const slug = slugParts.join("/");
return `/skill-registry/skills/${sourceId}/${slug}`;
}
export const skillRegistryApi = {
list: (opts?: { includeRemoved?: boolean }) =>
api.get<SkillListItem[]>(
`/skill-registry/skills${opts?.includeRemoved ? "?includeRemoved=true" : ""}`,
),
getById: (skillId: string) =>
api.get<SkillListItem>(skillPath(skillId)),
getVersions: (skillId: string) =>
api.get<SkillVersion[]>(`${skillPath(skillId)}/versions`),
fetch: () =>
api.post<{ fetched: number; errors: string[] }>("/skill-registry/fetch", {}),
install: (skillId: string, agentSkillsDir: string) =>
api.post(`${skillPath(skillId)}/install`, { agentSkillsDir }),
rollback: (skillId: string, versionId: string, agentSkillsDir: string) =>
api.post(`${skillPath(skillId)}/rollback`, { versionId, agentSkillsDir }),
remove: (skillId: string) =>
api.delete(skillPath(skillId)),
getRatings: (skillId: string) =>
api.get<PersonalRating[]>(`${skillPath(skillId)}/ratings`),
addRating: (skillId: string, body: { stars: number; versionId?: string; note?: string }) =>
api.post(`${skillPath(skillId)}/ratings`, body),
};

View file

@ -0,0 +1,82 @@
import { X, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type GroupBadgeProps = {
name: string;
isBuiltin: boolean;
skillCount?: number;
description?: string | null;
onRemove?: () => void;
removing?: boolean;
};
export function GroupBadge({
name,
isBuiltin,
skillCount,
description: _description,
onRemove,
removing = false,
}: GroupBadgeProps) {
const tooltipText = isBuiltin
? `${name} · built-in${skillCount != null ? ` · ${skillCount} skills` : ""}`
: `${name}${skillCount != null ? ` · ${skillCount} skills` : ""}`;
if (isBuiltin) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className={cn(
"cursor-default select-none text-sm font-semibold",
"hover:bg-accent/30",
"focus-visible:ring-ring focus-visible:ring-[3px]",
)}
>
{name}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(
"cursor-default select-none gap-1 text-sm font-semibold",
"hover:bg-accent/50",
"focus-visible:ring-ring focus-visible:ring-[3px]",
)}
>
{name}
{onRemove && (
<button
type="button"
aria-label={`Remove ${name}`}
disabled={removing}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{removing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
</button>
)}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
);
}

View file

@ -4,6 +4,7 @@
// redirected here at build time; the original file is preserved for upstream rebase.
import { useState, useEffect } from "react";
import { createPortal } from "react-dom"; // [nexus] use raw portal, not radix DialogPortal
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { VOCAB } from "@paperclipai/branding";
import { useQueryClient } from "@tanstack/react-query";
@ -13,7 +14,6 @@ import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "../lib/utils";
@ -108,6 +108,16 @@ export function OnboardingWizard() {
runtimeConfig,
});
// Step 4: Create Generalist agent (non-code work: copy, research, docs)
await agentsApi.create(company.id, {
name: "Generalist",
role: "general",
adapterType: "claude_local",
adapterConfig,
runtimeConfig,
metadata: { pendingSkillGroups: ["Creative"] },
});
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(company.id),
});
@ -123,8 +133,7 @@ export function OnboardingWizard() {
if (!effectiveOnboardingOpen) return null;
return (
<DialogPortal>
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
@ -146,7 +155,7 @@ export function OnboardingWizard() {
</h1>
<p className="text-sm text-muted-foreground">
Choose a project root directory. {VOCAB.appName} will set up a{" "}
{VOCAB.ceo.toLowerCase()} and engineer to start working.
{VOCAB.ceo.toLowerCase()}, engineer, and generalist to start working.
</p>
</div>
@ -213,7 +222,7 @@ export function OnboardingWizard() {
</Button>
</form>
</div>
</div>
</DialogPortal>
</div>,
document.body // [nexus] portal to body, not radix DialogPortal
);
}

View file

@ -0,0 +1,81 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { SkillCard } from "./SkillCard";
import type { SkillListItem } from "../api/skillRegistry";
// Stub @/lib/router Link as an <a> tag for SSR
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; [key: string]: unknown }) =>
<a href={to as string} {...props}>{children}</a>,
}));
const mockSkill: SkillListItem = {
id: "test-source/test-skill",
name: "Test Skill",
description: "A test skill for unit testing",
sourceId: "test-source",
category: "testing",
activeVersionId: null,
removedAt: null,
averageRating: 4.2,
ratingCount: 10,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
};
describe("SkillCard", () => {
it("renders skill name as a link", () => {
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
expect(html).toContain("Test Skill");
expect(html).toContain("skills/detail/");
});
it("renders source badge", () => {
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
expect(html).toContain("test-source");
});
it("renders star rating when averageRating is non-null", () => {
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
expect(html).toContain("4.2");
});
it("shows Install skill button when not installed", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} onInstall={() => {}} />,
);
expect(html).toContain("Install skill");
});
it("shows Update skill button when installed with update", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} isInstalled hasUpdate onUpdate={() => {}} />,
);
expect(html).toContain("Update skill");
});
it("shows update badge when hasUpdate is true", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} isInstalled hasUpdate onUpdate={() => {}} />,
);
expect(html).toContain("Update");
expect(html).toContain("amber");
});
it("shows check icon when installed without update", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} isInstalled />,
);
expect(html).toContain("Installed");
});
it("shows loading state on install button", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} onInstall={() => {}} isLoading />,
);
expect(html).toContain("Installing");
});
});

View file

@ -0,0 +1,127 @@
import { Check, Download, RotateCcw, Star } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { SkillListItem } from "@/api/skillRegistry";
// TODO: hasUpdate detection requires backend enhancement — SkillListItem needs
// a hasUpdate field or the UI needs to compare activeVersionId against latest version.
// For now, hasUpdate is always passed as false from parent components.
export interface SkillCardProps {
skill: SkillListItem;
isInstalled?: boolean;
hasUpdate?: boolean;
onInstall?: () => void;
onUpdate?: () => void;
onRollback?: () => void;
onUninstall?: () => void;
isLoading?: boolean;
className?: string;
}
export function SkillCard({
skill,
isInstalled = false,
hasUpdate = false,
onInstall,
onUpdate,
onRollback,
onUninstall,
isLoading = false,
className,
}: SkillCardProps) {
return (
<Card className={cn("flex flex-col", className)}>
<CardContent className="p-4 flex flex-col gap-2">
{/* Row 1: name link (primary visual anchor) + update badge */}
<div className="flex items-start justify-between gap-2">
<Link
to={`/skills/detail/${encodeURIComponent(skill.id)}`}
className="text-sm font-medium hover:underline"
>
{skill.name}
</Link>
{hasUpdate && (
<Badge
variant="outline"
className="text-xs text-amber-600 border-amber-500 shrink-0"
aria-label="Update available"
>
Update
</Badge>
)}
</div>
{/* Row 2: description (2-line clamp) */}
{skill.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{skill.description}</p>
)}
{/* Row 3: source badge + rating + actions (push right) */}
<div className="flex items-center gap-2 mt-auto pt-2">
<Badge variant="secondary" className="text-xs">{skill.sourceId}</Badge>
{skill.averageRating != null && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
{skill.averageRating.toFixed(1)}
</span>
)}
<div className="ml-auto flex gap-1">
{isInstalled && onRollback && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
onClick={onRollback}
disabled={isLoading}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Rollback</TooltipContent>
</Tooltip>
)}
{!isInstalled && (
<Button
size="sm"
variant="outline"
onClick={onInstall}
disabled={isLoading}
>
<Download className="h-3.5 w-3.5 mr-1" />
{isLoading ? "Installing\u2026" : "Install skill"}
</Button>
)}
{isInstalled && hasUpdate && (
<Button
size="sm"
variant="outline"
onClick={onUpdate}
disabled={isLoading}
>
{isLoading ? "Updating\u2026" : "Update skill"}
</Button>
)}
{isInstalled && !hasUpdate && (
<Button
size="icon-sm"
variant="ghost"
disabled
title="Installed"
>
<Check className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,65 @@
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface StarRatingProps {
value: number;
onChange?: (v: number) => void;
readonly?: boolean;
size?: "sm" | "md";
}
export function StarRating({
value,
onChange,
readonly = false,
size = "md",
}: StarRatingProps) {
const iconClass = size === "sm" ? "h-3.5 w-3.5" : "h-5 w-5";
const stars = (
<span className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= value;
return (
<button
key={star}
type="button"
aria-label={`Rate ${star} star${star > 1 ? "s" : ""}`}
disabled={readonly}
onClick={() => onChange?.(star)}
className={cn(
"focus-visible:ring-ring focus-visible:ring-[3px] rounded outline-none",
"hover:bg-accent/10",
readonly ? "cursor-default" : "cursor-pointer",
)}
>
<Star
className={cn(
iconClass,
filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
</button>
);
})}
</span>
);
if (readonly) {
return (
<Tooltip>
<TooltipTrigger asChild>
{stars}
</TooltipTrigger>
<TooltipContent>{value} out of 5 stars</TooltipContent>
</Tooltip>
);
}
return stars;
}

View file

@ -127,6 +127,19 @@ export const queryKeys = {
skills: {
available: ["skills", "available"] as const,
},
skillRegistry: {
list: ["skill-registry", "skills"] as const,
detail: (skillId: string) => ["skill-registry", "skills", skillId] as const,
versions: (skillId: string) => ["skill-registry", "skills", skillId, "versions"] as const,
},
skillGroups: {
list: ["skill-groups"] as const,
detail: (groupId: string) => ["skill-groups", groupId] as const,
members: (groupId: string) => ["skill-groups", groupId, "members"] as const,
agentGroups: (agentId: string) => ["skill-groups", "agent", agentId] as const,
agentSkills: (agentId: string) => ["skill-groups", "agent", agentId, "skills"] as const,
agentEffective: (agentId: string) => ["skill-groups", "agent", agentId, "effective"] as const,
},
plugins: {
all: ["plugins"] as const,
examples: ["plugins", "examples"] as const,

View file

@ -9,6 +9,7 @@ import {
type AgentPermissionUpdate,
} from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { skillGroupsApi, type SkillGroupRow } from "../api/skillGroups";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
@ -75,6 +76,17 @@ import {
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { GroupBadge } from "../components/GroupBadge";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import {
@ -2381,6 +2393,63 @@ function AgentSkillsTab({
},
});
// Skill groups queries
const agentGroupsQuery = useQuery({
queryKey: queryKeys.skillGroups.agentGroups(agent.id),
queryFn: () => skillGroupsApi.listAgentGroups(agent.id),
});
const allGroupsQuery = useQuery({
queryKey: queryKeys.skillGroups.list,
queryFn: () => skillGroupsApi.listGroups(),
});
const agentEffectiveSkillsQuery = useQuery({
queryKey: queryKeys.skillGroups.agentSkills(agent.id),
queryFn: () => skillGroupsApi.listAgentSkills(agent.id),
});
// Group dialog state
const [addGroupOpen, setAddGroupOpen] = useState(false);
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [removeGroupConfirm, setRemoveGroupConfirm] = useState<SkillGroupRow | null>(null);
const [groupSearch, setGroupSearch] = useState("");
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupDesc] = useState("");
const [effectiveOpen, setEffectiveOpen] = useState(false);
// Group mutations
const assignGroupMut = useMutation({
mutationFn: ({ groupId }: { groupId: string }) =>
skillGroupsApi.assignGroup(agent.id, groupId, ""),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
setAddGroupOpen(false);
},
});
const removeGroupMut = useMutation({
mutationFn: ({ groupId }: { groupId: string }) =>
skillGroupsApi.removeGroup(agent.id, groupId, ""),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
setRemoveGroupConfirm(null);
},
});
const createGroupMut = useMutation({
mutationFn: () =>
skillGroupsApi.createGroup({ name: newGroupName, description: newGroupDesc || undefined }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.list });
setCreateGroupOpen(false);
setNewGroupName("");
setNewGroupDesc("");
},
});
useEffect(() => {
setSkillDraft([]);
setLastSavedSkills([]);
@ -2554,6 +2623,289 @@ function AgentSkillsTab({
</div>
) : null}
{/* ---- Assigned Groups Section ---- */}
<Separator />
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Assigned Groups
</h3>
{agentGroupsQuery.isLoading ? (
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-28 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
) : agentGroupsQuery.data?.length === 0 ? (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">No groups assigned</p>
<p className="text-xs text-muted-foreground">
Add a skill group to install a bundle of skills for this agent.
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<TooltipProvider>
{(agentGroupsQuery.data ?? []).map((group) => (
<GroupBadge
key={group.id}
name={group.name}
isBuiltin={group.isBuiltin === 1}
onRemove={
group.isBuiltin === 1
? undefined
: () => setRemoveGroupConfirm(group)
}
removing={
removeGroupMut.isPending && removeGroupConfirm?.id === group.id
}
/>
))}
</TooltipProvider>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setAddGroupOpen(true)}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Group
</Button>
{agentGroupsQuery.isError && (
<p className="text-sm text-destructive">Failed to load groups. Try again.</p>
)}
</div>
<Separator />
{/* ---- Combined Effective Skills Section ---- */}
<div className="space-y-3">
<Collapsible open={effectiveOpen} onOpenChange={setEffectiveOpen}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Combined Effective Skills
</h3>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
{effectiveOpen
? "Hide skills"
: `Show ${agentEffectiveSkillsQuery.data?.length ?? 0} skills`}
<ChevronDown
className={cn("ml-1 h-3.5 w-3.5 transition-transform", effectiveOpen && "rotate-180")}
/>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
{agentEffectiveSkillsQuery.isLoading ? (
<div className="space-y-1.5 pt-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-36" />
</div>
) : agentEffectiveSkillsQuery.data?.length === 0 ? (
<p className="pt-2 text-sm text-muted-foreground">
No skills in assigned groups. Add skills to the group definitions first.
</p>
) : (
<ScrollArea className="max-h-[300px] pt-2">
<ul className="space-y-1">
{(agentEffectiveSkillsQuery.data ?? []).map((skillId) => (
<li key={skillId} className="text-sm text-muted-foreground font-mono">
{skillId}
</li>
))}
</ul>
</ScrollArea>
)}
</CollapsibleContent>
</Collapsible>
</div>
<Separator />
{/* ---- Additional Individual Skills ---- */}
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Additional Individual Skills
</h3>
{/* ---- Add Group Dialog ---- */}
<Dialog open={addGroupOpen} onOpenChange={setAddGroupOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Skill Group</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="Search groups..."
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="h-8 text-sm"
/>
<div className="max-h-64 overflow-y-auto divide-y divide-border rounded-md border border-border">
{(() => {
const assignedIds = new Set((agentGroupsQuery.data ?? []).map((g) => g.id));
const available = (allGroupsQuery.data ?? []).filter(
(g) =>
!assignedIds.has(g.id) &&
g.name.toLowerCase().includes(groupSearch.toLowerCase()),
);
if (available.length === 0) {
return (
<p className="px-3 py-4 text-sm text-muted-foreground">
{groupSearch ? "No groups match your search." : "No groups available to add."}
</p>
);
}
return available.map((group) => (
<div key={group.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{group.name}</p>
{group.isBuiltin === 1 && (
<p className="text-xs text-muted-foreground">built-in</p>
)}
{group.description && (
<p className="text-xs text-muted-foreground truncate">{group.description}</p>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={assignGroupMut.isPending}
onClick={() => assignGroupMut.mutate({ groupId: group.id })}
className="shrink-0 h-7 text-xs"
>
{assignGroupMut.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Assign group"
)}
</Button>
</div>
));
})()}
</div>
{assignGroupMut.isError && (
<p className="text-sm text-destructive">
Failed to assign group. Check the server logs for details.
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => {
setAddGroupOpen(false);
setCreateGroupOpen(true);
}}
>
New Group
</Button>
<Button variant="outline" onClick={() => setAddGroupOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ---- Create Group Dialog ---- */}
<Dialog open={createGroupOpen} onOpenChange={setCreateGroupOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Skill Group</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-semibold" htmlFor="new-group-name">
Name
</label>
<Input
id="new-group-name"
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold" htmlFor="new-group-desc">
Description
</label>
<Textarea
id="new-group-desc"
placeholder="Optional description"
value={newGroupDesc}
onChange={(e) => setNewGroupDesc(e.target.value)}
rows={2}
className="text-sm resize-none"
/>
</div>
{createGroupMut.isError && (
<p className="text-sm text-destructive">
Failed to create group. Check the server logs for details.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateGroupOpen(false)}>
Cancel
</Button>
<Button
disabled={!newGroupName.trim() || createGroupMut.isPending}
onClick={() => createGroupMut.mutate()}
>
{createGroupMut.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : null}
Create Group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ---- Remove Group Confirmation Dialog ---- */}
<Dialog
open={removeGroupConfirm !== null}
onOpenChange={(open) => { if (!open) setRemoveGroupConfirm(null); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Remove group from agent</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Removing{" "}
<span className="font-semibold text-foreground">{removeGroupConfirm?.name}</span>{" "}
will uninstall skills that are not used by any other assigned group.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveGroupConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={removeGroupMut.isPending}
onClick={() => {
if (removeGroupConfirm) {
removeGroupMut.mutate({ groupId: removeGroupConfirm.id });
}
}}
>
{removeGroupMut.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : null}
Remove group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isLoading ? (
<PageSkeleton variant="list" />
) : (

View file

@ -53,6 +53,7 @@ import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import {
Select,
@ -124,6 +125,9 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
import { InlineEditor } from "@/components/InlineEditor";
import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { SkillCard } from "@/components/SkillCard";
import { GroupBadge } from "@/components/GroupBadge";
import { StarRating } from "@/components/StarRating";
/* ------------------------------------------------------------------ */
/* Section wrapper */
@ -188,6 +192,7 @@ export function DesignGuide() {
{ key: "status", label: "Status", value: "Active" },
{ key: "priority", label: "Priority", value: "High" },
]);
const [starValue, setStarValue] = useState(3);
return (
<div className="space-y-10 max-w-4xl">
@ -1254,6 +1259,116 @@ export function DesignGuide() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* SKILL CARD */}
{/* ============================================================ */}
<Section title="Skill Card">
<SubSection title="Variants">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Default (uninstalled) */}
<SkillCard
skill={{
id: "anthropic/code-review",
name: "Code Review",
description: "Automated code review with style, security, and correctness checks.",
sourceId: "anthropic",
category: "engineering",
activeVersionId: null,
removedAt: null,
averageRating: 4.7,
ratingCount: 42,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
onInstall={() => {}}
/>
{/* Installed (no update) */}
<SkillCard
skill={{
id: "anthropic/planning",
name: "Planning",
description: "Breaks down complex features into actionable tasks.",
sourceId: "anthropic",
category: "productivity",
activeVersionId: "v1.0.0",
removedAt: null,
averageRating: null,
ratingCount: null,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
isInstalled
onRollback={() => {}}
onUninstall={() => {}}
/>
{/* Installed + update available */}
<SkillCard
skill={{
id: "community/docs-writer",
name: "Docs Writer",
description: "Generates clear technical documentation from code and comments.",
sourceId: "community",
category: "docs",
activeVersionId: "v1.1.0",
removedAt: null,
averageRating: 3.9,
ratingCount: 15,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
isInstalled
hasUpdate
onUpdate={() => {}}
onRollback={() => {}}
onUninstall={() => {}}
/>
{/* Loading (installing) */}
<SkillCard
skill={{
id: "anthropic-official/bash",
name: "Bash Scripting",
description: "Advanced bash scripting patterns for automation, file manipulation, and system administration tasks.",
sourceId: "anthropic-official",
category: "engineering",
activeVersionId: null,
removedAt: null,
averageRating: 4.2,
ratingCount: 38,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
onInstall={() => {}}
isLoading
/>
{/* Loading (updating) */}
<SkillCard
skill={{
id: "community/react-patterns",
name: "React Patterns",
description: "Common React patterns and best practices for component design.",
sourceId: "community",
category: "frontend",
activeVersionId: "anthropic-official/bash@abc123",
removedAt: null,
averageRating: null,
ratingCount: null,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
isInstalled
hasUpdate
onUpdate={() => {}}
isLoading
/>
</div>
</SubSection>
</Section>
{/* ============================================================ */}
{/* SEPARATOR */}
{/* ============================================================ */}
@ -1304,6 +1419,92 @@ export function DesignGuide() {
</div>
</Section>
{/* ============================================================ */}
{/* SKILL GROUPS */}
{/* ============================================================ */}
<Section title="Skill Groups">
<SubSection title="GroupBadge — built-in (no dismiss)">
<TooltipProvider>
<div className="flex flex-wrap gap-2">
<GroupBadge name="PM Essentials" isBuiltin={true} skillCount={5} />
<GroupBadge name="Engineer Core" isBuiltin={true} skillCount={8} />
</div>
</TooltipProvider>
</SubSection>
<SubSection title="GroupBadge — custom (with dismiss)">
<TooltipProvider>
<div className="flex flex-wrap gap-2">
<GroupBadge
name="Creative"
isBuiltin={false}
skillCount={3}
onRemove={() => undefined}
/>
<GroupBadge
name="Frontend Tools"
isBuiltin={false}
skillCount={6}
onRemove={() => undefined}
/>
</div>
</TooltipProvider>
</SubSection>
<SubSection title="GroupBadge — loading state (removing)">
<TooltipProvider>
<div className="flex flex-wrap gap-2">
<GroupBadge
name="Creative"
isBuiltin={false}
skillCount={3}
onRemove={() => undefined}
removing={true}
/>
</div>
</TooltipProvider>
</SubSection>
</Section>
{/* ============================================================ */}
{/* RATING SYSTEM */}
{/* ============================================================ */}
<Section title="Rating System">
<SubSection title="Interactive — no selection">
<TooltipProvider>
<StarRating value={0} onChange={(v) => console.log("rated", v)} />
</TooltipProvider>
</SubSection>
<SubSection title="Interactive — partial selection (controlled)">
<TooltipProvider>
<StarRating value={starValue} onChange={setStarValue} />
</TooltipProvider>
<p className="text-xs text-muted-foreground mt-1">Current value: {starValue}</p>
</SubSection>
<SubSection title="Interactive — full selection">
<TooltipProvider>
<StarRating value={5} onChange={(v) => console.log("rated", v)} />
</TooltipProvider>
</SubSection>
<SubSection title="Read-only display (value=4)">
<TooltipProvider>
<StarRating value={4} readonly />
</TooltipProvider>
</SubSection>
<SubSection title="Size comparison — sm vs md">
<TooltipProvider>
<div className="flex items-center gap-6">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">sm</span>
<StarRating value={3} readonly size="sm" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">md</span>
<StarRating value={3} readonly size="md" />
</div>
</div>
</TooltipProvider>
</SubSection>
</Section>
{/* ============================================================ */}
{/* KEYBOARD SHORTCUTS */}
{/* ============================================================ */}

View file

@ -0,0 +1,63 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// Mock all external dependencies for SSR
vi.mock("react-router-dom", () => ({
Link: ({ to, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children?: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
useNavigate: () => () => {},
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children?: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
useNavigate: () => () => {},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [], isLoading: false, isError: false }),
useQueryClient: () => ({ invalidateQueries: () => {} }),
useMutation: () => ({ mutate: () => {}, isPending: false }),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: () => {} }),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompany: { id: "test", name: "Test Workspace", slug: "TST" } }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: () => {} }),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
import { SkillBrowser } from "./SkillBrowser";
describe("SkillBrowser", () => {
it("renders page title", () => {
const html = renderToStaticMarkup(<SkillBrowser />);
expect(html).toContain("Skills");
});
it("renders Browse tab content", () => {
const html = renderToStaticMarkup(<SkillBrowser />);
expect(html).toContain("Search skills");
});
it("renders Refresh registry button", () => {
const html = renderToStaticMarkup(<SkillBrowser />);
expect(html).toContain("Refresh registry");
});
it("renders tab labels", () => {
const html = renderToStaticMarkup(<SkillBrowser />);
expect(html).toContain("Browse");
expect(html).toContain("Installed");
expect(html).toContain("Trending");
});
});

View file

@ -0,0 +1,563 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Download,
Search,
TrendingUp,
} from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { skillRegistryApi } from "@/api/skillRegistry";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SkillCard } from "@/components/SkillCard";
import { EmptyState } from "@/components/EmptyState";
import { FilterBar } from "@/components/FilterBar";
import type { FilterValue } from "@/components/FilterBar";
import { PageTabBar } from "@/components/PageTabBar";
import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { cn } from "@/lib/utils";
type SortBy = "rating" | "name" | "recent";
export function SkillBrowser() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
// Tab state
const [tab, setTab] = useState<"browse" | "installed" | "trending">("browse");
// Browse tab filter state
const [search, setSearch] = useState("");
const [sourceFilter, setSourceFilter] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortBy>("rating");
// Dialog state
const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null);
const [agentSkillsDir, setAgentSkillsDir] = useState("");
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Workspace", href: "/dashboard" },
{ label: "Skills" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
// Data fetching
const { data: skills = [], isLoading, isError } = useQuery({
queryKey: queryKeys.skillRegistry.list,
queryFn: () => skillRegistryApi.list(),
});
const { data: agents = [] } = useQuery({
queryKey: queryKeys.agents.list(selectedCompany?.id ?? ""),
queryFn: () => agentsApi.list(selectedCompany?.id ?? ""),
enabled: !!selectedCompany?.id,
});
// Mutations
const fetchMutation = useMutation({
mutationFn: () => skillRegistryApi.fetch(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
pushToast({ title: "Registry refreshed", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Registry refresh failed", body: err.message, tone: "error" });
},
});
const installMutation = useMutation({
mutationFn: (params: { skillId: string; agentSkillsDir: string }) =>
skillRegistryApi.install(params.skillId, params.agentSkillsDir),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
pushToast({ title: "Skill installed", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Install failed", body: err.message, tone: "error" });
},
});
const updateMutation = useMutation({
mutationFn: (params: { skillId: string; agentSkillsDir: string }) =>
skillRegistryApi.install(params.skillId, params.agentSkillsDir),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
pushToast({ title: "Skill updated", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Update failed", body: err.message, tone: "error" });
},
});
const rollbackMutation = useMutation({
mutationFn: (params: { skillId: string; versionId: string; agentSkillsDir: string }) =>
skillRegistryApi.rollback(params.skillId, params.versionId, params.agentSkillsDir),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
pushToast({ title: "Rolled back to previous version", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Rollback failed", body: err.message, tone: "error" });
},
});
const removeMutation = useMutation({
mutationFn: (skillId: string) => skillRegistryApi.remove(skillId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
pushToast({ title: "Skill uninstalled", tone: "success" });
},
});
// Derived data for filters
const sources = useMemo(
() => [...new Set(skills.map((s) => s.sourceId))].sort(),
[skills],
);
const categories = useMemo(
() => [...new Set(skills.map((s) => s.category).filter(Boolean) as string[])].sort(),
[skills],
);
// Browse tab filtering
const filteredSkills = useMemo(() => {
let result = skills.filter((s) => !s.removedAt);
if (search) {
result = result.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.description?.toLowerCase().includes(search.toLowerCase()),
);
}
if (sourceFilter) result = result.filter((s) => s.sourceId === sourceFilter);
if (categoryFilter) result = result.filter((s) => s.category === categoryFilter);
result = [...result];
if (sortBy === "rating") result.sort((a, b) => (b.averageRating ?? 0) - (a.averageRating ?? 0));
else if (sortBy === "name") result.sort((a, b) => a.name.localeCompare(b.name));
return result;
}, [skills, search, sourceFilter, categoryFilter, sortBy]);
// Active filter chips
const activeFilters = useMemo(() => {
const filters: FilterValue[] = [];
if (sourceFilter) filters.push({ key: "source", label: "Source", value: sourceFilter });
if (categoryFilter) filters.push({ key: "category", label: "Category", value: categoryFilter });
return filters;
}, [sourceFilter, categoryFilter]);
const handleClearFilters = () => {
setSourceFilter(null);
setCategoryFilter(null);
};
const handleRemoveFilter = (key: string) => {
if (key === "source") setSourceFilter(null);
if (key === "category") setCategoryFilter(null);
};
// Installed tab grouping
const installedGroups = useMemo(() => {
const installed = skills.filter((s) => s.activeVersionId && !s.removedAt);
if (installed.length === 0) return [];
return [{ agentId: "all", agentName: "All Agents", skills: installed }];
}, [skills]);
// Trending tab sections
const activeSkills = useMemo(() => skills.filter((s) => !s.removedAt), [skills]);
const gainingTraction = useMemo(
() => [...activeSkills].sort((a, b) => (b.ratingCount ?? 0) - (a.ratingCount ?? 0)).slice(0, 6),
[activeSkills],
);
const recentlyUpdated = useMemo(
() => [...activeSkills].sort((a, b) => b.id.localeCompare(a.id)).slice(0, 6),
[activeSkills],
);
const youMightLike = useMemo(() => {
const installedCategories = new Set(
activeSkills
.filter((s) => s.activeVersionId)
.map((s) => s.category)
.filter(Boolean),
);
if (installedCategories.size === 0)
return activeSkills.filter((s) => !s.activeVersionId).slice(0, 6);
return activeSkills
.filter((s) => !s.activeVersionId && s.category && installedCategories.has(s.category))
.slice(0, 6);
}, [activeSkills]);
const handleRollback = (skillId: string) => {
// Rollback requires a versionId — without version selection UI, use a no-op for now.
// Full rollback flow is in Plan 03 (SkillDetail page).
pushToast({ title: "Select a version from the skill detail page to roll back.", tone: "info" });
void skillId;
};
const handleInstallForAgent = (agentId: string) => {
if (!installDialog) return;
const dir = agentSkillsDir.trim() || `/agents/${agentId}/.claude/skills`;
if (installDialog.isUpdate) {
updateMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir });
} else {
installMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir });
}
setInstallDialog(null);
setAgentSkillsDir("");
};
const tabItems = [
{ value: "browse", label: "Browse" },
{ value: "installed", label: "Installed" },
{ value: "trending", label: "Trending" },
];
return (
<main className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Skills</h1>
<Button
variant="outline"
size="sm"
onClick={() => fetchMutation.mutate()}
disabled={fetchMutation.isPending}
>
{fetchMutation.isPending ? "Refreshing..." : "Refresh registry"}
</Button>
</div>
{/* Error state */}
{isError && (
<p className="text-sm text-destructive">
Failed to load skills. Check that the skill registry backend is running and try again.
</p>
)}
{/* Loading state */}
{isLoading && <PageSkeleton variant="list" />}
{/* Tabs */}
{!isLoading && (
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<PageTabBar items={tabItems} value={tab} onValueChange={(v) => setTab(v as typeof tab)} align="start" />
{/* Browse tab */}
<TabsContent value="browse" className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap gap-2">
<Input
placeholder="Search skills\u2026"
aria-label="Search skills"
className="max-w-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Select
value={sourceFilter ?? ""}
onValueChange={(v) => setSourceFilter(v || null)}
>
<SelectTrigger className="w-36">
<SelectValue placeholder="All sources" />
</SelectTrigger>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={categoryFilter ?? ""}
onValueChange={(v) => setCategoryFilter(v || null)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as SortBy)}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rating">Sort: Rating</SelectItem>
<SelectItem value="name">Sort: Name</SelectItem>
<SelectItem value="recent">Sort: Recent</SelectItem>
</SelectContent>
</Select>
</div>
{/* Active filter chips */}
<FilterBar
filters={activeFilters}
onRemove={handleRemoveFilter}
onClear={handleClearFilters}
/>
{/* Skill grid */}
{filteredSkills.length > 0 && (
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{filteredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
)}
{/* Browse empty state */}
{filteredSkills.length === 0 && (
<EmptyState
icon={Search}
message="No skills found"
action="Refresh registry"
onAction={() => fetchMutation.mutate()}
/>
)}
</TabsContent>
{/* Installed tab */}
<TabsContent value="installed" className="space-y-6">
{installedGroups.length === 0 && (
<EmptyState
icon={Download}
message="No skills installed"
action="Browse skills"
onAction={() => setTab("browse")}
/>
)}
{installedGroups.map((group) => (
<div key={group.agentId}>
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
<Identity name={group.agentName} size="sm" />
<span className="text-xs text-muted-foreground ml-1">{group.skills.length}</span>
</div>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-3")}>
{group.skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled
hasUpdate={false}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</div>
))}
</TabsContent>
{/* Trending tab */}
<TabsContent value="trending" className="space-y-8">
{skills.length === 0 && (
<EmptyState
icon={TrendingUp}
message="No trending data yet"
/>
)}
{skills.length > 0 && (
<>
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Gaining Traction
</h2>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{gainingTraction.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</section>
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recently Updated
</h2>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{recentlyUpdated.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</section>
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
You Might Like
</h2>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{youMightLike.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</section>
</>
)}
</TabsContent>
</Tabs>
)}
{/* Agent selector dialog (install / update) */}
<Dialog
open={!!installDialog}
onOpenChange={(open) => {
if (!open) {
setInstallDialog(null);
setAgentSkillsDir("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Select agent</DialogTitle>
<DialogDescription>
Choose which agent should receive this skill.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="skills-dir-input">
Agent skills directory (leave blank to use default)
</label>
<Input
id="skills-dir-input"
placeholder="/path/to/agent/.claude/skills"
value={agentSkillsDir}
onChange={(e) => setAgentSkillsDir(e.target.value)}
/>
</div>
<div className="space-y-1">
{agents.length === 0 && (
<p className="text-sm text-muted-foreground">No agents found in this workspace.</p>
)}
{agents.map((agent) => (
<Button
key={agent.id}
variant="outline"
className="w-full justify-start"
disabled={installMutation.isPending || updateMutation.isPending}
onClick={() => handleInstallForAgent(agent.id)}
>
<Identity name={agent.name} size="sm" />
</Button>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setInstallDialog(null);
setAgentSkillsDir("");
}}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Uninstall confirmation dialog */}
<Dialog
open={!!uninstallDialog}
onOpenChange={(open) => !open && setUninstallDialog(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall skill?</DialogTitle>
<DialogDescription>
This will remove the skill files from the agent&apos;s directory. You can reinstall it later.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUninstallDialog(null)}>
Keep skill
</Button>
<Button
variant="destructive"
disabled={removeMutation.isPending}
onClick={() => {
if (uninstallDialog) {
removeMutation.mutate(uninstallDialog.skillId);
setUninstallDialog(null);
}
}}
>
Yes, uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}

View file

@ -0,0 +1,81 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-router-dom", () => ({
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
useParams: () => ({ skillId: "test-source%2Ftest-skill" }),
useNavigate: () => () => {},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (
Array.isArray(queryKey) &&
(queryKey[3] === "versions" || String(queryKey[queryKey.length - 1]) === "versions")
) {
return { data: [], isLoading: false, isError: false };
}
return {
data: {
id: "test-source/test-skill",
name: "Test Skill",
description: "A test description",
sourceId: "test-source",
category: "testing",
activeVersionId: null,
removedAt: null,
averageRating: 4.2,
ratingCount: 10,
},
isLoading: false,
isError: false,
};
},
useQueryClient: () => ({ invalidateQueries: () => {} }),
useMutation: () => ({ mutate: () => {}, isPending: false }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: () => {} }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompany: { id: "test", name: "Test Workspace" } }),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: () => {} }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
import { SkillDetail } from "./SkillDetail";
describe("SkillDetail", () => {
it("renders skill name heading", () => {
const html = renderToStaticMarkup(<SkillDetail />);
expect(html).toContain("Test Skill");
});
it("renders source badge", () => {
const html = renderToStaticMarkup(<SkillDetail />);
expect(html).toContain("test-source");
});
it("renders back link to Skills", () => {
const html = renderToStaticMarkup(<SkillDetail />);
expect(html).toContain("Skills");
});
it("renders overview tab with rating", () => {
const html = renderToStaticMarkup(<SkillDetail />);
expect(html).toContain("4.2");
expect(html).toContain("Average rating");
});
});

View file

@ -0,0 +1,665 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { diffLines } from "diff";
import { ChevronLeft, Star, Download, RotateCcw, Trash2 } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { skillRegistryApi } from "@/api/skillRegistry";
import { queryKeys } from "@/lib/queryKeys";
import { cn, relativeTime } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PageTabBar } from "@/components/PageTabBar";
import { PageSkeleton } from "@/components/PageSkeleton";
import { EmptyState } from "@/components/EmptyState";
import { StarRating } from "@/components/StarRating";
/* ------------------------------------------------------------------ */
/* VersionDiff component */
/* ------------------------------------------------------------------ */
function VersionDiff({ oldContent, newContent }: { oldContent: string; newContent: string }) {
const parts = diffLines(oldContent, newContent);
return (
<pre
role="region"
aria-label="Version diff"
className="text-xs font-mono rounded border border-border bg-muted/30 p-4 overflow-x-auto"
>
{parts.map((part, i) => (
<span
key={i}
className={cn(
part.added && "bg-green-500/20 text-green-700 dark:text-green-400",
part.removed && "bg-red-500/20 text-red-700 dark:text-red-400",
!part.added && !part.removed && "text-muted-foreground",
)}
>
{part.value}
</span>
))}
</pre>
);
}
/* ------------------------------------------------------------------ */
/* SkillDetail page */
/* ------------------------------------------------------------------ */
export function SkillDetail() {
const { skillId: rawSkillId } = useParams<{ skillId: string }>();
const skillId = rawSkillId ? decodeURIComponent(rawSkillId) : "";
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [detailTab, setDetailTab] = useState("overview");
const [versionA, setVersionA] = useState<string>("");
const [versionB, setVersionB] = useState<string>("");
// Rating form state
const [pendingStars, setPendingStars] = useState(0);
const [pendingNote, setPendingNote] = useState("");
// Install dialog state — used for both install and update flows
const [installDialog, setInstallDialog] = useState<{
skillId: string;
isUpdate: boolean;
agentSkillsDir?: string;
} | null>(null);
// Uninstall confirmation dialog state
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null);
/* ---------------------------------------------------------------- */
/* Data queries */
/* ---------------------------------------------------------------- */
const { data: skill, isLoading, isError } = useQuery({
queryKey: queryKeys.skillRegistry.detail(skillId),
queryFn: () => skillRegistryApi.getById(skillId),
enabled: !!skillId,
});
const { data: versions = [] } = useQuery({
queryKey: queryKeys.skillRegistry.versions(skillId),
queryFn: () => skillRegistryApi.getVersions(skillId),
enabled: !!skillId,
});
const {
data: ratings = [],
isLoading: ratingsLoading,
isError: ratingsError,
} = useQuery({
queryKey: ["skill-ratings", skillId],
queryFn: () => skillRegistryApi.getRatings(skillId),
enabled: !!skillId,
});
/* ---------------------------------------------------------------- */
/* Breadcrumbs */
/* ---------------------------------------------------------------- */
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Workspace", href: "/dashboard" },
{ label: "Skills", href: "../skills" },
{ label: skill?.name ?? "Skill" },
]);
}, [selectedCompany?.name, skill?.name, setBreadcrumbs]);
/* ---------------------------------------------------------------- */
/* Mutations */
/* ---------------------------------------------------------------- */
const invalidateSkill = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.detail(skillId) });
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
};
const installMutation = useMutation({
mutationFn: ({ agentSkillsDir }: { agentSkillsDir: string }) =>
skillRegistryApi.install(skillId, agentSkillsDir),
onSuccess: () => {
invalidateSkill();
setInstallDialog(null);
pushToast({ title: "Skill installed", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Install failed", body: err.message, tone: "error" });
},
});
const updateMutation = useMutation({
mutationFn: ({ agentSkillsDir }: { agentSkillsDir: string }) =>
skillRegistryApi.install(skillId, agentSkillsDir),
onSuccess: () => {
invalidateSkill();
setInstallDialog(null);
pushToast({ title: "Skill updated", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Update failed", body: err.message, tone: "error" });
},
});
const rollbackMutation = useMutation({
mutationFn: ({ versionId, agentSkillsDir }: { versionId: string; agentSkillsDir: string }) =>
skillRegistryApi.rollback(skillId, versionId, agentSkillsDir),
onSuccess: () => {
invalidateSkill();
pushToast({ title: "Rolled back to previous version", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Rollback failed", body: err.message, tone: "error" });
},
});
const removeMutation = useMutation({
mutationFn: () => skillRegistryApi.remove(skillId),
onSuccess: () => {
invalidateSkill();
setUninstallDialog(null);
pushToast({ title: "Skill uninstalled", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Uninstall failed", body: err.message, tone: "error" });
},
});
const saveRatingMutation = useMutation({
mutationFn: ({ stars, note }: { stars: number; note: string }) =>
skillRegistryApi.addRating(skillId, {
stars,
versionId: skill?.activeVersionId ?? undefined,
note: note.trim() || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skill-ratings", skillId] });
setPendingStars(0);
setPendingNote("");
pushToast({ title: "Rating saved", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to save rating", body: err.message, tone: "error" });
},
});
/* ---------------------------------------------------------------- */
/* Loading / error states */
/* ---------------------------------------------------------------- */
if (isLoading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-4">
<PageSkeleton variant="detail" />
</main>
);
}
if (isError) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-4">
<p className="text-sm text-destructive">
Failed to load skills. Check that the skill registry backend is running and try again.
</p>
</main>
);
}
if (!skill) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-4">
<p className="text-sm text-muted-foreground">Skill not found.</p>
</main>
);
}
const isInstalled = !!skill.activeVersionId;
const isMutating =
installMutation.isPending ||
updateMutation.isPending ||
rollbackMutation.isPending ||
removeMutation.isPending;
/* ---------------------------------------------------------------- */
/* Render */
/* ---------------------------------------------------------------- */
return (
<main className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Back link */}
<Link
to="../skills"
className="text-sm text-muted-foreground hover:underline flex items-center gap-1"
>
<ChevronLeft className="h-4 w-4" /> Skills
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">{skill.name}</h1>
<Badge variant="secondary" className="text-xs mt-1">
{skill.sourceId}
</Badge>
</div>
<div className="flex gap-2 flex-shrink-0">
{!isInstalled && (
<Button
size="sm"
variant="outline"
disabled={isMutating}
onClick={() => setInstallDialog({ skillId, isUpdate: false })}
>
<Download className="h-3.5 w-3.5 mr-1" />
Install skill
</Button>
)}
{isInstalled && (
<>
<Button
size="sm"
variant="outline"
disabled={isMutating}
onClick={() => setInstallDialog({ skillId, isUpdate: true })}
>
Update skill
</Button>
<Button
size="sm"
variant="destructive"
disabled={isMutating}
onClick={() => setUninstallDialog({ skillId })}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Uninstall
</Button>
</>
)}
</div>
</div>
{/* Description */}
{skill.description && (
<p className="text-sm text-muted-foreground">{skill.description}</p>
)}
{/* Detail tabs */}
<TooltipProvider>
<Tabs value={detailTab} onValueChange={setDetailTab}>
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "versions", label: "Versions" },
{ value: "diff", label: "Diff" },
{ value: "ratings", label: "Ratings" },
]}
value={detailTab}
onValueChange={setDetailTab}
align="start"
/>
{/* Overview tab */}
<TabsContent value="overview" className="space-y-1 pt-4">
<div className="space-y-1">
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Installs</span>
<span className="text-sm font-semibold">{skill.ratingCount ?? "\u2014"}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average rating</span>
<span className="flex items-center gap-1 text-sm font-semibold">
{skill.averageRating != null ? (
<>
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
{skill.averageRating.toFixed(1)}
</>
) : (
"No ratings yet"
)}
</span>
</div>
{skill.averageRating != null && skill.ratingCount != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Community</span>
<span className="text-sm font-semibold">({skill.ratingCount} ratings)</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Source</span>
<span className="text-xs font-mono text-muted-foreground">{skill.sourceId}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Active version</span>
<span className="text-xs font-mono text-muted-foreground">
{skill.activeVersionId
? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
: "\u2014"}
</span>
</div>
{/* Usage stats — Phase 12 */}
{skill.taskCount != null && skill.taskCount > 0 && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Tasks completed</span>
<span className="text-sm font-semibold">{skill.taskCount}</span>
</div>
)}
{skill.avgCostUsd != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Avg task cost</span>
<span className="text-sm font-semibold">${skill.avgCostUsd.toFixed(4)}</span>
</div>
)}
{skill.lastUsedAt != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Last used</span>
<span className="text-sm font-semibold">{relativeTime(new Date(skill.lastUsedAt))}</span>
</div>
)}
</div>
</TabsContent>
{/* Versions tab */}
<TabsContent value="versions" className="pt-4">
{versions.length === 0 ? (
<p className="text-sm text-muted-foreground">No versions fetched yet.</p>
) : (
<ScrollArea className="max-h-96">
<div className="space-y-1">
{versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between py-2 px-2 rounded hover:bg-accent/50"
>
<div>
<span className="text-xs font-mono text-muted-foreground">{v.version}</span>
<span className="text-xs text-muted-foreground ml-2">
{relativeTime(new Date(v.fetchedAt))}
</span>
</div>
{v.id === skill.activeVersionId && (
<Badge variant="outline" className="text-xs">
Active
</Badge>
)}
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
{/* Diff tab */}
<TabsContent value="diff" className="pt-4 space-y-4">
<div className="flex gap-2 mb-4">
<Select value={versionA} onValueChange={setVersionA}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select version A" />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.version}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={versionB} onValueChange={setVersionB}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select version B" />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{versionA && versionB ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Version diff requires file content endpoint (coming in a future update).
</p>
<VersionDiff oldContent="" newContent="" />
</div>
) : (
<p className="text-xs text-muted-foreground">
Select two versions above to compare them.
</p>
)}
</TabsContent>
{/* Ratings tab */}
<TabsContent value="ratings" className="pt-4 space-y-6">
{/* Section 1: Rate this skill */}
<div className="space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Rate this skill
</h2>
<div className="space-y-2">
<StarRating value={pendingStars} onChange={setPendingStars} size="md" />
<Textarea
placeholder="Add a note about this version\u2026"
value={pendingNote}
onChange={(e) => setPendingNote(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
<Button
size="sm"
variant="default"
disabled={pendingStars === 0 || saveRatingMutation.isPending}
onClick={() => {
if (pendingStars === 0) return;
saveRatingMutation.mutate({ stars: pendingStars, note: pendingNote });
}}
>
{saveRatingMutation.isPending ? "Saving\u2026" : "Save rating"}
</Button>
{saveRatingMutation.isError && (
<p className="text-sm text-destructive">
{(saveRatingMutation.error as Error).message}
</p>
)}
</div>
<Separator />
{/* Section 2: Your Rating History */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Your Rating History
</h2>
{ratingsError && (
<p className="text-sm text-destructive">Failed to load ratings.</p>
)}
{ratingsLoading && (
<div aria-hidden="true" className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
)}
{!ratingsLoading && !ratingsError && ratings.length === 0 && (
<EmptyState
icon={Star}
message="No ratings yet"
/>
)}
{!ratingsLoading && !ratingsError && ratings.length > 0 && (
<ScrollArea className="max-h-80">
<div className="space-y-1">
{ratings.map((r) => (
<div
key={r.id}
className="flex items-start justify-between py-2 px-2 rounded hover:bg-accent/50"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<StarRating value={r.stars} readonly size="sm" />
{r.versionId && (
<Badge variant="outline" className="text-xs font-mono">
{r.versionId.slice(-7)}
</Badge>
)}
</div>
{r.note && (
<p className="text-sm text-muted-foreground">{r.note}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 ml-4">
{relativeTime(new Date(r.createdAt))}
</span>
</div>
))}
</div>
</ScrollArea>
)}
</div>
<Separator />
{/* Section 3: Community Ratings */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Community Ratings
</h2>
{skill.averageRating != null ? (
<div className="space-y-1">
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average</span>
<span className="flex items-center gap-2">
<StarRating value={Math.round(skill.averageRating)} readonly size="sm" />
<span className="text-sm font-semibold">{skill.averageRating.toFixed(1)}</span>
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Ratings</span>
<span className="text-sm font-semibold">{skill.ratingCount}</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No community ratings available.</p>
)}
</div>
</TabsContent>
</Tabs>
</TooltipProvider>
{/* Install / Update dialog */}
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{installDialog?.isUpdate ? "Update skill" : "Install skill"}
</DialogTitle>
<DialogDescription>
{installDialog?.isUpdate
? "Update this skill to the latest version for your agents."
: "Install this skill to add new capabilities to your agents."}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<p className="text-sm text-muted-foreground">
Agent skills directory is required to install. Enter the path to the agent&apos;s
skills directory.
</p>
<input
type="text"
className="mt-2 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="/path/to/agent/skills"
value={installDialog?.agentSkillsDir ?? ""}
onChange={(e) =>
setInstallDialog((d) => d ? { ...d, agentSkillsDir: e.target.value } : null)
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialog(null)}>
Cancel
</Button>
<Button
disabled={!installDialog?.agentSkillsDir || isMutating}
onClick={() => {
if (!installDialog?.agentSkillsDir) return;
if (installDialog.isUpdate) {
updateMutation.mutate({ agentSkillsDir: installDialog.agentSkillsDir });
} else {
installMutation.mutate({ agentSkillsDir: installDialog.agentSkillsDir });
}
}}
>
{installDialog?.isUpdate
? updateMutation.isPending
? "Updating\u2026"
: "Update skill"
: installMutation.isPending
? "Installing\u2026"
: "Install skill"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Uninstall confirmation dialog */}
<Dialog open={!!uninstallDialog} onOpenChange={(open) => !open && setUninstallDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall skill?</DialogTitle>
<DialogDescription>
This will remove the skill files from the agent&apos;s directory. You can reinstall
it later.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUninstallDialog(null)}>
Keep skill
</Button>
<Button
variant="destructive"
disabled={removeMutation.isPending}
onClick={() => removeMutation.mutate()}
>
{removeMutation.isPending ? "Uninstalling\u2026" : "Yes, uninstall"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}