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",
|
qa: "QA",
|
||||||
devops: "DevOps",
|
devops: "DevOps",
|
||||||
researcher: "Researcher",
|
researcher: "Researcher",
|
||||||
general: "General",
|
general: "Generalist", // [nexus] was: "General"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AGENT_ICON_NAMES = [
|
export const AGENT_ICON_NAMES = [
|
||||||
|
|
|
||||||
284
pnpm-lock.yaml
generated
284
pnpm-lock.yaml
generated
|
|
@ -78,7 +78,7 @@ importers:
|
||||||
version: 17.3.1
|
version: 17.3.1
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 0.38.4
|
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:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
||||||
|
|
@ -236,7 +236,7 @@ importers:
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.4
|
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:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
||||||
|
|
@ -449,6 +449,9 @@ importers:
|
||||||
'@aws-sdk/client-s3':
|
'@aws-sdk/client-s3':
|
||||||
specifier: ^3.888.0
|
specifier: ^3.888.0
|
||||||
version: 3.994.0
|
version: 3.994.0
|
||||||
|
'@libsql/client':
|
||||||
|
specifier: ^0.17.2
|
||||||
|
version: 0.17.2
|
||||||
'@paperclipai/adapter-claude-local':
|
'@paperclipai/adapter-claude-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/claude-local
|
version: link:../packages/adapters/claude-local
|
||||||
|
|
@ -490,7 +493,7 @@ importers:
|
||||||
version: 3.0.1(ajv@8.18.0)
|
version: 3.0.1(ajv@8.18.0)
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 1.4.18
|
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:
|
chokidar:
|
||||||
specifier: ^4.0.3
|
specifier: ^4.0.3
|
||||||
version: 4.0.3
|
version: 4.0.3
|
||||||
|
|
@ -505,7 +508,7 @@ importers:
|
||||||
version: 17.3.1
|
version: 17.3.1
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.4
|
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:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
||||||
|
|
@ -651,6 +654,9 @@ importers:
|
||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
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)
|
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:
|
lexical:
|
||||||
specifier: 0.35.0
|
specifier: 0.35.0
|
||||||
version: 0.35.0
|
version: 0.35.0
|
||||||
|
|
@ -685,6 +691,9 @@ importers:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.7
|
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))
|
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':
|
'@types/node':
|
||||||
specifier: ^25.2.3
|
specifier: ^25.2.3
|
||||||
version: 25.2.3
|
version: 25.2.3
|
||||||
|
|
@ -2017,6 +2026,63 @@ packages:
|
||||||
'@lezer/yaml@1.0.4':
|
'@lezer/yaml@1.0.4':
|
||||||
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
|
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':
|
'@marijn/find-cluster-break@1.0.2':
|
||||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||||
|
|
||||||
|
|
@ -2037,6 +2103,9 @@ packages:
|
||||||
'@mermaid-js/parser@1.0.0':
|
'@mermaid-js/parser@1.0.0':
|
||||||
resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==}
|
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':
|
'@noble/ciphers@2.1.1':
|
||||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
@ -3390,6 +3459,10 @@ packages:
|
||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
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':
|
'@types/estree-jsx@1.0.5':
|
||||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||||
|
|
||||||
|
|
@ -3853,6 +3926,9 @@ packages:
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
cross-fetch@4.1.0:
|
||||||
|
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -4033,6 +4109,10 @@ packages:
|
||||||
dagre-d3-es@7.0.13:
|
dagre-d3-es@7.0.13:
|
||||||
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
|
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:
|
data-urls@7.0.0:
|
||||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
@ -4096,6 +4176,10 @@ packages:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -4118,6 +4202,10 @@ packages:
|
||||||
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
|
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
|
||||||
engines: {node: '>=0.3.1'}
|
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:
|
dompurify@3.3.2:
|
||||||
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -4389,6 +4477,10 @@ packages:
|
||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fetch-blob@3.2.0:
|
||||||
|
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||||
|
engines: {node: ^12.20 || >= 14.13}
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
|
|
@ -4401,6 +4493,10 @@ packages:
|
||||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||||
engines: {node: '>=0.4.x'}
|
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:
|
formidable@3.5.4:
|
||||||
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -4599,6 +4695,9 @@ packages:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
js-base64@3.7.8:
|
||||||
|
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
|
@ -4664,6 +4763,11 @@ packages:
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
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:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
@ -5029,6 +5133,24 @@ packages:
|
||||||
next-tick@1.1.0:
|
next-tick@1.1.0:
|
||||||
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
|
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:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
|
@ -5213,6 +5335,9 @@ packages:
|
||||||
process-warning@5.0.0:
|
process-warning@5.0.0:
|
||||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
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:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
|
|
@ -5628,6 +5753,9 @@ packages:
|
||||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
tr46@6.0.0:
|
tr46@6.0.0:
|
||||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -5906,6 +6034,13 @@ packages:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
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:
|
webidl-conversions@8.0.1:
|
||||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -5918,6 +6053,9 @@ packages:
|
||||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
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:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -7665,6 +7803,68 @@ snapshots:
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@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': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
langium: 4.2.1
|
langium: 4.2.1
|
||||||
|
|
||||||
|
'@neon-rs/load@0.0.4': {}
|
||||||
|
|
||||||
'@noble/ciphers@2.1.1': {}
|
'@noble/ciphers@2.1.1': {}
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
@ -9220,6 +9422,10 @@ snapshots:
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/diff@8.0.0':
|
||||||
|
dependencies:
|
||||||
|
diff: 8.0.4
|
||||||
|
|
||||||
'@types/estree-jsx@1.0.5':
|
'@types/estree-jsx@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
@ -9446,7 +9652,7 @@ snapshots:
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
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:
|
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/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))
|
'@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
|
zod: 4.3.6
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-kit: 0.31.9
|
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
|
pg: 8.18.0
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
@ -9669,6 +9875,12 @@ snapshots:
|
||||||
'@epic-web/invariant': 1.0.0
|
'@epic-web/invariant': 1.0.0
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
|
cross-fetch@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|
@ -9880,6 +10092,8 @@ snapshots:
|
||||||
d3: 7.9.0
|
d3: 7.9.0
|
||||||
lodash-es: 4.17.23
|
lodash-es: 4.17.23
|
||||||
|
|
||||||
|
data-uri-to-buffer@4.0.1: {}
|
||||||
|
|
||||||
data-urls@7.0.0(@noble/hashes@2.0.1):
|
data-urls@7.0.0(@noble/hashes@2.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
|
|
@ -9926,6 +10140,8 @@ snapshots:
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
|
detect-libc@2.0.2: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
|
|
@ -9945,6 +10161,8 @@ snapshots:
|
||||||
|
|
||||||
diff@5.2.2: {}
|
diff@5.2.2: {}
|
||||||
|
|
||||||
|
diff@8.0.4: {}
|
||||||
|
|
||||||
dompurify@3.3.2:
|
dompurify@3.3.2:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
@ -9971,9 +10189,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
optionalDependencies:
|
||||||
'@electric-sql/pglite': 0.3.15
|
'@electric-sql/pglite': 0.3.15
|
||||||
|
'@libsql/client': 0.17.2
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
pg: 8.18.0
|
pg: 8.18.0
|
||||||
|
|
@ -10240,6 +10459,11 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
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:
|
finalhandler@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -10261,6 +10485,10 @@ snapshots:
|
||||||
|
|
||||||
format@0.2.2: {}
|
format@0.2.2: {}
|
||||||
|
|
||||||
|
formdata-polyfill@4.0.10:
|
||||||
|
dependencies:
|
||||||
|
fetch-blob: 3.2.0
|
||||||
|
|
||||||
formidable@3.5.4:
|
formidable@3.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@paralleldrive/cuid2': 2.3.1
|
'@paralleldrive/cuid2': 2.3.1
|
||||||
|
|
@ -10451,6 +10679,8 @@ snapshots:
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
|
js-base64@3.7.8: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-tokens@9.0.1: {}
|
js-tokens@9.0.1: {}
|
||||||
|
|
@ -10520,6 +10750,21 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
isomorphic.js: 0.2.5
|
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:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -11165,6 +11410,18 @@ snapshots:
|
||||||
|
|
||||||
next-tick@1.1.0: {}
|
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: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
@ -11362,6 +11619,8 @@ snapshots:
|
||||||
|
|
||||||
process-warning@5.0.0: {}
|
process-warning@5.0.0: {}
|
||||||
|
|
||||||
|
promise-limit@2.7.0: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
@ -11905,6 +12164,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
tldts: 7.0.26
|
tldts: 7.0.26
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
tr46@6.0.0:
|
tr46@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
@ -12253,6 +12514,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
webidl-conversions@8.0.1: {}
|
webidl-conversions@8.0.1: {}
|
||||||
|
|
||||||
whatwg-mimetype@5.0.0: {}
|
whatwg-mimetype@5.0.0: {}
|
||||||
|
|
@ -12265,6 +12530,11 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@noble/hashes'
|
- '@noble/hashes'
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
tr46: 0.0.3
|
||||||
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
|
"@libsql/client": "^0.17.2",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -368,10 +368,10 @@ describe("agent skill routes", () => {
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
"AGENTS.md": expect.stringContaining("You are the CEO."),
|
"AGENTS.md": expect.stringContaining("You are the Project Manager for this Nexus workspace."),
|
||||||
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
|
"HEARTBEAT.md": expect.stringContaining("Project Manager Task Loop"),
|
||||||
"SOUL.md": expect.stringContaining("CEO Persona"),
|
"SOUL.md": expect.stringContaining("Project Manager Persona"),
|
||||||
"TOOLS.md": expect.stringContaining("# Tools"),
|
"TOOLS.md": expect.stringContaining("# TOOLS.md"),
|
||||||
}),
|
}),
|
||||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||||
);
|
);
|
||||||
|
|
@ -395,7 +395,7 @@ describe("agent skill routes", () => {
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
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 },
|
{ 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 { healthRoutes } from "./routes/health.js";
|
||||||
import { companyRoutes } from "./routes/companies.js";
|
import { companyRoutes } from "./routes/companies.js";
|
||||||
import { companySkillRoutes } from "./routes/company-skills.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 { agentRoutes } from "./routes/agents.js";
|
||||||
import { projectRoutes } from "./routes/projects.js";
|
import { projectRoutes } from "./routes/projects.js";
|
||||||
import { issueRoutes } from "./routes/issues.js";
|
import { issueRoutes } from "./routes/issues.js";
|
||||||
|
|
@ -141,6 +143,8 @@ export async function createApp(
|
||||||
);
|
);
|
||||||
api.use("/companies", companyRoutes(db, opts.storageService));
|
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||||
api.use(companySkillRoutes(db));
|
api.use(companySkillRoutes(db));
|
||||||
|
api.use(skillRegistryRoutes());
|
||||||
|
api.use(skillGroupRoutes());
|
||||||
api.use(agentRoutes(db));
|
api.use(agentRoutes(db));
|
||||||
api.use(assetRoutes(db, opts.storageService));
|
api.use(assetRoutes(db, opts.storageService));
|
||||||
api.use(projectRoutes(db));
|
api.use(projectRoutes(db));
|
||||||
|
|
|
||||||
|
|
@ -113,3 +113,12 @@ export function resolveManagedProjectWorkspaceDir(input: {
|
||||||
export function resolveHomeAwarePath(value: string): string {
|
export function resolveHomeAwarePath(value: string): string {
|
||||||
return path.resolve(expandHomePrefix(value));
|
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,
|
formatDatabaseBackupResult,
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
authUsers,
|
authUsers,
|
||||||
|
agents,
|
||||||
companies,
|
companies,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
instanceUserRoles,
|
instanceUserRoles,
|
||||||
|
|
@ -28,7 +29,7 @@ import { createApp } from "./app.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { logger } from "./middleware/logger.js";
|
import { logger } from "./middleware/logger.js";
|
||||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.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 { createStorageServiceFromConfig } from "./storage/index.js";
|
||||||
import { printStartupBanner } from "./startup-banner.js";
|
import { printStartupBanner } from "./startup-banner.js";
|
||||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.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 db;
|
||||||
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
||||||
let embeddedPostgresStartedByThisProcess = false;
|
let embeddedPostgresStartedByThisProcess = false;
|
||||||
|
|
@ -459,6 +486,18 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
if (config.deploymentMode === "local_trusted") {
|
if (config.deploymentMode === "local_trusted") {
|
||||||
await ensureLocalTrustedBoardPrincipal(db as any);
|
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") {
|
if (config.deploymentMode === "authenticated") {
|
||||||
const {
|
const {
|
||||||
createBetterAuthHandler,
|
createBetterAuthHandler,
|
||||||
|
|
@ -562,6 +601,50 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
|
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) {
|
if (config.heartbeatSchedulerEnabled) {
|
||||||
const heartbeat = heartbeatService(db as any);
|
const heartbeat = heartbeatService(db as any);
|
||||||
const routines = routineService(db as any);
|
const routines = routineService(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.
|
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:
|
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
|
- **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
|
- **Cross-functional or unclear** → break into separate subtasks per domain
|
||||||
- If no suitable agent exists, create one via `nexus-create-agent` before delegating.
|
- 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.
|
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 { findServerAdapter } from "../adapters/index.js";
|
||||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||||
import { notFound, unprocessable } from "../errors.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 { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
|
|
@ -469,90 +478,8 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(url: string) {
|
// [nexus] GitHub helpers extracted to shared module — imported below
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
function extractCommandTokens(raw: string) {
|
||||||
const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const DEFAULT_AGENT_BUNDLE_FILES = {
|
||||||
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
|
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
|
||||||
pm: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"], // [nexus]
|
pm: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"], // [nexus]
|
||||||
engineer: ["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;
|
} as const;
|
||||||
|
|
||||||
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
|
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 === "ceo") return "ceo";
|
||||||
if (role === "pm") return "pm"; // [nexus]
|
if (role === "pm") return "pm"; // [nexus]
|
||||||
if (role === "engineer") return "engineer"; // [nexus]
|
if (role === "engineer") return "engineer"; // [nexus]
|
||||||
|
if (role === "general") return "general"; // [nexus]
|
||||||
return "default";
|
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);
|
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) {
|
} catch (err) {
|
||||||
const message = redactCurrentUserText(
|
const message = redactCurrentUserText(
|
||||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { companyService } from "./companies.js";
|
export { companyService } from "./companies.js";
|
||||||
export { companySkillService } from "./company-skills.js";
|
export { companySkillService } from "./company-skills.js";
|
||||||
|
export { skillRegistryService } from "./skill-registry.js";
|
||||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||||
export { assetService } from "./assets.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/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lexical/link": "0.35.0",
|
"@lexical/link": "0.35.0",
|
||||||
"lexical": "0.35.0",
|
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
|
@ -48,6 +47,8 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"diff": "^8.0.4",
|
||||||
|
"lexical": "0.35.0",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"mermaid": "^11.12.0",
|
"mermaid": "^11.12.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
@ -60,6 +61,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.7",
|
"@tailwindcss/vite": "^4.0.7",
|
||||||
|
"@types/diff": "^8.0.0",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import { Costs } from "./pages/Costs";
|
||||||
import { Activity } from "./pages/Activity";
|
import { Activity } from "./pages/Activity";
|
||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
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 { CompanyExport } from "./pages/CompanyExport";
|
||||||
import { CompanyImport } from "./pages/CompanyImport";
|
import { CompanyImport } from "./pages/CompanyImport";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
|
|
@ -126,7 +127,8 @@ function boardRoutes() {
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
<Route path="company/export/*" element={<CompanyExport />} />
|
<Route path="company/export/*" element={<CompanyExport />} />
|
||||||
<Route path="company/import" element={<CompanyImport />} />
|
<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="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
<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.
|
// redirected here at build time; the original file is preserved for upstream rebase.
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 { useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { VOCAB } from "@paperclipai/branding";
|
import { VOCAB } from "@paperclipai/branding";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -13,7 +14,6 @@ import { companiesApi } from "../api/companies";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||||
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -108,6 +108,16 @@ export function OnboardingWizard() {
|
||||||
runtimeConfig,
|
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({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.agents.list(company.id),
|
queryKey: queryKeys.agents.list(company.id),
|
||||||
});
|
});
|
||||||
|
|
@ -123,8 +133,7 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
if (!effectiveOnboardingOpen) return null;
|
if (!effectiveOnboardingOpen) return null;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<DialogPortal>
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -146,7 +155,7 @@ export function OnboardingWizard() {
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Choose a project root directory. {VOCAB.appName} will set up a{" "}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -213,7 +222,7 @@ export function OnboardingWizard() {
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</DialogPortal>
|
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: {
|
skills: {
|
||||||
available: ["skills", "available"] as const,
|
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: {
|
plugins: {
|
||||||
all: ["plugins"] as const,
|
all: ["plugins"] as const,
|
||||||
examples: ["plugins", "examples"] as const,
|
examples: ["plugins", "examples"] as const,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type AgentPermissionUpdate,
|
type AgentPermissionUpdate,
|
||||||
} from "../api/agents";
|
} from "../api/agents";
|
||||||
import { companySkillsApi } from "../api/companySkills";
|
import { companySkillsApi } from "../api/companySkills";
|
||||||
|
import { skillGroupsApi, type SkillGroupRow } from "../api/skillGroups";
|
||||||
import { budgetsApi } from "../api/budgets";
|
import { budgetsApi } from "../api/budgets";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
|
@ -75,6 +76,17 @@ import {
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||||
import {
|
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(() => {
|
useEffect(() => {
|
||||||
setSkillDraft([]);
|
setSkillDraft([]);
|
||||||
setLastSavedSkills([]);
|
setLastSavedSkills([]);
|
||||||
|
|
@ -2554,6 +2623,289 @@ function AgentSkillsTab({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{isLoading ? (
|
||||||
<PageSkeleton variant="list" />
|
<PageSkeleton variant="list" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -124,6 +125,9 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
||||||
import { InlineEditor } from "@/components/InlineEditor";
|
import { InlineEditor } from "@/components/InlineEditor";
|
||||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||||
import { Identity } from "@/components/Identity";
|
import { Identity } from "@/components/Identity";
|
||||||
|
import { SkillCard } from "@/components/SkillCard";
|
||||||
|
import { GroupBadge } from "@/components/GroupBadge";
|
||||||
|
import { StarRating } from "@/components/StarRating";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Section wrapper */
|
/* Section wrapper */
|
||||||
|
|
@ -188,6 +192,7 @@ export function DesignGuide() {
|
||||||
{ key: "status", label: "Status", value: "Active" },
|
{ key: "status", label: "Status", value: "Active" },
|
||||||
{ key: "priority", label: "Priority", value: "High" },
|
{ key: "priority", label: "Priority", value: "High" },
|
||||||
]);
|
]);
|
||||||
|
const [starValue, setStarValue] = useState(3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 max-w-4xl">
|
<div className="space-y-10 max-w-4xl">
|
||||||
|
|
@ -1254,6 +1259,116 @@ export function DesignGuide() {
|
||||||
</SubSection>
|
</SubSection>
|
||||||
</Section>
|
</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 */}
|
{/* SEPARATOR */}
|
||||||
{/* ============================================================ */}
|
{/* ============================================================ */}
|
||||||
|
|
@ -1304,6 +1419,92 @@ export function DesignGuide() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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 */}
|
{/* 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