Compare commits
34 commits
bb393b421d
...
67568a08f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 67568a08f6 | |||
| bea6144e5a | |||
| b52f5a8adf | |||
| 1a1c3ce399 | |||
| 3b2fbe97ef | |||
| 1a85831d8a | |||
| e7f487a841 | |||
| 8436f3b981 | |||
| 4172d7d23f | |||
| 40165ffae1 | |||
| f6c92a8bbe | |||
| 5c2ce8b940 | |||
| 7e48f924f1 | |||
| e07b8fba18 | |||
| 5931ba2898 | |||
| 4fa69aefd2 | |||
| f492ec49f0 | |||
| e3e4450113 | |||
| 776255425a | |||
| c9719cbdae | |||
| ccd6e6f162 | |||
| 3700c75a86 | |||
| 2b58169600 | |||
| 5b2fe34223 | |||
| 9c2569ebb0 | |||
| 8c86031b50 | |||
| d26b888957 | |||
| 16ceef77d2 | |||
| ade26c0cc2 | |||
| ab0e15f950 | |||
| 749a0a6c96 | |||
| b2dfd5c22e | |||
| 715d9f42cb | |||
| 120cadb517 |
46 changed files with 6168 additions and 106 deletions
|
|
@ -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
284
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
404
server/src/__tests__/skill-registry-fetch.test.ts
Normal file
404
server/src/__tests__/skill-registry-fetch.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
320
server/src/__tests__/skill-registry-install.test.ts
Normal file
320
server/src/__tests__/skill-registry-install.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
122
server/src/__tests__/skill-registry-ratings.test.ts
Normal file
122
server/src/__tests__/skill-registry-ratings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
240
server/src/__tests__/skill-registry-routes.test.ts
Normal file
240
server/src/__tests__/skill-registry-routes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
136
server/src/__tests__/skill-registry-schema.test.ts
Normal file
136
server/src/__tests__/skill-registry-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
46
server/src/onboarding-assets/general/AGENTS.md
Normal file
46
server/src/onboarding-assets/general/AGENTS.md
Normal 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
|
||||
61
server/src/onboarding-assets/general/HEARTBEAT.md
Normal file
61
server/src/onboarding-assets/general/HEARTBEAT.md
Normal 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.
|
||||
26
server/src/onboarding-assets/general/SOUL.md
Normal file
26
server/src/onboarding-assets/general/SOUL.md
Normal 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.
|
||||
40
server/src/onboarding-assets/general/TOOLS.md
Normal file
40
server/src/onboarding-assets/general/TOOLS.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
209
server/src/routes/skill-registry-groups.ts
Normal file
209
server/src/routes/skill-registry-groups.ts
Normal 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;
|
||||
}
|
||||
102
server/src/routes/skill-registry.ts
Normal file
102
server/src/routes/skill-registry.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) ?? [];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
102
server/src/services/github-skill-helpers.ts
Normal file
102
server/src/services/github-skill-helpers.ts
Normal 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(/^\/+/, "")}`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
186
server/src/services/skill-registry-db.ts
Normal file
186
server/src/services/skill-registry-db.ts
Normal 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;
|
||||
}
|
||||
411
server/src/services/skill-registry-fetcher.ts
Normal file
411
server/src/services/skill-registry-fetcher.ts
Normal 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 };
|
||||
}
|
||||
500
server/src/services/skill-registry-groups.ts
Normal file
500
server/src/services/skill-registry-groups.ts
Normal 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!;
|
||||
},
|
||||
};
|
||||
}
|
||||
99
server/src/services/skill-registry-ratings.ts
Normal file
99
server/src/services/skill-registry-ratings.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
91
server/src/services/skill-registry-schema.ts
Normal file
91
server/src/services/skill-registry-schema.ts
Normal 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(),
|
||||
});
|
||||
202
server/src/services/skill-registry.ts
Normal file
202
server/src/services/skill-registry.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
93
ui/src/api/skillGroups.ts
Normal 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`),
|
||||
};
|
||||
63
ui/src/api/skillRegistry.ts
Normal file
63
ui/src/api/skillRegistry.ts
Normal 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),
|
||||
};
|
||||
82
ui/src/components/GroupBadge.tsx
Normal file
82
ui/src/components/GroupBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
81
ui/src/components/SkillCard.test.tsx
Normal file
81
ui/src/components/SkillCard.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
127
ui/src/components/SkillCard.tsx
Normal file
127
ui/src/components/SkillCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
ui/src/components/StarRating.tsx
Normal file
65
ui/src/components/StarRating.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
{/* ============================================================ */}
|
||||
|
|
|
|||
63
ui/src/pages/SkillBrowser.test.tsx
Normal file
63
ui/src/pages/SkillBrowser.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
563
ui/src/pages/SkillBrowser.tsx
Normal file
563
ui/src/pages/SkillBrowser.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
81
ui/src/pages/SkillDetail.test.tsx
Normal file
81
ui/src/pages/SkillDetail.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
665
ui/src/pages/SkillDetail.tsx
Normal file
665
ui/src/pages/SkillDetail.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue