Compare commits

..
Author SHA1 Message Date
Peter Steinberger ca70633b92 fix: harden explore limit + tests/docs (#14) (thanks @jdrhyne) 2026-01-18 16:25:18 +00:00
Jonathan Rhyne b8f2e4d7bf feat(cli): add explore command to browse latest updated skills
Adds a new `clawdhub explore` command that fetches the most recently
updated skills from the registry, sorted by updatedAt descending.

Usage:
  clawdhub explore           # Show latest 25 skills
  clawdhub explore --limit 10

Output includes slug, version, relative time since update, and summary.

The API endpoint already exists and returns skills sorted by updatedAt,
this just exposes it via the CLI.
2026-01-18 16:21:14 +00:00
Peter Steinberger 9a912ee5eb chore: update dependencies 2026-01-18 14:10:09 +00:00
Peter Steinberger 11b257a062 fix: harden search and cli http 2026-01-18 14:04:35 +00:00
Peter Steinberger 02e509404a chore: update convex api types 2026-01-18 09:12:52 +00:00
Peter Steinberger 2cf6182991 fix: tighten search matching 2026-01-18 09:11:16 +00:00
Peter Steinberger 18bfea5035 fix: enable explore search button 2026-01-17 21:54:29 +00:00
Peter Steinberger 5f93ddb390 fix: rename ClawdBot to Clawdbot 2026-01-16 01:14:24 +00:00
Peter Steinberger 2b552c4803 feat: default workdir from clawdbot config 2026-01-13 06:04:38 +00:00
Peter Steinberger ece0b830fb test: raise branch coverage 2026-01-13 01:25:06 +00:00
Peter Steinberger 57afea9de5 chore: ignore test-results in biome 2026-01-13 01:24:56 +00:00
Peter Steinberger 1e788b2e0c test: add playwright smoke suite 2026-01-13 00:46:13 +00:00
Peter Steinberger f025721261 fix: prevent skills index crash 2026-01-13 00:40:04 +00:00
Shadow c2cc61df24 feat: add skills lazy loading 2026-01-12 15:52:52 -06:00
Shadow 7a8a3f75ce fix: paginate skills index 2026-01-12 13:36:28 -06:00
Peter Steinberger ac0bdf0375 fix: hide onlycrabs branding 2026-01-11 05:20:20 +01:00
Peter Steinberger 6c7dd2ec6d fix: hide onlycrabs link and code pill 2026-01-11 05:15:43 +01:00
Peter Steinberger c72e3007a1 test: guard skills list query limit 2026-01-10 22:04:42 +01:00
Peter Steinberger b66d28a184 fix: lower skills list query limit 2026-01-10 22:01:06 +01:00
Shadow dddee828af Change ClawdBot link to new URL 2026-01-10 14:01:27 -06:00
Peter Steinberger 106cb1896a Merge pull request #1 from clawdbot/nix-plugin-metadata
Add nix-clawdbot plugin pointers to skill metadata
2026-01-10 19:33:11 +00:00
47 changed files with 1952 additions and 282 deletions
+3
View File
@@ -21,3 +21,6 @@ todos.json
.vscode
.env*.local
coverage
playwright-report
test-results
.playwright
+3 -1
View File
@@ -1,10 +1,11 @@
# Changelog
## Unreleased
## 0.2.0 - 2026-01-13
### Added
- Web: dynamic OG image cards for skills (name, description, version).
- CLI: auto-scan Clawdbot skill roots (per-agent workspaces, shared skills, extraDirs).
- CLI: add `explore` command for latest updates, with limit clamping + tests (thanks @jdrhyne, #14).
- Web: import skills from public GitHub URLs (auto-detect `SKILL.md`, smart file selection, provenance).
- Web/API: SoulHub (SOUL.md registry) with v1 endpoints and first-run auto-seed.
@@ -14,6 +15,7 @@
- Registry: make SoulHub auto-seed idempotent and non-user-owned.
- Registry: keep GitHub backup state + publish backups intact (thanks @joshp123, #1).
- CLI/Registry: restore fork lineage on sync + clamp bulk list queries (thanks @joshp123, #1).
- CLI: default workdir falls back to Clawdbot workspace (override with `--workdir` / `CLAWDHUB_WORKDIR`).
## 0.0.6 - 2026-01-07
+1
View File
@@ -10,6 +10,7 @@
"!**/.output",
"!**/coverage",
"!**/convex/_generated",
"!**/test-results",
"!**/src/routeTree.gen.ts",
"!**/.tanstack",
"!**/public",
+126 -111
View File
@@ -4,7 +4,7 @@
"": {
"name": "clawdhub",
"dependencies": {
"@auth/core": "^0.41.1",
"@auth/core": "^0.34.3",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
@@ -14,17 +14,17 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-router": "^1.144.0",
"@tanstack/react-router-devtools": "^1.144.0",
"@tanstack/react-start": "^1.145.3",
"@tanstack/router-plugin": "^1.145.2",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.3",
"@tanstack/react-router-devtools": "^1.151.3",
"@tanstack/react-start": "^1.151.5",
"@tanstack/router-plugin": "^1.151.5",
"@vercel/analytics": "^1.6.1",
"clawdhub-schema": "^0.0.2",
"clawdhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.2",
"convex": "^1.31.5",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.5",
"h3": "2.0.1-rc.8",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"nitro": "^3.0.1-alpha.1",
@@ -35,31 +35,32 @@
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.0.3",
"vite-tsconfig-paths": "^6.0.4",
"yaml": "^2.8.2",
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@tanstack/devtools-vite": "^0.4.0",
"@playwright/test": "^1.57.0",
"@tanstack/devtools-vite": "^0.4.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.1",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vitest": "^4.0.16",
"vite": "^7.3.1",
"vitest": "^4.0.17",
},
},
"packages/clawdhub": {
"name": "clawdhub",
"version": "0.1.0",
"version": "0.2.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
},
@@ -76,7 +77,7 @@
"semver": "^7.7.3",
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/node": "^25.0.9",
"typescript": "^5.9.3",
},
},
@@ -84,10 +85,10 @@
"name": "clawdhub-schema",
"version": "0.0.2",
"dependencies": {
"arktype": "^2.1.29",
"arktype": "latest",
},
"devDependencies": {
"typescript": "^5.9.3",
"typescript": "latest",
},
},
},
@@ -104,7 +105,7 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
@@ -354,36 +355,38 @@
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0fI0P0W7bSO/GCP/N5dkmtB9vBqCA4ggo1WmXTnxNJVmFFOtcA1vYm1I9jl8fxo+sucW2WnlpnI4fjKdo3JKxA=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KGC4++BeEqrIcmDHiJt/e6/860PWJmUJjjp0mE+smpBmRXMjmOFFjrPmN+ZyCyVgf1WdmhPkQXsRSPeTR+2omw=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UJIOFeJZpFTJIGS+bMdFXcvjslvnXBEouMvzynfQD7RTazcFIRLbokYgEbhrN2P6B352Ut1TUtvR0CLAp/9QfA=="],
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-tvmrDgj3Q0tdc+zMWfCVLVq8EQDEUqasm1zaWgSMYIszpID6qdgqbT+OpWWXV9fLZgtvrkoXGwxkHAUJzdVZXQ=="],
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-68O8YvexIm+ISZKl2vBFII1dMfLrteDyPcuCIecDuiBIj2tV0KYq13zpSCMz4dvJUWJW6RmOOGZKrkkvOAy6uQ=="],
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-7kD28z6/ykGx8WetKTPRZt30pd+ziassxg/8cM24lhjUI+hNXyRHVtHes73dh9D6glJKno+1ut+3amUdZBZcpQ=="],
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-hXBInrFxPNbPPbPQYozo8YpSsFFYdtHBWRUiLMxul71vTy1CdSA7H5Qq2KbrKomr/ASmhvIDVAQZxh9hIJNHMA=="],
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NmJmiqdzYUTHIxteSTyX6IFFgnIsOAjRWXfrS6Jbo5xlB3g39WHniSF3asB/khLJNtwSg4InUS34NprYM7zrEw=="],
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aMaGctlwrJhaIQPOdVJR+AGHZGPm4D1pJ457l0SqZt4dLXAhuUt2ene6cUUGF+864R7bDyFVGZqbZHODYpENyA=="],
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-3KrT80vl3nXUkjuJI/z8dF6xWsKx0t9Tz4ZQHgQw3fYw+CoihBRWGklrdlmCz+EGfMyVaQLqBV9PZckhSqLe2A=="],
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ipOs6kKo8fz5n5LSHvcbyZFmEpEIsh2m7+B03RW3jGjBEPMiXb4PfKNuxnusFYTtJM9WaR3bCVm5UxeJTA8r3w=="],
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hW1fSJZVxG51sLdGq1sQjOzb1tsQ23z/BquJfUwL7CqBobxr7TJvGmoINL+9KryOJt0jCoaiMfWe4yoYw5XfIA=="],
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-m2apsAXg6qU3ulQG45W/qshyEpOjoL+uaQyXJG5dBoDoa66XPtCaSkBlKltD0EwGu0aoB8lM4I5I3OzQ6raNhw=="],
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.36.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg=="],
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.39.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lT3hNhIa02xCujI6YGgjmYGg3Ht/X9ag5ipUVETaMpx5Rd4BbTNWUPif1WN1YZHxt3KLCIqaAe7zVhatv83HOQ=="],
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.36.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ=="],
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.39.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UT+rfTWd+Yr7iJeSLd/7nF8X4gTYssKh+n77hxl6Oilp3NnG1CKRHxZDy3o3lIBnwgzJkdyUAiYWO1bTMXQ1lA=="],
"@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.36.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg=="],
"@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.39.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q=="],
"@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.36.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw=="],
"@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.39.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA=="],
"@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.36.0", "", { "os": "linux", "cpu": "x64" }, "sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q=="],
"@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.39.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw=="],
"@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.36.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ=="],
"@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.39.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g=="],
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.36.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg=="],
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.39.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA=="],
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.36.0", "", { "os": "win32", "cpu": "x64" }, "sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg=="],
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
@@ -532,7 +535,7 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/devtools": ["@tanstack/devtools@0.10.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-1gtPmCDXV4Pl1nVtoqwjV0tc4E9GMuFtlkBX1Lz1KfqI3W9JojT5YsVifOQ/g8BTQ5w5+tyIANwHU7WYgLq/MQ=="],
"@tanstack/devtools": ["@tanstack/devtools@0.10.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-M2HnKtaNf3Z8JDTNDq+X7/1gwOqSwTnCyC0GR+TYiRZM9mkY9GpvTqp6p6bx3DT8onu2URJiVxgHD9WK2e3MNQ=="],
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0" } }, "sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA=="],
@@ -542,47 +545,47 @@
"@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.4.4", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg=="],
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.4.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-vZ5SsjcLSLC+lBb4N6QDJEdrsrORs8OtIcwQafexAR7aJOv6SxGNoqERujEbTzfWY+PAypa1oYxPqtEAOcitDw=="],
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.4.1", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-PkMOomcWnl/pUkCqIjqL/csjPHtkMVBirDpJVOZR7XJZDxo5CuD7B+3KsujFCF4Dsn6QYlae97gCZvxi/CB76Q=="],
"@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="],
"@tanstack/history": ["@tanstack/history@1.151.1", "", {}, "sha512-Z/eymNBuUGHYIea7nNX3xR5feqx418ChlwWOKklVpCVzEQ5Q3kNTUw+WK4HYUKxF+1uXFN01Dbuhhl7SmW1LJA=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.0", "", { "dependencies": { "@tanstack/devtools": "0.10.1" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-Lq0svXOTG5N61SHgx8F0on6zz2GB0kmFjN/yyfNLrJyRgJ+U3jYFRd9ti3uBPABsXzHQMHYYujnTXrOYp/OaUg=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.2", "", { "dependencies": { "@tanstack/devtools": "0.10.3" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-JNXvBO3jgq16GzTVm7p65n5zHNfMhnqF6Bm7CawjoqZrjEakxbM6Yvy63aKSIpbrdf+Wun2Xn8P0qD+vp56e1g=="],
"@tanstack/react-router": ["@tanstack/react-router@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.144.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-GmRyIGmHtGj3VLTHXepIwXAxTcHyL5W7Vw7O1CnVEtFxQQWKMVOnWgI7tPY6FhlNwMKVb3n0mPFWz9KMYyd2GA=="],
"@tanstack/react-router": ["@tanstack/react-router@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.151.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Xb8444OgVpmwCl9hpUA+N/X0dsik/WWYWdldIYNAcmBAl1ZYYXm8kKnfjgHS2aMZzPnqN1BjKocKgc5jg3lK9Q=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.144.0", "", { "dependencies": { "@tanstack/router-devtools-core": "1.144.0" }, "peerDependencies": { "@tanstack/react-router": "^1.144.0", "@tanstack/router-core": "^1.144.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-nstjZvZbOM4U0/Hzi82rtsP1DsR2tfigBidK+WuaDRVVstBsnwVor3DQXTGY5CcfgIiMI3eKzI17VOy3SQDDoQ=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.151.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.151.3" }, "peerDependencies": { "@tanstack/react-router": "^1.151.3", "@tanstack/router-core": "^1.151.3", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WQ8cA4BkDBIglieexdHFvHbhvZOdtgCWO5BDR58MQoz+ZQwsWo9R0m6t6yVOQzkPi69t6Og9s1NogE4BF0/NYA=="],
"@tanstack/react-start": ["@tanstack/react-start@1.145.3", "", { "dependencies": { "@tanstack/react-router": "1.144.0", "@tanstack/react-start-client": "1.145.0", "@tanstack/react-start-server": "1.145.3", "@tanstack/router-utils": "^1.143.11", "@tanstack/start-client-core": "1.145.0", "@tanstack/start-plugin-core": "1.145.3", "@tanstack/start-server-core": "1.145.3", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-ZRd0VbcpPSmYTGdR7PF5LdyPnB7rd4zfyuf8bjtUbjphh4P0wjE3DUTA7Mk29RMvvo6sS7Advjsax9ZqEevLgg=="],
"@tanstack/react-start": ["@tanstack/react-start@1.151.5", "", { "dependencies": { "@tanstack/react-router": "1.151.3", "@tanstack/react-start-client": "1.151.3", "@tanstack/react-start-server": "1.151.3", "@tanstack/router-utils": "^1.143.11", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-plugin-core": "1.151.5", "@tanstack/start-server-core": "1.151.3", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-V3di0M9L6Y/dGCTJrqjTWvxHPxXkSnT2+Dt1ffmVSixlO0HiUDrc1BfEZb3avERPkJ0SL8zxSz6xFYsQZew/yg=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.145.0", "", { "dependencies": { "@tanstack/react-router": "1.144.0", "@tanstack/router-core": "1.144.0", "@tanstack/start-client-core": "1.145.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-UC/+ONaOzuFnlHbOEudYS+AHOrcwAJaqbnfh9zZ5pUtTkJToBawW3YabDbMnS3o6lEiKggc8uGpsiCglUJrBcA=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.151.3", "", { "dependencies": { "@tanstack/react-router": "1.151.3", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-oW6hyCnetWu5dpPROUUxoX3Rpvc1Y7bEwqj3zfHCzGBQ0CsQqryjvtWifd9IBIVYW6W4qKD9ScLyzoLl7uw+ww=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.145.3", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-router": "1.144.0", "@tanstack/router-core": "1.144.0", "@tanstack/start-client-core": "1.145.0", "@tanstack/start-server-core": "1.145.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-HHFq8KTUUsgjifNpYfU7o1jJaVmrwhrjtqQuabGiRseaeIRd4qIGsIS6M1bmOM4+5sYZLKm+lkP6oxgOBuvvaQ=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-router": "1.151.3", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-server-core": "1.151.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Zdlw5gyNkffd52E6u8IFCNu/xwUFfuLnQId3DIkGzPBFi9rg5JfcRXANbjUL0SrnM3+nksyhnbnluYTlCrEmvg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/router-core": ["@tanstack/router-core@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-6oVERtK9XDHCP4XojgHsdHO56ZSj11YaWjF5g/zw39LhyA6Lx+/X86AEIHO4y0BUrMQaJfcjdAQMVSAs6Vjtdg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-JVaNgR/4xl6/5BhVVZCnaZrN+VgpIaHVIWnMiC8dgpZZAI+FfIUOXoTQ+95167cp1igjR8pKDAKEX+gDgD3yKw=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.144.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.144.0", "csstype": "^3.0.10", "solid-js": ">=1.9.5" }, "optionalPeers": ["csstype"] }, "sha512-rbpQn1aHUtcfY3U3SyJqOZRqDu0a2uPK+TE2CH50HieJApmCuNKj5RsjVQYHgwiFFvR0w0LUmueTnl2X2hiWTg=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.151.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.151.3", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-C4ZjhYz3ASkromu0PkXvr6MVwHWsipfQ3fttPODMgjQ1Crvulixmd6yaeZjPoqYXWWTFw75qfiY1Fmh89mD+oQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.145.2", "", { "dependencies": { "@tanstack/router-core": "1.144.0", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.141.0", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-6DLwfqhexgxw2T2QuS9Y349Vb49hCXBIz9mjWyynjMrpejLXJL+PaHaKJw0Y+H7Ao6RE2vlvXCc2cMjgbz5c7Q=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.151.5", "", { "dependencies": { "@tanstack/router-core": "1.151.3", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-YxXCVil0hMBwSYHYL1kGY9ENI0mi3FUDTqVty8XudddT3Jvx21lW94BAUU56VZAp7kuDIVpn8nsyk++ogDJgOg=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.145.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.144.0", "@tanstack/router-generator": "1.145.2", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.141.0", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.144.0", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-dOABjCE4M2KxB+f/mY71dDZduwVTpf+tCPb4NxmAqbF5Rxes24QaaIZQmiU12jte/L8zYyIA/yX9fi93xZue5Q=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.151.5", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.151.3", "@tanstack/router-generator": "1.151.5", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.151.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-l+4jmNACXHDamjbP/bFcOLheqzzqGCpEMjMS/w+BqBEYIu0W+Jqwj8wOwMaEBcuk/Vhnvmkp+WN1RYdM6+GVLg=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.143.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.145.0", "", { "dependencies": { "@tanstack/router-core": "1.144.0", "@tanstack/start-fn-stubs": "1.143.8", "@tanstack/start-storage-context": "1.144.0", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-pqINeN7ZqdfTZrkhy9C7isxRr8U3cByH5ZtLVnUxJp9fvLgwX7LlI+OWpGI0q3E8f/mHMUqJdeE56+atSs8Khw=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3", "@tanstack/start-fn-stubs": "1.151.3", "@tanstack/start-storage-context": "1.151.3", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-44i4lPsY7rmQYY9kZYicc+x+/zDXkDGEn6qNVOA0Q4O6evNXr92eXPVBAYFly1y7m+wlFgeFWddBXbW08gUA7g=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.143.8", "", {}, "sha512-2IKUPh/TlxwzwHMiHNeFw95+L2sD4M03Es27SxMR0A60Qc4WclpaD6gpC8FsbuNASM2jBxk2UyeYClJxW1GOAQ=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.151.3", "", {}, "sha512-/zWBnfsOwact936Bn0CxigudU1QRZdiNTsK7ME/LMXXA66XsDxkryX5+5FeGwU5ETNPfLAx6pRUet1mtUKnLCg=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.145.3", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.144.0", "@tanstack/router-generator": "1.145.2", "@tanstack/router-plugin": "1.145.2", "@tanstack/router-utils": "1.143.11", "@tanstack/start-client-core": "1.145.0", "@tanstack/start-server-core": "1.145.3", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.0", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-PUWKI/8OMyvq8Yjn8ccbEwenASBs5YPEHpXmUjeZ0qb8REGJ6v71Twlqtuva6/fBqZrAKl+2CZrWjgbYZr/h8g=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.151.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.151.3", "@tanstack/router-generator": "1.151.5", "@tanstack/router-plugin": "1.151.5", "@tanstack/router-utils": "1.143.11", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-server-core": "1.151.3", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.0", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-IA3XJ5jzJu2JcLFCTDgEqsR8SgsX1drJKZbvVgoYk6RS3scBtIs7PaBlJ/hn61TvYUx14UTQsJzFzxU6DRH1fQ=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.145.3", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/router-core": "1.144.0", "@tanstack/start-client-core": "1.145.0", "@tanstack/start-storage-context": "1.144.0", "h3-v2": "npm:h3@2.0.1-rc.7", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" } }, "sha512-atsi0fyzymG9BRDJL4kb0oJjhCdB+Wqds+OGPDiWj5VOteCXLpop0ulDlak6wNL2QJZbqqv5BgtGbTQ6rlNyJg=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-storage-context": "1.151.3", "h3-v2": "npm:h3@2.0.1-rc.7", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" } }, "sha512-Hg+VwS2PKkx7+EW9MGZeT3arY7K+JUKvjhuQ/Ugs05f8x/dks3cn91aK5DYkPN0YRMpTNbDwZJX/DePBSnse8w=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.144.0", "", { "dependencies": { "@tanstack/router-core": "1.144.0" } }, "sha512-DuUx5CXfLNettyJlsHDQp66y5haeqzXJkUor7kp5p10SVv24p76dTYqBOpw+wQz//RfJlOciIZFVBcKezXXY0w=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3" } }, "sha512-3gUiBFfcV/OYbdwex6Dl4BWY2oTHy46fDF9CzOzTpujDcz3hI7pqWs68sxatChdyoQVMZR2Xf8os4VRLMgF3ig=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
@@ -602,6 +605,8 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -616,9 +621,9 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -634,21 +639,21 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.17", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.17", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.17", "vitest": "4.0.17" }, "optionalPeers": ["@vitest/browser"] }, "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw=="],
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
"@vitest/expect": ["@vitest/expect@4.0.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ=="],
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
"@vitest/mocker": ["@vitest/mocker@4.0.17", "", { "dependencies": { "@vitest/spy": "4.0.17", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.17", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw=="],
"@vitest/runner": ["@vitest/runner@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" } }, "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q=="],
"@vitest/runner": ["@vitest/runner@4.0.17", "", { "dependencies": { "@vitest/utils": "4.0.17", "pathe": "^2.0.3" } }, "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
"@vitest/snapshot": ["@vitest/snapshot@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ=="],
"@vitest/spy": ["@vitest/spy@4.0.16", "", {}, "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="],
"@vitest/spy": ["@vitest/spy@4.0.17", "", {}, "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew=="],
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -734,9 +739,9 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"convex": ["convex@1.31.2", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-RFuJOwlL2bM5X63egvBI5ZZZH6wESREpAbHsLjODxzDeJuewTLKrEnbvHV/NWp1uJYpgEFJziuGHmZ0tnAmmJg=="],
"convex": ["convex@1.31.5", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-E1IuJKFwMCHDToNGukBPs6c7RFaarR3t8chLF9n98TM5/Tgmj8lM6l7sKM1aJ3VwqGaB4wbeUAPY8osbCOXBhQ=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
@@ -836,7 +841,7 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
"h3": ["h3@2.0.1-rc.8", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.0" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-IIMQG7qnXx1Ls75suuMHH4xtcvTFxsUguDIZB+dgdYr1RftLj59FkeWF1dOr+jnejDs8Eo+ZKV1CMqogFeqGRQ=="],
"h3-v2": ["h3@2.0.1-rc.7", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.0" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qbrRu1OLXmUYnysWOCVrYhtC/m8ZuXu/zCbo3U/KyphJxbPFiC76jHYwVrmEcss9uNAHO5BoUguQ46yEpgI2PA=="],
@@ -896,13 +901,11 @@
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -1072,7 +1075,7 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -1088,9 +1091,9 @@
"oxc-transform": ["oxc-transform@0.96.0", "", { "optionalDependencies": { "@oxc-transform/binding-android-arm64": "0.96.0", "@oxc-transform/binding-darwin-arm64": "0.96.0", "@oxc-transform/binding-darwin-x64": "0.96.0", "@oxc-transform/binding-freebsd-x64": "0.96.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-transform/binding-linux-arm-musleabihf": "0.96.0", "@oxc-transform/binding-linux-arm64-gnu": "0.96.0", "@oxc-transform/binding-linux-arm64-musl": "0.96.0", "@oxc-transform/binding-linux-riscv64-gnu": "0.96.0", "@oxc-transform/binding-linux-s390x-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-musl": "0.96.0", "@oxc-transform/binding-wasm32-wasi": "0.96.0", "@oxc-transform/binding-win32-arm64-msvc": "0.96.0", "@oxc-transform/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dQPNIF+gHpSkmC0+Vg9IktNyhcn28Y8R3eTLyzn52UNymkasLicl3sFAtz7oEVuFmCpgGjaUTKkwk+jW2cHpDQ=="],
"oxlint": ["oxlint@1.36.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.36.0", "@oxlint/darwin-x64": "1.36.0", "@oxlint/linux-arm64-gnu": "1.36.0", "@oxlint/linux-arm64-musl": "1.36.0", "@oxlint/linux-x64-gnu": "1.36.0", "@oxlint/linux-x64-musl": "1.36.0", "@oxlint/win32-arm64": "1.36.0", "@oxlint/win32-x64": "1.36.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxc_language_server": "bin/oxc_language_server", "oxlint": "bin/oxlint" } }, "sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw=="],
"oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.10.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.10.1", "@oxlint-tsgolint/darwin-x64": "0.10.1", "@oxlint-tsgolint/linux-arm64": "0.10.1", "@oxlint-tsgolint/linux-x64": "0.10.1", "@oxlint-tsgolint/win32-arm64": "0.10.1", "@oxlint-tsgolint/win32-x64": "0.10.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.1", "@oxlint-tsgolint/darwin-x64": "0.11.1", "@oxlint-tsgolint/linux-arm64": "0.11.1", "@oxlint-tsgolint/linux-x64": "0.11.1", "@oxlint-tsgolint/win32-arm64": "0.11.1", "@oxlint-tsgolint/win32-x64": "0.11.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-WulCp+0/6RvpM4zPv+dAXybf03QvRA8ATxaBlmj4XMIQqTs5jeq3cUTk48WCt4CpLwKhyyGZPHmjLl1KHQ/cvA=="],
"p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="],
@@ -1110,11 +1113,15 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="],
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"preact-render-to-string": ["preact-render-to-string@5.2.3", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
@@ -1192,7 +1199,7 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
"srvx": ["srvx@0.10.0", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1292,13 +1299,13 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.3", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3", "vite": "*" } }, "sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
"vitest": ["vitest@4.0.17", "", { "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", "@vitest/pretty-format": "4.0.17", "@vitest/runner": "4.0.17", "@vitest/snapshot": "4.0.17", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.17", "@vitest/browser-preview": "4.0.17", "@vitest/browser-webdriverio": "4.0.17", "@vitest/ui": "4.0.17", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -1338,7 +1345,9 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@convex-dev/auth/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"@convex-dev/auth/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"@convex-dev/auth/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-ryJnSmj4UhrGLZZPJ6PKVb4wNPAIkW6iyLy+0TRwazd3L1u0wzMe8RfqevAh2HbcSkoeLiSYnOVDOys4JSGYyg=="],
@@ -1354,19 +1363,19 @@
"@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="],
"@tanstack/start-plugin-core/srvx": ["srvx@0.10.0", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"ast-v8-to-istanbul/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"h3-v2/srvx": ["srvx@0.10.0", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
"nitro/srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1374,6 +1383,10 @@
"parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"preact-render-to-string/pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1384,54 +1397,56 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
"convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
"convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="],
"convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="],
"convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="],
"convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="],
"convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="],
"convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="],
"convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="],
"convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="],
"convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="],
"convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="],
"convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="],
"convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="],
"convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="],
"convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="],
"convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="],
"convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="],
"convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="],
"convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="],
"convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="],
"convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"convex/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="],
"convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="],
"convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="],
"convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="],
"convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
}
}
+4
View File
@@ -28,6 +28,7 @@ import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubBackup from "../lib/githubBackup.js";
import type * as lib_githubImport from "../lib/githubImport.js";
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skills from "../lib/skills.js";
@@ -39,6 +40,7 @@ import type * as maintenance from "../maintenance.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skills from "../skills.js";
import type * as soulComments from "../soulComments.js";
import type * as soulDownloads from "../soulDownloads.js";
@@ -78,6 +80,7 @@ declare const fullApi: ApiFromModules<{
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubImport": typeof lib_githubImport;
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
"lib/searchText": typeof lib_searchText;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillPublish": typeof lib_skillPublish;
"lib/skills": typeof lib_skills;
@@ -89,6 +92,7 @@ declare const fullApi: ApiFromModules<{
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
seedSouls: typeof seedSouls;
skills: typeof skills;
soulComments: typeof soulComments;
soulDownloads: typeof soulDownloads;
+139 -1
View File
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { publishVersionForUser } = await import('./skills')
const { __handlers } = await import('./httpApi')
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = await import('./httpApi')
const { hashSkillFiles } = await import('./lib/skills')
function makeCtx(partial: Record<string, unknown>) {
@@ -55,6 +55,19 @@ describe('httpApi handlers', () => {
expect(json.results[0].slug).toBe('a')
})
it('searchSkillsHttp omits approvedOnly when false', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
new Request('https://example.com/api/search?q=test&approvedOnly=false'),
)
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
approvedOnly: undefined,
})
})
it('getSkillHttp validates slug', async () => {
const response = await __handlers.getSkillHandler(
makeCtx({ runQuery: vi.fn() }),
@@ -104,6 +117,30 @@ describe('httpApi handlers', () => {
expect(json.owner.handle).toBe('p')
})
it('getSkillHttp returns payload with null owner/latestVersion', async () => {
const runQuery = vi.fn().mockResolvedValue({
skill: {
slug: 'demo',
displayName: 'Demo',
summary: null,
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: null,
owner: null,
})
const response = await __handlers.getSkillHandler(
makeCtx({ runQuery }),
new Request('https://example.com/api/skill?slug=demo'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.latestVersion).toBeNull()
expect(json.owner).toBeNull()
})
it('resolveSkillVersionHttp validates hash', async () => {
const response = await __handlers.resolveSkillVersionHandler(
makeCtx({ runQuery: vi.fn() }),
@@ -186,6 +223,65 @@ describe('httpApi handlers', () => {
expect(runMutation).toHaveBeenCalledTimes(1)
})
it('cliTelemetrySyncHttp returns 400 on invalid payload', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'users:1' } as never)
const response = await __handlers.cliTelemetrySyncHandler(
makeCtx({ runMutation: vi.fn() }),
new Request('https://x/api/cli/telemetry/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: 'nope' }),
}),
)
expect(response.status).toBe(400)
})
it('cliTelemetrySyncHttp forwards skill versions when provided', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'users:1' } as never)
const runMutation = vi.fn().mockResolvedValue(null)
await __handlers.cliTelemetrySyncHandler(
makeCtx({ runMutation }),
new Request('https://x/api/cli/telemetry/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roots: [
{
rootId: 'abc',
label: '~/skills',
skills: [{ slug: 'weather', version: '1.0.0' }],
},
],
}),
}),
)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: 'users:1',
roots: [
{ rootId: 'abc', label: '~/skills', skills: [{ slug: 'weather', version: '1.0.0' }] },
],
})
})
it('cliTelemetrySyncHttp returns 400 on invalid json', async () => {
const request = new Request('https://x/api/cli/telemetry/sync', { method: 'POST', body: '{' })
const response = await __handlers.cliTelemetrySyncHandler(makeCtx({}), request)
expect(response.status).toBe(400)
})
it('cliTelemetrySyncHttp returns 401 when unauthorized', async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
const response = await __handlers.cliTelemetrySyncHandler(
makeCtx({}),
new Request('https://x/api/cli/telemetry/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: [] }),
}),
)
expect(response.status).toBe(401)
})
it('cliUploadUrlHttp returns uploadUrl', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue('https://upload.local')
@@ -316,6 +412,48 @@ describe('httpApi handlers', () => {
})
})
it('cliSkillUndeleteHttp calls delete handler with deleted=false', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const response = await cliSkillUndeleteHttp(
makeCtx({ runMutation }),
new Request('https://x/api/cli/skill/undelete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
}),
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: 'user1',
slug: 'demo',
deleted: false,
})
warnSpy.mockRestore()
})
it('cliSkillDeleteHttp calls delete handler with deleted=true', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const response = await cliSkillDeleteHttp(
makeCtx({ runMutation }),
new Request('https://x/api/cli/skill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
}),
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: 'user1',
slug: 'demo',
deleted: true,
})
warnSpy.mockRestore()
})
it('cliSkillDeleteHandler returns 400 on invalid json', async () => {
const request = new Request('https://x/api/cli/skill/delete', { method: 'POST', body: '{' })
const response = await __handlers.cliSkillDeleteHandler(makeCtx({}), request, true)
+23
View File
@@ -25,6 +25,29 @@ describe('httpApi', () => {
expect(parsed.files[0]?.path).toBe('SKILL.md')
})
it('normalizes optional fields in publish payload', () => {
const parsed = __test.parsePublishBody({
slug: 'cool-skill',
displayName: 'Cool Skill',
version: '1.2.3',
changelog: '',
tags: [],
forkOf: { slug: 'base-skill' },
files: [
{
path: 'SKILL.md',
size: 5,
storageId: 'fakeStorageId',
sha256: 'abcd',
contentType: 'text/markdown',
},
],
})
expect(parsed.tags).toBeUndefined()
expect(parsed.source).toBeUndefined()
expect(parsed.forkOf).toEqual({ slug: 'base-skill', version: undefined })
})
it('rejects invalid publish payloads', () => {
expect(() => __test.parsePublishBody(null)).toThrow(/Publish payload/i)
expect(() =>
+32
View File
@@ -0,0 +1,32 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import { __test, matchesExactTokens, tokenize } from './searchText'
describe('searchText', () => {
it('tokenize lowercases and splits on punctuation', () => {
expect(tokenize('Minimax Usage /minimax-usage')).toEqual([
'minimax',
'usage',
'minimax',
'usage',
])
})
it('matchesExactTokens requires all query tokens', () => {
const queryTokens = tokenize('Remind Me')
expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
false,
)
})
it('matchesExactTokens ignores empty inputs', () => {
expect(matchesExactTokens([], ['text'])).toBe(false)
expect(matchesExactTokens(['token'], [' ', null, undefined])).toBe(false)
})
it('normalize uses lowercase', () => {
expect(__test.normalize('AbC')).toBe('abc')
})
})
+25
View File
@@ -0,0 +1,25 @@
const WORD_RE = /[a-z0-9]+/g
function normalize(value: string) {
return value.toLowerCase()
}
export function tokenize(value: string): string[] {
if (!value) return []
return normalize(value).match(WORD_RE) ?? []
}
export function matchesExactTokens(
queryTokens: string[],
parts: Array<string | null | undefined>,
): boolean {
if (queryTokens.length === 0) return false
const text = parts.filter((part) => Boolean(part?.trim())).join(' ')
if (!text) return false
const textTokens = tokenize(text)
if (textTokens.length === 0) return false
const textSet = new Set(textTokens)
return queryTokens.every((token) => textSet.has(token))
}
export const __test = { normalize, tokenize, matchesExactTokens }
+26 -13
View File
@@ -1,20 +1,33 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import { API_TOKEN_PREFIX, generateToken, hashToken } from './tokens'
import { __test, generateToken, hashToken } from './tokens'
describe('tokens', () => {
it('generates token with prefix and url-safe chars', () => {
const { token, prefix } = generateToken()
expect(token.startsWith(API_TOKEN_PREFIX)).toBe(true)
expect(prefix).toBe(token.slice(0, 12))
expect(token).toMatch(/^[a-z0-9_-]+$/i)
it('hashToken returns sha256 hex', async () => {
await expect(hashToken('test')).resolves.toBe(
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
)
})
it('hashes tokens deterministically', async () => {
const a = await hashToken('clh_test')
const b = await hashToken('clh_test')
const c = await hashToken('clh_other')
expect(a).toBe(b)
expect(a).not.toBe(c)
expect(a).toMatch(/^[a-f0-9]{64}$/)
it('generateToken returns token + prefix', () => {
const { token, prefix } = generateToken()
expect(token).toMatch(/^clh_[A-Za-z0-9_-]+$/)
expect(prefix).toBe(token.slice(0, 12))
})
it('toHex encodes bytes', () => {
expect(__test.toHex(new Uint8Array([0, 15, 255]))).toBe('000fff')
})
it('toBase64 encodes 1/2/3-byte tails', () => {
expect(__test.toBase64(new Uint8Array([0xff]))).toBe('/w==')
expect(__test.toBase64(new Uint8Array([0xff, 0xee]))).toBe('/+4=')
expect(__test.toBase64(new Uint8Array([0xff, 0xee, 0xdd]))).toBe('/+7d')
})
it('toBase64Url replaces alphabet and strips padding', () => {
expect(__test.toBase64Url(new Uint8Array([0xff]))).toBe('_w')
expect(__test.toBase64Url(new Uint8Array([0xfa, 0x00, 0x00]))).toBe('-gAA')
})
})
+6
View File
@@ -43,3 +43,9 @@ function toBase64(bytes: Uint8Array) {
}
return output
}
export const __test = {
toHex,
toBase64,
toBase64Url,
}
+12
View File
@@ -0,0 +1,12 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import { __test } from './search'
describe('search helpers', () => {
it('advances candidate limit until max', () => {
expect(__test.getNextCandidateLimit(50, 1000)).toBe(100)
expect(__test.getNextCandidateLimit(800, 1000)).toBe(1000)
expect(__test.getNextCandidateLimit(1000, 1000)).toBeNull()
})
})
+91 -27
View File
@@ -3,6 +3,7 @@ import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { action, internalQuery } from './_generated/server'
import { generateEmbedding } from './lib/embeddings'
import { matchesExactTokens, tokenize } from './lib/searchText'
type HydratedEntry = {
embeddingId: Id<'skillEmbeddings'>
@@ -12,6 +13,11 @@ type HydratedEntry = {
type SearchResult = HydratedEntry & { score: number }
function getNextCandidateLimit(current: number, max: number) {
const next = Math.min(current * 2, max)
return next > current ? next : null
}
export const searchSkills: ReturnType<typeof action> = action({
args: {
query: v.string(),
@@ -21,31 +27,59 @@ export const searchSkills: ReturnType<typeof action> = action({
handler: async (ctx, args): Promise<SearchResult[]> => {
const query = args.query.trim()
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
const vector = await generateEmbedding(query)
const results = await ctx.vectorSearch('skillEmbeddings', 'by_embedding', {
vector,
limit: args.limit ?? 10,
filter: (q) => q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
})
const limit = args.limit ?? 10
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
let hydrated: HydratedEntry[] = []
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
let exactMatches: HydratedEntry[] = []
const hydrated = (await ctx.runQuery(internal.search.hydrateResults, {
embeddingIds: results.map((result) => result._id),
})) as HydratedEntry[]
while (candidateLimit <= maxCandidate) {
const results = await ctx.vectorSearch('skillEmbeddings', 'by_embedding', {
vector,
limit: candidateLimit,
filter: (q) => q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
})
const scoreById = new Map<Id<'skillEmbeddings'>, number>(
results.map((result) => [result._id, result._score]),
)
hydrated = (await ctx.runQuery(internal.search.hydrateResults, {
embeddingIds: results.map((result) => result._id),
})) as HydratedEntry[]
const filtered = args.highlightedOnly
? hydrated.filter((entry) => entry.skill?.batch === 'highlighted')
: hydrated
scoreById = new Map<Id<'skillEmbeddings'>, number>(
results.map((result) => [result._id, result._score]),
)
return filtered
const filtered = args.highlightedOnly
? hydrated.filter((entry) => entry.skill?.batch === 'highlighted')
: hydrated
exactMatches = filtered.filter((entry) =>
matchesExactTokens(queryTokens, [
entry.skill?.displayName,
entry.skill?.slug,
entry.skill?.summary,
]),
)
if (exactMatches.length >= limit || results.length < candidateLimit) {
break
}
const nextLimit = getNextCandidateLimit(candidateLimit, maxCandidate)
if (!nextLimit) break
candidateLimit = nextLimit
}
return exactMatches
.map((entry) => ({
...entry,
score: scoreById.get(entry.embeddingId) ?? 0,
}))
.filter((entry) => entry.skill)
.slice(0, limit)
},
})
@@ -83,27 +117,55 @@ export const searchSouls: ReturnType<typeof action> = action({
handler: async (ctx, args): Promise<SoulSearchResult[]> => {
const query = args.query.trim()
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
const vector = await generateEmbedding(query)
const results = await ctx.vectorSearch('soulEmbeddings', 'by_embedding', {
vector,
limit: args.limit ?? 10,
filter: (q) => q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
})
const limit = args.limit ?? 10
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
let hydrated: HydratedSoulEntry[] = []
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
let exactMatches: HydratedSoulEntry[] = []
const hydrated = (await ctx.runQuery(internal.search.hydrateSoulResults, {
embeddingIds: results.map((result) => result._id),
})) as HydratedSoulEntry[]
while (candidateLimit <= maxCandidate) {
const results = await ctx.vectorSearch('soulEmbeddings', 'by_embedding', {
vector,
limit: candidateLimit,
filter: (q) => q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
})
const scoreById = new Map<Id<'soulEmbeddings'>, number>(
results.map((result) => [result._id, result._score]),
)
hydrated = (await ctx.runQuery(internal.search.hydrateSoulResults, {
embeddingIds: results.map((result) => result._id),
})) as HydratedSoulEntry[]
return hydrated
scoreById = new Map<Id<'soulEmbeddings'>, number>(
results.map((result) => [result._id, result._score]),
)
exactMatches = hydrated.filter((entry) =>
matchesExactTokens(queryTokens, [
entry.soul?.displayName,
entry.soul?.slug,
entry.soul?.summary,
]),
)
if (exactMatches.length >= limit || results.length < candidateLimit) {
break
}
const nextLimit = getNextCandidateLimit(candidateLimit, maxCandidate)
if (!nextLimit) break
candidateLimit = nextLimit
}
return exactMatches
.map((entry) => ({
...entry,
score: scoreById.get(entry.embeddingId) ?? 0,
}))
.filter((entry) => entry.soul)
.slice(0, limit)
},
})
@@ -124,3 +186,5 @@ export const hydrateSoulResults = internalQuery({
return entries
},
})
export const __test = { getNextCandidateLimit }
+9 -1
View File
@@ -17,7 +17,7 @@ bun clawdhub --help
## Global flags
- `--workdir <dir>`: working directory (default: cwd)
- `--workdir <dir>`: working directory (default: cwd; falls back to Clawdbot workspace if configured)
- `--dir <dir>`: install dir under workdir (default: `skills`)
- `--site <url>`: base URL for browser login (default: `https://clawdhub.com`)
- `--registry <url>`: API base URL (default: discovered, else `https://clawdhub.com`)
@@ -27,6 +27,7 @@ Env equivalents:
- `CLAWDHUB_SITE`
- `CLAWDHUB_REGISTRY`
- `CLAWDHUB_WORKDIR`
## Config file
@@ -50,6 +51,13 @@ Stores your API token + cached registry URL.
- Calls `/api/v1/search?q=...`.
### `explore`
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
- Flags:
- `--limit <n>` (150, default: 25)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
### `install <slug>`
- Resolves latest version via `/api/v1/skills/<slug>`.
+14
View File
@@ -48,3 +48,17 @@ read_when:
## Sync
- `bun clawdhub sync --dry-run --all`
## Playwright (menu smoke)
Run against prod:
```
PLAYWRIGHT_BASE_URL=https://clawdhub.com bun run test:pw
```
Run against a local preview server:
```
bun run test:e2e:local
```
+1 -1
View File
@@ -57,7 +57,7 @@ bun clawdhub whoami
bun clawdhub search gif --limit 5
```
Install a skill into `./skills/<slug>`:
Install a skill into `./skills/<slug>` (if Clawdbot is configured, installs into that workspace instead):
```bash
bun clawdhub install <slug>
+34 -7
View File
@@ -11,9 +11,23 @@ import {
parseArk,
} from 'clawdhub-schema'
import { unzipSync } from 'fflate'
import { Agent, setGlobalDispatcher } from 'undici'
import { describe, expect, it } from 'vitest'
import { readGlobalConfig } from '../packages/clawdhub/src/config'
const REQUEST_TIMEOUT_MS = 15_000
try {
setGlobalDispatcher(
new Agent({
allowH2: true,
connect: { timeout: REQUEST_TIMEOUT_MS },
}),
)
} catch {
// ignore dispatcher setup failures
}
function mustGetToken() {
const fromEnv = process.env.CLAWDHUB_E2E_TOKEN?.trim()
if (fromEnv) return fromEnv
@@ -31,6 +45,16 @@ async function makeTempConfig(registry: string, token: string | null) {
return { dir, path }
}
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
try {
return await fetch(input, { ...init, signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
describe('clawdhub e2e', () => {
it('prints CLI version via --cli-version', async () => {
const result = spawnSync('bun', ['clawdhub', '--cli-version'], {
@@ -47,7 +71,9 @@ describe('clawdhub e2e', () => {
url.searchParams.set('q', 'gif')
url.searchParams.set('limit', '5')
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
const response = await fetchWithTimeout(url.toString(), {
headers: { Accept: 'application/json' },
})
expect(response.ok).toBe(true)
const json = (await response.json()) as unknown
const parsed = parseArk(ApiV1SearchResponseSchema, json, 'API response')
@@ -103,7 +129,7 @@ describe('clawdhub e2e', () => {
const cfg = await makeTempConfig(registry, token)
try {
const whoamiUrl = new URL(ApiRoutes.whoami, registry)
const whoamiRes = await fetch(whoamiUrl.toString(), {
const whoamiRes = await fetchWithTimeout(whoamiUrl.toString(), {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
})
expect(whoamiRes.ok).toBe(true)
@@ -310,7 +336,7 @@ describe('clawdhub e2e', () => {
const downloadUrl = new URL(ApiRoutes.download, registry)
downloadUrl.searchParams.set('slug', slug)
downloadUrl.searchParams.set('version', '1.0.1')
const zipRes = await fetch(downloadUrl.toString())
const zipRes = await fetchWithTimeout(downloadUrl.toString())
expect(zipRes.ok).toBe(true)
const zipBytes = new Uint8Array(await zipRes.arrayBuffer())
const unzipped = unzipSync(zipBytes)
@@ -374,7 +400,8 @@ describe('clawdhub e2e', () => {
)
expect(update.status).toBe(0)
const metaRes = await fetch(`${registry}${ApiRoutes.skills}/${slug}`, {
const metaUrl = new URL(`${ApiRoutes.skills}/${slug}`, registry)
const metaRes = await fetchWithTimeout(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaRes.status).toBe(200)
@@ -401,12 +428,12 @@ describe('clawdhub e2e', () => {
)
expect(del.status).toBe(0)
const metaAfterDelete = await fetch(metaUrl.toString(), {
const metaAfterDelete = await fetchWithTimeout(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaAfterDelete.status).toBe(404)
const downloadAfterDelete = await fetch(downloadUrl.toString())
const downloadAfterDelete = await fetchWithTimeout(downloadUrl.toString())
expect(downloadAfterDelete.status).toBe(404)
const undelete = spawnSync(
@@ -431,7 +458,7 @@ describe('clawdhub e2e', () => {
)
expect(undelete.status).toBe(0)
const metaAfterUndelete = await fetch(metaUrl.toString(), {
const metaAfterUndelete = await fetchWithTimeout(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaAfterUndelete.status).toBe(200)
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test'
const navLabels = ['Skills', 'Upload', 'Import', 'Search']
test('skills loads without error', async ({ page }) => {
await page.goto('/skills', { waitUntil: 'domcontentloaded' })
await expect(page.locator('text=Something went wrong!')).toHaveCount(0)
await expect(page.locator('h1', { hasText: 'Skills' })).toBeVisible()
})
test('souls loads without error', async ({ page }) => {
await page.goto('/souls', { waitUntil: 'domcontentloaded' })
await expect(page.locator('text=Something went wrong!')).toHaveCount(0)
await expect(page.locator('h1', { hasText: 'Souls' })).toBeVisible()
})
test('header menu routes render', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' })
for (const label of navLabels) {
const link = page.getByRole('link', { name: label }).first()
await expect(link).toBeVisible()
await link.click()
if (label === 'Skills') {
await expect(page).toHaveURL(/\/skills/)
await expect(page.locator('h1', { hasText: 'Skills' })).toBeVisible()
}
if (label === 'Upload') {
await expect(page).toHaveURL(/\/upload/)
const heading = page.locator('h1.section-title', { hasText: /^Publish a /i })
const signInCard = page.locator('text=Sign in to upload')
await expect(heading.or(signInCard)).toBeVisible()
}
if (label === 'Import') {
await expect(page).toHaveURL(/\/import/)
const heading = page.getByRole('heading', { name: 'Import from GitHub' })
const signInCard = page.locator('text=Sign in to import and publish skills.')
await expect(heading.or(signInCard)).toBeVisible()
}
if (label === 'Search') {
await expect(page).toHaveURL(/\/?(\?|$)/)
await expect(page.locator('h1', { hasText: 'ClawdHub' })).toBeVisible()
}
}
})
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test'
test('skills search paginates exact results', async ({ page }) => {
await page.addInitScript(() => {
const makeSearchResults = (count: number) =>
Array.from({ length: count }, (_, index) => ({
score: 0.9,
skill: {
_id: `skill_${index}`,
slug: `skill-${index}`,
displayName: `Skill ${index}`,
summary: `Summary ${index}`,
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
version: null,
}))
class MockWebSocket {
url: string
readyState = 0
onopen?: () => void
onmessage?: (event: { data: string }) => void
onclose?: (event: { code: number; reason: string }) => void
onerror?: () => void
constructor(url: string) {
this.url = url
window.setTimeout(() => {
this.readyState = 1
this.onopen?.()
}, 0)
}
send(data: string) {
try {
const message = JSON.parse(data) as {
type?: string
requestId?: number
udfPath?: string
args?: Array<Record<string, unknown>>
}
if (message.type === 'Action' && message.udfPath?.includes('searchSkills')) {
const [args] = message.args ?? []
const limit = typeof args?.limit === 'number' ? args.limit : 10
const limits = (window as typeof window & { __searchLimits: number[] }).__searchLimits
limits.push(limit)
const response = {
type: 'ActionResponse',
requestId: message.requestId,
success: true,
result: makeSearchResults(limit),
logLines: [],
}
window.setTimeout(() => {
this.onmessage?.({ data: JSON.stringify(response) })
}, 0)
}
} catch {
this.onerror?.()
}
}
close(code = 1000, reason = 'closed') {
this.readyState = 3
this.onclose?.({ code, reason })
}
}
;(window as typeof window & { __searchLimits: number[] }).__searchLimits = []
window.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
await page.goto('/skills', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'Skills' })).toBeVisible()
const input = page.getByPlaceholder('Filter by name, slug, or summary…')
await input.fill('remind')
await expect(page.getByText('Skill 0')).toBeVisible()
await expect(page.getByText('Scroll to load more')).toBeVisible()
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await expect(page.getByText('Skill 75')).toBeVisible()
const limits = await page.evaluate(
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits,
)
expect(limits).toEqual([50, 100])
})
+21 -18
View File
@@ -13,6 +13,8 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run -c vitest.e2e.config.ts",
"test:e2e:local": "bash scripts/run-playwright-local.sh",
"test:pw": "playwright test",
"coverage": "vitest run --coverage",
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
"lint": "bun run lint:biome && bun run lint:oxlint",
@@ -21,7 +23,7 @@
"format": "biome format --write ."
},
"dependencies": {
"@auth/core": "^0.41.1",
"@auth/core": "^0.34.3",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
@@ -31,17 +33,17 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-router": "^1.144.0",
"@tanstack/react-router-devtools": "^1.144.0",
"@tanstack/react-start": "^1.145.3",
"@tanstack/router-plugin": "^1.145.2",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.3",
"@tanstack/react-router-devtools": "^1.151.3",
"@tanstack/react-start": "^1.151.5",
"@tanstack/router-plugin": "^1.151.5",
"@vercel/analytics": "^1.6.1",
"clawdhub-schema": "^0.0.2",
"clawdhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.2",
"convex": "^1.31.5",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.5",
"h3": "2.0.1-rc.8",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"nitro": "^3.0.1-alpha.1",
@@ -52,25 +54,26 @@
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.0.3",
"vite-tsconfig-paths": "^6.0.4",
"yaml": "^2.8.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@tanstack/devtools-vite": "^0.4.0",
"@playwright/test": "^1.57.0",
"@tanstack/devtools-vite": "^0.4.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.1",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vitest": "^4.0.16"
"vite": "^7.3.1",
"vitest": "^4.0.17"
}
}
+1 -1
View File
@@ -53,5 +53,5 @@ clawdhub sync --root ../clawdis/skills --all --dry-run
- Site: `https://clawdhub.com` (override via `--site` or `CLAWDHUB_SITE`)
- Registry: discovered from `/.well-known/clawdhub.json` on the site (override via `--registry` or `CLAWDHUB_REGISTRY`)
- Workdir: current directory (override via `--workdir`)
- Workdir: current directory (falls back to Clawdbot workspace if configured; override via `--workdir` or `CLAWDHUB_WORKDIR`)
- Install dir: `./skills` under workdir (override via `--dir`)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clawdhub",
"version": "0.1.0",
"version": "0.2.0",
"description": "ClawdHub CLI \u2014 install, update, search, and publish agent skills.",
"license": "MIT",
"type": "module",
@@ -31,7 +31,7 @@
"semver": "^7.7.3"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/node": "^25.0.9",
"typescript": "^5.9.3"
},
"engines": {
+50
View File
@@ -21,12 +21,23 @@ describe('browserAuth', () => {
expect(url).toContain('state=')
})
it('builds auth url without label', () => {
const url = buildCliAuthUrl({
siteUrl: 'https://example.com',
redirectUri: 'http://127.0.0.1:1234/callback',
state: 'state123',
})
expect(url).toContain('https://example.com/cli/auth?')
expect(url).not.toContain('label_b64=')
})
it('accepts only loopback http redirect uris', () => {
expect(isAllowedLoopbackRedirectUri('http://127.0.0.1:1234/callback')).toBe(true)
expect(isAllowedLoopbackRedirectUri('http://localhost:1234/callback')).toBe(true)
expect(isAllowedLoopbackRedirectUri('http://[::1]:1234/callback')).toBe(true)
expect(isAllowedLoopbackRedirectUri('https://127.0.0.1:1234/callback')).toBe(false)
expect(isAllowedLoopbackRedirectUri('http://evil.com/callback')).toBe(false)
expect(isAllowedLoopbackRedirectUri('not a url')).toBe(false)
})
it('receives token via loopback server', async () => {
@@ -43,4 +54,43 @@ describe('browserAuth', () => {
})
await expect(server.waitForResult()).resolves.toEqual(payload)
})
it('serves callback html', async () => {
const server = await startLoopbackAuthServer({ timeoutMs: 2000 })
const response = await fetch(server.redirectUri)
expect(response.status).toBe(200)
const text = await response.text()
expect(text).toContain('ClawdHub CLI Login')
server.close()
})
it('returns 404 for unknown routes', async () => {
const server = await startLoopbackAuthServer({ timeoutMs: 2000 })
const response = await fetch(server.redirectUri.replace('/callback', '/nope'))
expect(response.status).toBe(404)
server.close()
})
it('rejects invalid json payloads', async () => {
const server = await startLoopbackAuthServer({ timeoutMs: 2000 })
const tokenUrl = server.redirectUri.replace('/callback', '/token')
const response = await fetch(tokenUrl, { method: 'POST', body: '{' })
expect(response.status).toBe(400)
await expect(server.waitForResult()).rejects.toThrow()
})
it('rejects state mismatches', async () => {
const server = await startLoopbackAuthServer({ timeoutMs: 2000 })
await fetch(server.redirectUri.replace('/callback', '/token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'clh_test', registry: 'https://example.com', state: 'nope' }),
})
await expect(server.waitForResult()).rejects.toThrow(/state mismatch/i)
})
it('times out waiting for login', async () => {
const server = await startLoopbackAuthServer({ timeoutMs: 25 })
await expect(server.waitForResult()).rejects.toThrow(/timed out waiting for browser login/i)
})
})
+70 -20
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env node
import { resolve } from 'node:path'
import { stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { Command } from 'commander'
import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
import { cmdPublish } from './cli/commands/publish.js'
import { cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
import { cmdSync } from './cli/commands/sync.js'
import { configureCommanderHelp, styleEnvBlock, styleTitle } from './cli/helpStyle.js'
import { DEFAULT_REGISTRY, DEFAULT_SITE } from './cli/registry.js'
@@ -28,13 +30,16 @@ const program = new Command()
.option('--no-input', 'Disable prompts')
.showHelpAfterError()
.showSuggestionAfterError()
.addHelpText('after', styleEnvBlock('\nEnv:\n CLAWDHUB_SITE\n CLAWDHUB_REGISTRY\n'))
.addHelpText(
'after',
styleEnvBlock('\nEnv:\n CLAWDHUB_SITE\n CLAWDHUB_REGISTRY\n CLAWDHUB_WORKDIR\n'),
)
configureCommanderHelp(program)
function resolveGlobalOpts(): GlobalOpts {
async function resolveGlobalOpts(): Promise<GlobalOpts> {
const raw = program.opts<{ workdir?: string; dir?: string; site?: string; registry?: string }>()
const workdir = resolve(raw.workdir ?? process.cwd())
const workdir = await resolveWorkdir(raw.workdir)
const dir = resolve(workdir, raw.dir ?? 'skills')
const site = raw.site ?? process.env.CLAWDHUB_SITE ?? DEFAULT_SITE
const registrySource = raw.registry ? 'cli' : process.env.CLAWDHUB_REGISTRY ? 'env' : 'default'
@@ -47,6 +52,35 @@ function isInputAllowed() {
return globalFlags.input !== false
}
async function resolveWorkdir(explicit?: string) {
if (explicit?.trim()) return resolve(explicit.trim())
const envWorkdir = process.env.CLAWDHUB_WORKDIR?.trim()
if (envWorkdir) return resolve(envWorkdir)
const cwd = resolve(process.cwd())
const hasMarker = await hasClawdhubMarker(cwd)
if (hasMarker) return cwd
const clawdbotWorkspace = await resolveClawdbotDefaultWorkspace()
return clawdbotWorkspace ? resolve(clawdbotWorkspace) : cwd
}
async function hasClawdhubMarker(workdir: string) {
const lockfile = join(workdir, '.clawdhub', 'lock.json')
if (await pathExists(lockfile)) return true
const markerDir = join(workdir, '.clawdhub')
return pathExists(markerDir)
}
async function pathExists(path: string) {
try {
await stat(path)
return true
} catch {
return false
}
}
program
.command('login')
.description('Log in (opens browser or stores token)')
@@ -54,7 +88,7 @@ program
.option('--label <label>', 'Token label (browser flow only)', 'CLI token')
.option('--no-browser', 'Do not open browser (requires --token)')
.action(async (options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdLoginFlow(opts, options, isInputAllowed())
})
@@ -62,7 +96,7 @@ program
.command('logout')
.description('Remove stored token')
.action(async () => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdLogout(opts)
})
@@ -70,7 +104,7 @@ program
.command('whoami')
.description('Validate token')
.action(async () => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdWhoami(opts)
})
@@ -87,7 +121,7 @@ auth
.option('--label <label>', 'Token label (browser flow only)', 'CLI token')
.option('--no-browser', 'Do not open browser (requires --token)')
.action(async (options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdLoginFlow(opts, options, isInputAllowed())
})
@@ -95,7 +129,7 @@ auth
.command('logout')
.description('Remove stored token')
.action(async () => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdLogout(opts)
})
@@ -103,7 +137,7 @@ auth
.command('whoami')
.description('Validate token')
.action(async () => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdWhoami(opts)
})
@@ -113,7 +147,7 @@ program
.argument('<query...>', 'Query string')
.option('--limit <n>', 'Max results', (value) => Number.parseInt(value, 10))
.action(async (queryParts, options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
const query = queryParts.join(' ').trim()
await cmdSearch(opts, query, options.limit)
})
@@ -125,7 +159,7 @@ program
.option('--version <version>', 'Version to install')
.option('--force', 'Overwrite existing folder')
.action(async (slug, options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdInstall(opts, slug, options.version, options.force)
})
@@ -137,7 +171,7 @@ program
.option('--version <version>', 'Update to specific version (single slug only)')
.option('--force', 'Overwrite when local files do not match any version')
.action(async (slug, options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdUpdate(opts, slug, options, isInputAllowed())
})
@@ -145,10 +179,26 @@ program
.command('list')
.description('List installed skills (from lockfile)')
.action(async () => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdList(opts)
})
program
.command('explore')
.description('Browse latest updated skills from the registry')
.option(
'--limit <n>',
'Number of skills to show (max 50)',
(value) => Number.parseInt(value, 10),
25,
)
.action(async (options) => {
const opts = await resolveGlobalOpts()
const limit =
typeof options.limit === 'number' && Number.isFinite(options.limit) ? options.limit : 25
await cmdExplore(opts, limit)
})
program
.command('publish')
.description('Publish skill from folder')
@@ -160,7 +210,7 @@ program
.option('--changelog <text>', 'Changelog text')
.option('--tags <tags>', 'Comma-separated tags', 'latest')
.action(async (folder, options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdPublish(opts, folder, options)
})
@@ -170,7 +220,7 @@ program
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdDeleteSkill(opts, slug, options, isInputAllowed())
})
@@ -180,7 +230,7 @@ program
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
})
@@ -195,7 +245,7 @@ program
.option('--tags <tags>', 'Comma-separated tags', 'latest')
.option('--concurrency <n>', 'Concurrent registry checks (default: 4)', '4')
.action(async (options) => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
const bump = String(options.bump ?? 'patch') as 'patch' | 'minor' | 'major'
if (!['patch', 'minor', 'major'].includes(bump)) fail('--bump must be patch|minor|major')
const concurrencyRaw = Number(options.concurrency ?? 4)
@@ -217,7 +267,7 @@ program
})
program.action(async () => {
const opts = resolveGlobalOpts()
const opts = await resolveGlobalOpts()
const cfg = await readGlobalConfig()
if (cfg?.token) {
await cmdSync(opts, {}, isInputAllowed())
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveClawdbotSkillRoots } from './clawdbotConfig.js'
import { resolveClawdbotDefaultWorkspace, resolveClawdbotSkillRoots } from './clawdbotConfig.js'
const originalEnv = { ...process.env }
@@ -24,7 +24,15 @@ describe('resolveClawdbotSkillRoots', () => {
const config = `{
// JSON5 comments + trailing commas supported
agent: { workspace: '~/clawd-main', },
agents: {
defaults: { workspace: '~/clawd-main', },
list: [
{ id: 'work', name: 'Work Bot', workspace: '~/clawd-work', },
{ id: 'family', workspace: '~/clawd-family', },
],
},
// legacy entries still supported
agent: { workspace: '~/clawd-legacy', },
routing: {
agents: {
work: { name: 'Work Bot', workspace: '~/clawd-work', },
@@ -57,6 +65,56 @@ describe('resolveClawdbotSkillRoots', () => {
expect(labels[resolve('/opt/skills')]).toBe('Extra: skills')
})
it('resolves default workspace from agents.defaults and agents.list', async () => {
const base = await mkdtemp(join(tmpdir(), 'clawdhub-clawdbot-default-'))
const home = join(base, 'home')
const stateDir = join(base, 'state')
const configPath = join(base, 'clawdbot.json')
const workspaceMain = join(base, 'workspace-main')
const workspaceList = join(base, 'workspace-list')
process.env.HOME = home
process.env.CLAWDBOT_STATE_DIR = stateDir
process.env.CLAWDBOT_CONFIG_PATH = configPath
const config = `{
agents: {
defaults: { workspace: "${workspaceMain}", },
list: [
{ id: 'main', workspace: "${workspaceList}", default: true },
],
},
}`
await writeFile(configPath, config, 'utf8')
const workspace = await resolveClawdbotDefaultWorkspace()
expect(workspace).toBe(resolve(workspaceMain))
})
it('falls back to default agent in agents.list when defaults missing', async () => {
const base = await mkdtemp(join(tmpdir(), 'clawdhub-clawdbot-list-'))
const home = join(base, 'home')
const configPath = join(base, 'clawdbot.json')
const workspaceMain = join(base, 'workspace-main')
const workspaceWork = join(base, 'workspace-work')
process.env.HOME = home
process.env.CLAWDBOT_CONFIG_PATH = configPath
const config = `{
agents: {
list: [
{ id: 'main', workspace: "${workspaceMain}", default: true },
{ id: 'work', workspace: "${workspaceWork}" },
],
},
}`
await writeFile(configPath, config, 'utf8')
const workspace = await resolveClawdbotDefaultWorkspace()
expect(workspace).toBe(resolve(workspaceMain))
})
it('respects CLAWDBOT_STATE_DIR and CLAWDBOT_CONFIG_PATH overrides', async () => {
const base = await mkdtemp(join(tmpdir(), 'clawdhub-clawdbot-override-'))
const home = join(base, 'home')
+47 -3
View File
@@ -5,6 +5,15 @@ import JSON5 from 'json5'
type ClawdbotConfig = {
agent?: { workspace?: string }
agents?: {
defaults?: { workspace?: string }
list?: Array<{
id?: string
name?: string
workspace?: string
default?: boolean
}>
}
routing?: {
agents?: Record<
string,
@@ -37,11 +46,21 @@ export async function resolveClawdbotSkillRoots(): Promise<ClawdbotSkillRoots> {
const config = await readClawdbotConfig()
if (!config) return { roots, labels }
const mainWorkspace = resolveUserPath(config.agent?.workspace ?? '')
const mainWorkspace = resolveUserPath(
config.agents?.defaults?.workspace ?? config.agent?.workspace ?? '',
)
if (mainWorkspace) {
pushRoot(roots, labels, join(mainWorkspace, 'skills'), 'Agent: main')
}
const listedAgents = config.agents?.list ?? []
for (const entry of listedAgents) {
const workspace = resolveUserPath(entry?.workspace ?? '')
if (!workspace) continue
const name = entry?.name?.trim() || entry?.id?.trim() || 'agent'
pushRoot(roots, labels, join(workspace, 'skills'), `Agent: ${name}`)
}
const agents = config.routing?.agents ?? {}
for (const [agentId, entry] of Object.entries(agents)) {
const workspace = resolveUserPath(entry?.workspace ?? '')
@@ -61,6 +80,22 @@ export async function resolveClawdbotSkillRoots(): Promise<ClawdbotSkillRoots> {
return { roots, labels }
}
export async function resolveClawdbotDefaultWorkspace(): Promise<string | null> {
const config = await readClawdbotConfig()
if (!config) return null
const defaultsWorkspace = resolveUserPath(
config.agents?.defaults?.workspace ?? config.agent?.workspace ?? '',
)
if (defaultsWorkspace) return defaultsWorkspace
const listedAgents = config.agents?.list ?? []
const defaultAgent =
listedAgents.find((entry) => entry.default) ?? listedAgents.find((entry) => entry.id === 'main')
const listWorkspace = resolveUserPath(defaultAgent?.workspace ?? '')
return listWorkspace || null
}
function resolveClawdbotStateDir() {
const override = process.env.CLAWDBOT_STATE_DIR?.trim()
if (override) return resolveUserPath(override)
@@ -96,8 +131,17 @@ async function readClawdbotConfig(): Promise<ClawdbotConfig | null> {
function pushRoot(roots: string[], labels: Record<string, string>, root: string, label?: string) {
const resolved = resolveUserPath(root)
if (!resolved) return
roots.push(resolved)
if (!roots.includes(resolved)) roots.push(resolved)
if (!label) return
const existing = labels[resolved]
labels[resolved] = existing ? `${existing}, ${label}` : label
if (!existing) {
labels[resolved] = label
return
}
const parts = existing
.split(', ')
.map((part) => part.trim())
.filter(Boolean)
if (parts.includes(label)) return
labels[resolved] = `${existing}, ${label}`
}
@@ -0,0 +1,98 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
const mockApiRequest = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
}))
const mockGetRegistry = vi.fn(async () => 'https://clawdhub.com')
vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => mockSpinner),
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}))
const { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawdhub.com',
registry: 'https://clawdhub.com',
registrySource: 'default',
}
}
afterEach(() => {
vi.clearAllMocks()
})
describe('explore helpers', () => {
it('clamps explore limits and handles non-finite values', () => {
expect(clampLimit(-5)).toBe(1)
expect(clampLimit(0)).toBe(1)
expect(clampLimit(1)).toBe(1)
expect(clampLimit(50)).toBe(50)
expect(clampLimit(99)).toBe(50)
expect(clampLimit(Number.NaN)).toBe(25)
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
expect(clampLimit(Number.NaN, 10)).toBe(10)
})
it('formats explore lines with relative time and truncation', () => {
const now = 4 * 60 * 60 * 1000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
const summary = 'a'.repeat(60)
const line = formatExploreLine({
slug: 'weather',
summary,
updatedAt: now - 2 * 60 * 60 * 1000,
latestVersion: null,
})
expect(line).toBe(`weather v? 2h ago ${'a'.repeat(49)}`)
nowSpy.mockRestore()
})
})
describe('cmdExplore', () => {
it('clamps limit and handles empty results', async () => {
mockApiRequest.mockResolvedValue({ items: [] })
await cmdExplore(makeOpts(), 0)
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('1')
expect(mockLog).toHaveBeenCalledWith('No skills found.')
})
it('prints formatted results', async () => {
const now = 10 * 60 * 1000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
const item = {
slug: 'gog',
summary: 'Google Workspace CLI for Gmail, Calendar, Drive and more.',
updatedAt: now - 90 * 1000,
latestVersion: { version: '1.2.3' },
}
mockApiRequest.mockResolvedValue({ items: [item] })
await cmdExplore(makeOpts(), 100)
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('50')
expect(mockLog).toHaveBeenCalledWith(formatExploreLine(item))
nowSpy.mockRestore()
})
})
@@ -5,6 +5,7 @@ import { apiRequest, downloadZip } from '../../http.js'
import {
ApiRoutes,
ApiV1SearchResponseSchema,
ApiV1SkillListResponseSchema,
ApiV1SkillResolveResponseSchema,
ApiV1SkillResponseSchema,
} from '../../schema/index.js'
@@ -241,6 +242,74 @@ export async function cmdList(opts: GlobalOpts) {
}
}
export async function cmdExplore(opts: GlobalOpts, limit = 25) {
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching latest skills')
try {
const url = new URL(ApiRoutes.skills, registry)
const boundedLimit = clampLimit(limit)
url.searchParams.set('limit', String(boundedLimit))
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
ApiV1SkillListResponseSchema,
)
spinner.stop()
if (result.items.length === 0) {
console.log('No skills found.')
return
}
for (const item of result.items) {
console.log(formatExploreLine(item))
}
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
export function formatExploreLine(item: {
slug: string
summary?: string | null
updatedAt: number
latestVersion?: { version: string } | null
}) {
const version = item.latestVersion?.version ?? '?'
const age = formatRelativeTime(item.updatedAt)
const summary = item.summary ? ` ${truncate(item.summary, 50)}` : ''
return `${item.slug} v${version} ${age}${summary}`
}
export function clampLimit(limit: number, fallback = 25) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, limit), 50)
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 30) {
const months = Math.floor(days / 30)
return `${months}mo ago`
}
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return 'just now'
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str
return `${str.slice(0, maxLen - 1)}`
}
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
+39
View File
@@ -33,4 +33,43 @@ describe('discovery', () => {
minCliVersion: undefined,
})
})
it('parses apiBase config', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
apiBase: 'https://api.example.com',
authBase: 'https://auth.example.com',
minCliVersion: '1.2.3',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
) as unknown as typeof fetch,
)
await expect(discoverRegistryFromSite('https://example.com')).resolves.toEqual({
apiBase: 'https://api.example.com',
authBase: 'https://auth.example.com',
minCliVersion: '1.2.3',
})
})
it('returns null when apiBase is empty', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(JSON.stringify({ apiBase: '' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
) as unknown as typeof fetch,
)
await expect(discoverRegistryFromSite('https://example.com')).resolves.toBeNull()
})
})
+74 -1
View File
@@ -1,7 +1,7 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from 'vitest'
import { apiRequest, downloadZip } from './http'
import { apiRequest, apiRequestForm, downloadZip } from './http'
import { ApiV1WhoamiResponseSchema } from './schema/index.js'
describe('apiRequest', () => {
@@ -80,4 +80,77 @@ describe('apiRequest', () => {
expect(url).toContain('version=1.0.0')
vi.unstubAllGlobals()
})
it('does not retry on non-retryable errors', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 404,
text: async () => 'nope',
})
vi.stubGlobal('fetch', fetchMock)
await expect(downloadZip('https://example.com', { slug: 'demo' })).rejects.toThrow('nope')
expect(fetchMock).toHaveBeenCalledTimes(1)
vi.unstubAllGlobals()
})
})
describe('apiRequestForm', () => {
it('posts form data and returns json', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ ok: true }),
})
vi.stubGlobal('fetch', fetchMock)
const form = new FormData()
form.append('x', '1')
const result = await apiRequestForm('https://example.com', {
method: 'POST',
path: '/upload',
token: 'clh_token',
form,
})
expect(result).toEqual({ ok: true })
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.body).toBe(form)
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer clh_token')
vi.unstubAllGlobals()
})
it('retries on 429', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 429,
text: async () => 'rate limited',
})
vi.stubGlobal('fetch', fetchMock)
await expect(
apiRequestForm('https://example.com', {
method: 'POST',
path: '/upload',
form: new FormData(),
}),
).rejects.toThrow('rate limited')
expect(fetchMock).toHaveBeenCalledTimes(3)
vi.unstubAllGlobals()
})
it('falls back to HTTP status when body cannot be read', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => {
throw new Error('boom')
},
})
vi.stubGlobal('fetch', fetchMock)
await expect(
apiRequestForm('https://example.com', {
method: 'POST',
path: '/upload',
form: new FormData(),
}),
).rejects.toThrow('HTTP 400')
expect(fetchMock).toHaveBeenCalledTimes(1)
vi.unstubAllGlobals()
})
})
+202 -3
View File
@@ -1,7 +1,29 @@
import { spawnSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import pRetry, { AbortError } from 'p-retry'
import { Agent, setGlobalDispatcher } from 'undici'
import type { ArkValidator } from './schema/index.js'
import { ApiRoutes, parseArk } from './schema/index.js'
const REQUEST_TIMEOUT_MS = 15_000
const REQUEST_TIMEOUT_SECONDS = Math.ceil(REQUEST_TIMEOUT_MS / 1000)
const isBun = typeof process !== 'undefined' && Boolean(process.versions?.bun)
if (typeof process !== 'undefined' && process.versions?.node) {
try {
setGlobalDispatcher(
new Agent({
allowH2: true,
connect: { timeout: REQUEST_TIMEOUT_MS },
}),
)
} catch {
// ignore dispatcher setup failures in non-node runtimes
}
}
type RequestArgs =
| { method: 'GET' | 'POST' | 'DELETE'; path: string; token?: string; body?: unknown }
| { method: 'GET' | 'POST' | 'DELETE'; url: string; token?: string; body?: unknown }
@@ -20,6 +42,10 @@ export async function apiRequest<T>(
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
const json = await pRetry(
async () => {
if (isBun) {
return await fetchJsonViaCurl(url, args)
}
const headers: Record<string, string> = { Accept: 'application/json' }
if (args.token) headers.Authorization = `Bearer ${args.token}`
let body: string | undefined
@@ -27,7 +53,15 @@ export async function apiRequest<T>(
headers['Content-Type'] = 'application/json'
body = JSON.stringify(args.body ?? {})
}
const response = await fetch(url, { method: args.method, headers, body })
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url, {
method: args.method,
headers,
body,
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) {
const text = await response.text().catch(() => '')
const message = text || `HTTP ${response.status}`
@@ -62,9 +96,21 @@ export async function apiRequestForm<T>(
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
const json = await pRetry(
async () => {
if (isBun) {
return await fetchJsonFormViaCurl(url, args)
}
const headers: Record<string, string> = { Accept: 'application/json' }
if (args.token) headers.Authorization = `Bearer ${args.token}`
const response = await fetch(url, { method: args.method, headers, body: args.form })
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url, {
method: args.method,
headers,
body: args.form,
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) {
const text = await response.text().catch(() => '')
const message = text || `HTTP ${response.status}`
@@ -87,7 +133,14 @@ export async function downloadZip(registry: string, args: { slug: string; versio
if (args.version) url.searchParams.set('version', args.version)
return pRetry(
async () => {
const response = await fetch(url.toString(), { method: 'GET' })
if (isBun) {
return await fetchBinaryViaCurl(url.toString())
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) {
const message = (await response.text().catch(() => '')) || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
@@ -100,3 +153,149 @@ export async function downloadZip(registry: string, args: { slug: string; versio
{ retries: 2 },
)
}
async function fetchJsonViaCurl(url: string, args: RequestArgs) {
const headers = ['-H', 'Accept: application/json']
if (args.token) {
headers.push('-H', `Authorization: Bearer ${args.token}`)
}
const curlArgs = [
'--silent',
'--show-error',
'--location',
'--max-time',
String(REQUEST_TIMEOUT_SECONDS),
'--write-out',
'\n%{http_code}',
'-X',
args.method,
...headers,
url,
]
if (args.method === 'POST') {
curlArgs.push('-H', 'Content-Type: application/json')
curlArgs.push('--data-binary', JSON.stringify(args.body ?? {}))
}
const result = spawnSync('curl', curlArgs, { encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(result.stderr || 'curl failed')
}
const output = result.stdout ?? ''
const splitAt = output.lastIndexOf('\n')
if (splitAt === -1) throw new Error('curl response missing status')
const body = output.slice(0, splitAt)
const status = Number(output.slice(splitAt + 1).trim())
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
if (status === 429 || status >= 500) {
throw new Error(body || `HTTP ${status}`)
}
throw new AbortError(body || `HTTP ${status}`)
}
return JSON.parse(body || 'null') as unknown
}
async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
const headers = ['-H', 'Accept: application/json']
if (args.token) {
headers.push('-H', `Authorization: Bearer ${args.token}`)
}
const tempDir = await mkdtemp(join(tmpdir(), 'clawdhub-upload-'))
try {
const formArgs: string[] = []
for (const [key, value] of args.form.entries()) {
if (value instanceof Blob) {
const filename = typeof (value as File).name === 'string' ? (value as File).name : 'file'
const filePath = join(tempDir, filename)
const bytes = new Uint8Array(await value.arrayBuffer())
await writeFile(filePath, bytes)
formArgs.push('-F', `${key}=@${filePath};filename=${filename}`)
} else {
formArgs.push('-F', `${key}=${String(value)}`)
}
}
const curlArgs = [
'--silent',
'--show-error',
'--location',
'--max-time',
String(REQUEST_TIMEOUT_SECONDS),
'--write-out',
'\n%{http_code}',
'-X',
args.method,
...headers,
...formArgs,
url,
]
const result = spawnSync('curl', curlArgs, { encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(result.stderr || 'curl failed')
}
const output = result.stdout ?? ''
const splitAt = output.lastIndexOf('\n')
if (splitAt === -1) throw new Error('curl response missing status')
const body = output.slice(0, splitAt)
const status = Number(output.slice(splitAt + 1).trim())
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
if (status === 429 || status >= 500) {
throw new Error(body || `HTTP ${status}`)
}
throw new AbortError(body || `HTTP ${status}`)
}
return JSON.parse(body || 'null') as unknown
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
async function fetchBinaryViaCurl(url: string) {
const tempDir = await mkdtemp(join(tmpdir(), 'clawdhub-download-'))
const filePath = join(tempDir, 'payload.bin')
try {
const curlArgs = [
'--silent',
'--show-error',
'--location',
'--max-time',
String(REQUEST_TIMEOUT_SECONDS),
'-o',
filePath,
'--write-out',
'%{http_code}',
url,
]
const result = spawnSync('curl', curlArgs, { encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(result.stderr || 'curl failed')
}
const status = Number((result.stdout ?? '').trim())
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
const body = await readFileSafe(filePath)
const message = body ? new TextDecoder().decode(body) : `HTTP ${status}`
if (status === 429 || status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
}
const bytes = await readFileSafe(filePath)
return bytes ? new Uint8Array(bytes) : new Uint8Array()
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
async function readFileSafe(path: string) {
try {
const { readFile } = await import('node:fs/promises')
return await readFile(path)
} catch {
return null
}
}
@@ -0,0 +1,23 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import * as schema from '.'
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from './textFiles'
describe('packages/clawdhub schema textFiles', () => {
it('exports text-file extension set', () => {
expect(TEXT_FILE_EXTENSION_SET.has('md')).toBe(true)
expect(TEXT_FILE_EXTENSION_SET.has('exe')).toBe(false)
})
it('detects text content types with parameters', () => {
expect(isTextContentType('text/plain; charset=utf-8')).toBe(true)
expect(isTextContentType('application/json; charset=utf-8')).toBe(true)
expect(isTextContentType('application/octet-stream')).toBe(false)
})
it('re-exports helpers from index', () => {
expect(typeof schema.isTextContentType).toBe('function')
expect(schema.isTextContentType('application/markdown')).toBe(true)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test'
const port = Number(process.env.PLAYWRIGHT_PORT || 4173)
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${port}`
export default defineConfig({
testDir: './e2e',
testMatch: /.*\.pw\.test\.ts/,
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
use: {
baseURL,
trace: 'retain-on-failure',
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command: 'bun run preview -- --host 127.0.0.1 --port 4173',
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'ignore',
stderr: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${PLAYWRIGHT_PORT:-4173}"
if [[ -n "${PLAYWRIGHT_BASE_URL:-}" ]]; then
echo "Running against $PLAYWRIGHT_BASE_URL"
bun run test:pw
exit 0
fi
echo "Running against local preview server on http://127.0.0.1:$PORT"
bun run build
PLAYWRIGHT_PORT="$PORT" bun run test:pw
+2 -2
View File
@@ -79,8 +79,8 @@ function wrapText(value: string, maxChars: number, maxLines: number) {
}
export function buildSoulOgSvg(params: SoulOgSvgParams) {
const rawTitle = params.title.trim() || 'onlycrabs.ai'
const rawDescription = params.description.trim() || 'SOUL.md bundle on onlycrabs.ai.'
const rawTitle = params.title.trim() || 'SoulHub'
const rawDescription = params.description.trim() || 'SOUL.md bundle on SoulHub.'
const cardX = 72
const cardY = 96
+5 -6
View File
@@ -46,10 +46,9 @@ async function ensureWasm() {
await wasmInitPromise
}
function buildFooter(host: string | null, slug: string, owner: string | null) {
const base = host ? host : 'onlycrabs.ai'
if (owner) return `${base}/@${owner}/${slug}`
return `${base}/souls/${slug}`
function buildFooter(slug: string, owner: string | null) {
if (owner) return `@${owner}/${slug}`
return `souls/${slug}`
}
export default defineEventHandler(async (event) => {
@@ -75,9 +74,9 @@ export default defineEventHandler(async (event) => {
const title = titleFromQuery || meta?.displayName || slug
const description = descriptionFromQuery || meta?.summary || ''
const ownerLabel = owner ? `@${owner}` : 'onlycrabs.ai'
const ownerLabel = owner ? `@${owner}` : 'SoulHub'
const versionLabel = version ? `v${version}` : 'latest'
const footer = buildFooter(getRequestHost(event), slug, owner || null)
const footer = buildFooter(slug, owner || null)
const cacheKey = version ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'
setHeader(event, 'Cache-Control', cacheKey)
+125
View File
@@ -0,0 +1,125 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SkillsIndex } from '../routes/skills/index'
const navigateMock = vi.fn()
const useQueryMock = vi.fn()
const useActionMock = vi.fn()
let searchMock: Record<string, unknown> = {}
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock,
}),
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
}))
vi.mock('convex/react', () => ({
useAction: (...args: unknown[]) => useActionMock(...args),
useQuery: (...args: unknown[]) => useQueryMock(...args),
}))
describe('SkillsIndex', () => {
beforeEach(() => {
useQueryMock.mockReset()
useActionMock.mockReset()
navigateMock.mockReset()
searchMock = {}
useActionMock.mockReturnValue(() => Promise.resolve([]))
useQueryMock.mockReturnValue({ items: [], nextCursor: null })
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('requests the first skills page', () => {
render(<SkillsIndex />)
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), {
cursor: undefined,
limit: 50,
})
})
it('renders an empty state when no skills are returned', () => {
render(<SkillsIndex />)
expect(screen.getByText('No skills match that filter.')).toBeTruthy()
})
it('skips list query and calls search when query is set', async () => {
searchMock = { q: 'remind' }
const actionFn = vi.fn().mockResolvedValue([])
useActionMock.mockReturnValue(actionFn)
useQueryMock.mockReturnValue(undefined)
vi.useFakeTimers()
render(<SkillsIndex />)
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip')
await act(async () => {
await vi.runAllTimersAsync()
})
expect(actionFn).toHaveBeenCalledWith({
query: 'remind',
highlightedOnly: false,
limit: 50,
})
})
it('loads more results when search pagination is requested', async () => {
searchMock = { q: 'remind' }
vi.stubGlobal('IntersectionObserver', undefined)
const actionFn = vi
.fn()
.mockResolvedValueOnce(makeSearchResults(50))
.mockResolvedValueOnce(makeSearchResults(100))
useActionMock.mockReturnValue(actionFn)
useQueryMock.mockReturnValue(undefined)
vi.useFakeTimers()
render(<SkillsIndex />)
await act(async () => {
await vi.runAllTimersAsync()
})
const loadMoreButton = screen.getByRole('button', { name: 'Load more' })
await act(async () => {
fireEvent.click(loadMoreButton)
await vi.runAllTimersAsync()
})
expect(actionFn).toHaveBeenLastCalledWith({
query: 'remind',
highlightedOnly: false,
limit: 100,
})
})
})
function makeSearchResults(count: number) {
return Array.from({ length: count }, (_, index) => ({
score: 0.9,
skill: {
_id: `skill_${index}`,
slug: `skill-${index}`,
displayName: `Skill ${index}`,
summary: `Summary ${index}`,
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
version: null,
}))
}
+2 -2
View File
@@ -9,8 +9,8 @@ export function Footer() {
<div className="site-footer-row">
<div className="site-footer-copy">
{siteName} · A{' '}
<a href="https://clawdbot.com" target="_blank" rel="noreferrer">
ClawdBot
<a href="https://clawd.bot" target="_blank" rel="noreferrer">
Clawdbot
</a>{' '}
project ·{' '}
<a href="https://github.com/clawdbot/clawdhub" target="_blank" rel="noreferrer">
+6 -13
View File
@@ -5,7 +5,7 @@ import { Menu, Monitor, Moon, Sun } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { api } from '../../convex/_generated/api'
import { gravatarUrl } from '../lib/gravatar'
import { getClawdHubSiteUrl, getOnlyCrabsSiteUrl, getSiteMode, getSiteName } from '../lib/site'
import { getClawdHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
import { applyTheme, useThemeMode } from '../lib/theme'
import { startThemeTransition } from '../lib/theme-transition'
import {
@@ -26,7 +26,6 @@ export default function Header() {
const siteMode = getSiteMode()
const siteName = useMemo(() => getSiteName(siteMode), [siteMode])
const isSoulMode = siteMode === 'souls'
const onlyCrabsUrl = getOnlyCrabsSiteUrl()
const clawdHubUrl = getClawdHubSiteUrl()
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
@@ -57,11 +56,7 @@ export default function Header() {
<span className="brand-name">{siteName}</span>
</Link>
<nav className="nav-links">
{isSoulMode ? (
<a href={clawdHubUrl}>ClawdHub</a>
) : (
<a href={onlyCrabsUrl}>onlycrabs.ai</a>
)}
{isSoulMode ? <a href={clawdHubUrl}>ClawdHub</a> : null}
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
@@ -97,13 +92,11 @@ export default function Header() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
{isSoulMode ? (
{isSoulMode ? (
<DropdownMenuItem asChild>
<a href={clawdHubUrl}>ClawdHub</a>
) : (
<a href={onlyCrabsUrl}>onlycrabs.ai</a>
)}
</DropdownMenuItem>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link
to={isSoulMode ? '/souls' : '/skills'}
+3 -3
View File
@@ -35,7 +35,7 @@ describe('og helpers', () => {
summary: 'Personal north star notes.',
version: '0.1.0',
})
expect(meta.title).toBe('North Star — onlycrabs.ai')
expect(meta.title).toBe('North Star — SoulHub')
expect(meta.description).toBe('Personal north star notes.')
expect(meta.url).toContain('/souls/north-star')
expect(meta.owner).toBe('someone')
@@ -57,8 +57,8 @@ describe('og helpers', () => {
it('uses soul defaults when owner and summary are missing', () => {
const meta = buildSoulMeta({ slug: 'signal' })
expect(meta.title).toBe('signal — onlycrabs.ai')
expect(meta.description).toMatch(/onlycrabs\.ai — the home for SOUL.md/i)
expect(meta.title).toBe('signal — SoulHub')
expect(meta.description).toMatch(/SoulHub — the home for SOUL.md/i)
expect(meta.url).toContain('/souls/signal')
expect(meta.owner).toBeNull()
expect(meta.image).toContain('slug=signal')
+3 -4
View File
@@ -33,8 +33,7 @@ type SoulMeta = {
}
const DEFAULT_DESCRIPTION = 'ClawdHub — a fast skill registry for agents, with vector search.'
const DEFAULT_SOUL_DESCRIPTION =
'onlycrabs.ai — the home for SOUL.md bundles and personal system lore.'
const DEFAULT_SOUL_DESCRIPTION = 'SoulHub — the home for SOUL.md bundles and personal system lore.'
const OG_SKILL_IMAGE_LAYOUT_VERSION = '5'
const OG_SOUL_IMAGE_LAYOUT_VERSION = '1'
@@ -125,9 +124,9 @@ export function buildSoulMeta(source: SoulMetaSource): SoulMeta {
const displayName = clean(source.displayName) || clean(source.slug)
const summary = clean(source.summary)
const version = clean(source.version)
const title = `${displayName}onlycrabs.ai`
const title = `${displayName}SoulHub`
const description =
summary || (owner ? `Soul by @${owner} on onlycrabs.ai.` : DEFAULT_SOUL_DESCRIPTION)
summary || (owner ? `Soul by @${owner} on SoulHub.` : DEFAULT_SOUL_DESCRIPTION)
const url = `${siteUrl}/souls/${source.slug}`
const imageParams = new URLSearchParams()
imageParams.set('v', OG_SOUL_IMAGE_LAYOUT_VERSION)
+130
View File
@@ -0,0 +1,130 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectSiteMode,
detectSiteModeFromUrl,
getClawdHubSiteUrl,
getOnlyCrabsHost,
getOnlyCrabsSiteUrl,
getSiteDescription,
getSiteMode,
getSiteName,
getSiteUrlForMode,
} from './site'
function withMetaEnv<T>(values: Record<string, string | undefined>, run: () => T): T {
const env = import.meta.env as unknown as Record<string, unknown>
const previous = new Map<string, unknown>()
for (const [key, value] of Object.entries(values)) {
previous.set(key, env[key])
if (value === undefined) {
delete env[key]
} else {
env[key] = value
}
}
try {
return run()
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) delete env[key]
else env[key] = value
}
}
}
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
describe('site helpers', () => {
it('returns default and env configured site URLs', () => {
expect(getClawdHubSiteUrl()).toBe('https://clawdhub.com')
withMetaEnv({ VITE_SITE_URL: 'https://example.com' }, () => {
expect(getClawdHubSiteUrl()).toBe('https://example.com')
})
})
it('picks SoulHub URL from explicit env', () => {
withMetaEnv({ VITE_SOULHUB_SITE_URL: 'https://souls.example.com' }, () => {
expect(getOnlyCrabsSiteUrl()).toBe('https://souls.example.com')
})
})
it('derives SoulHub URL from local VITE_SITE_URL', () => {
withMetaEnv({ VITE_SITE_URL: 'http://localhost:3000' }, () => {
expect(getOnlyCrabsSiteUrl()).toBe('http://localhost:3000')
})
withMetaEnv({ VITE_SITE_URL: 'http://127.0.0.1:3000' }, () => {
expect(getOnlyCrabsSiteUrl()).toBe('http://127.0.0.1:3000')
})
withMetaEnv({ VITE_SITE_URL: 'http://0.0.0.0:3000' }, () => {
expect(getOnlyCrabsSiteUrl()).toBe('http://0.0.0.0:3000')
})
})
it('falls back to default SoulHub URL for invalid VITE_SITE_URL', () => {
withMetaEnv({ VITE_SITE_URL: 'not a url' }, () => {
expect(getOnlyCrabsSiteUrl()).toBe('https://onlycrabs.ai')
})
})
it('detects site mode from host and URLs', () => {
expect(detectSiteMode(null)).toBe('skills')
withMetaEnv({ VITE_SOULHUB_HOST: 'souls.example.com' }, () => {
expect(getOnlyCrabsHost()).toBe('souls.example.com')
expect(detectSiteMode('souls.example.com')).toBe('souls')
expect(detectSiteMode('sub.souls.example.com')).toBe('souls')
expect(detectSiteMode('clawdhub.com')).toBe('skills')
expect(detectSiteModeFromUrl('https://souls.example.com/x')).toBe('souls')
expect(detectSiteModeFromUrl('souls.example.com')).toBe('souls')
expect(detectSiteModeFromUrl('https://clawdhub.com')).toBe('skills')
})
})
it('detects site mode from window when available', () => {
withMetaEnv({ VITE_SOULHUB_HOST: 'onlycrabs.ai' }, () => {
vi.stubGlobal('window', { location: { hostname: 'onlycrabs.ai' } } as unknown as Window)
expect(getSiteMode()).toBe('souls')
})
})
it('detects site mode from env on the server', () => {
withMetaEnv({ VITE_SITE_MODE: 'souls', VITE_SOULHUB_HOST: 'onlycrabs.ai' }, () => {
expect(getSiteMode()).toBe('souls')
})
withMetaEnv({ VITE_SITE_MODE: 'skills', VITE_SOULHUB_HOST: 'onlycrabs.ai' }, () => {
expect(getSiteMode()).toBe('skills')
})
})
it('detects site mode from VITE_SOULHUB_SITE_URL and SITE_URL fallback', () => {
withMetaEnv(
{ VITE_SITE_MODE: undefined, VITE_SOULHUB_SITE_URL: 'https://onlycrabs.ai' },
() => {
expect(getSiteMode()).toBe('souls')
},
)
withMetaEnv({ VITE_SOULHUB_SITE_URL: undefined, VITE_SITE_URL: undefined }, () => {
vi.stubEnv('SITE_URL', 'https://onlycrabs.ai')
expect(getSiteMode()).toBe('souls')
})
})
it('derives site metadata from mode', () => {
expect(getSiteName('skills')).toBe('ClawdHub')
expect(getSiteName('souls')).toBe('SoulHub')
expect(getSiteDescription('skills')).toContain('ClawdHub')
expect(getSiteDescription('souls')).toContain('SoulHub')
expect(getSiteUrlForMode('skills')).toBe('https://clawdhub.com')
expect(getSiteUrlForMode('souls')).toBe('https://onlycrabs.ai')
})
})
+2 -2
View File
@@ -70,12 +70,12 @@ export function getSiteMode(): SiteMode {
}
export function getSiteName(mode: SiteMode = getSiteMode()) {
return mode === 'souls' ? 'onlycrabs.ai' : 'ClawdHub'
return mode === 'souls' ? 'SoulHub' : 'ClawdHub'
}
export function getSiteDescription(mode: SiteMode = getSiteMode()) {
return mode === 'souls'
? 'onlycrabs.ai — the home for SOUL.md bundles and personal system lore.'
? 'SoulHub — the home for SOUL.md bundles and personal system lore.'
: 'ClawdHub — a fast skill registry for agents, with vector search.'
}
+6 -6
View File
@@ -115,8 +115,8 @@ export interface FileRoutesByFullPath {
'/skills/$slug': typeof SkillsSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -169,8 +169,8 @@ export interface FileRouteTypes {
| '/skills/$slug'
| '/souls/$slug'
| '/u/$handle'
| '/skills'
| '/souls'
| '/skills/'
| '/souls/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -286,14 +286,14 @@ declare module '@tanstack/react-router' {
'/souls/': {
id: '/souls/'
path: '/souls'
fullPath: '/souls'
fullPath: '/souls/'
preLoaderRoute: typeof SoulsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/skills/': {
id: '/skills/'
path: '/skills'
fullPath: '/skills'
fullPath: '/skills/'
preLoaderRoute: typeof SkillsIndexRouteImport
parentRoute: typeof rootRouteImport
}
+10 -3
View File
@@ -103,9 +103,16 @@ function SkillsHome() {
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a skill
</Link>
<Link to="/search" search={{ q: undefined, highlighted: undefined }} className="btn">
<button
className="btn"
type="button"
onClick={() => {
setSearchMode(true)
inputRef.current?.focus()
}}
>
Explore search
</Link>
</button>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
@@ -336,7 +343,7 @@ function OnlyCrabsHome() {
<div className="hero-inner">
<div className="hero-copy fade-up" data-delay="1">
<span className="hero-badge">SOUL.md, shared.</span>
<h1 className="hero-title">onlycrabs.ai, where system lore lives.</h1>
<h1 className="hero-title">SoulHub, where system lore lives.</h1>
<p className="hero-subtitle">
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
public place.
+153 -27
View File
@@ -1,11 +1,12 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from 'convex/react'
import { useEffect, useMemo, useState } from 'react'
import { useAction, useQuery } from 'convex/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../../convex/_generated/api'
import type { Doc } from '../../../convex/_generated/dataModel'
import { SkillCard } from '../../components/SkillCard'
const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const
const pageSize = 50
type SortKey = (typeof sortKeys)[number]
type SortDir = 'asc' | 'desc'
@@ -33,7 +34,7 @@ export const Route = createFileRoute('/skills/')({
component: SkillsIndex,
})
function SkillsIndex() {
export function SkillsIndex() {
const navigate = Route.useNavigate()
const search = Route.useSearch()
const sort = search.sort ?? 'newest'
@@ -41,29 +42,107 @@ function SkillsIndex() {
const view = search.view ?? 'list'
const highlightedOnly = search.highlighted ?? false
const [query, setQuery] = useState(search.q ?? '')
const searchSkills = useAction(api.search.searchSkills)
const [pages, setPages] = useState<
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
>([])
const [cursor, setCursor] = useState<string | null>(null)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
>([])
const [searchLimit, setSearchLimit] = useState(pageSize)
const [isSearching, setIsSearching] = useState(false)
const searchRequest = useRef(0)
const loadMoreRef = useRef<HTMLDivElement | null>(null)
const items = useQuery(api.skills.listWithLatest, { limit: 500 }) as
| Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
const trimmedQuery = useMemo(() => query.trim(), [query])
const hasQuery = trimmedQuery.length > 0
const searchKey = trimmedQuery ? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}` : ''
const listPage = useQuery(
api.skills.listPublicPage,
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
) as
| {
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
nextCursor: string | null
}
| undefined
const isLoadingSkills = items === undefined
const isLoadingList = !hasQuery && pages.length === 0 && listPage === undefined
useEffect(() => {
setQuery(search.q ?? '')
}, [search.q])
const filtered = useMemo(() => {
const value = query.trim().toLowerCase()
const all = (items ?? []).filter((entry) =>
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
)
if (!value) return all
return all.filter((entry) => {
const skill = entry.skill
if (skill.slug.toLowerCase().includes(value)) return true
if (skill.displayName.toLowerCase().includes(value)) return true
return (skill.summary ?? '').toLowerCase().includes(value)
})
}, [highlightedOnly, query, items])
useEffect(() => {
if (hasQuery) return
setPages([])
setCursor(null)
setNextCursor(null)
}, [hasQuery])
useEffect(() => {
if (hasQuery || !listPage) return
setNextCursor(listPage.nextCursor)
setPages((prev) => (cursor ? [...prev, ...listPage.items] : listPage.items))
}, [cursor, hasQuery, listPage])
useEffect(() => {
if (!searchKey) {
setSearchResults([])
setIsSearching(false)
return
}
setSearchResults([])
setSearchLimit(pageSize)
}, [searchKey])
useEffect(() => {
if (!hasQuery) return
searchRequest.current += 1
const requestId = searchRequest.current
setIsSearching(true)
const handle = window.setTimeout(() => {
void (async () => {
try {
const data = (await searchSkills({
query: trimmedQuery,
highlightedOnly,
limit: searchLimit,
})) as Array<{
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
score: number
}>
if (requestId === searchRequest.current) {
setSearchResults(data)
}
} finally {
if (requestId === searchRequest.current) {
setIsSearching(false)
}
}
})()
}, 220)
return () => window.clearTimeout(handle)
}, [hasQuery, highlightedOnly, searchLimit, searchSkills, trimmedQuery])
const baseItems = useMemo(() => {
if (hasQuery) {
return searchResults.map((entry) => ({
skill: entry.skill,
latestVersion: entry.version,
}))
}
return pages
}, [hasQuery, pages, searchResults])
const filtered = useMemo(
() =>
baseItems.filter((entry) => (highlightedOnly ? entry.skill.batch === 'highlighted' : true)),
[baseItems, highlightedOnly],
)
const sorted = useMemo(() => {
const multiplier = dir === 'asc' ? 1 : -1
@@ -93,10 +172,39 @@ function SkillsIndex() {
return results
}, [dir, filtered, sort])
const showing = sorted.length
const total = items?.filter((entry) =>
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
).length
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
const canLoadMore = hasQuery
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
: nextCursor !== null
const isLoadingMore = hasQuery
? isSearching && searchResults.length > 0
: listPage === undefined && pages.length > 0
const canAutoLoad = typeof IntersectionObserver !== 'undefined'
const loadMore = useCallback(() => {
if (isLoadingMore || !canLoadMore) return
if (hasQuery) {
setSearchLimit((value) => value + pageSize)
} else if (nextCursor) {
setCursor(nextCursor)
}
}, [canLoadMore, hasQuery, isLoadingMore, nextCursor])
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === 'undefined') return
const target = loadMoreRef.current
if (!target) return
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadMore()
}
},
{ rootMargin: '200px' },
)
observer.observe(target)
return () => observer.disconnect()
}, [canLoadMore, loadMore])
return (
<main className="section">
@@ -108,9 +216,7 @@ function SkillsIndex() {
<p className="section-subtitle" style={{ marginBottom: 0 }}>
{isLoadingSkills
? 'Loading skills…'
: `${showing}${typeof total === 'number' ? ` of ${total}` : ''} skills${
highlightedOnly ? ' (highlighted)' : ''
}.`}
: `Browse the skill library${highlightedOnly ? ' (highlighted)' : ''}.`}
</p>
</div>
<div className="skills-toolbar">
@@ -209,7 +315,7 @@ function SkillsIndex() {
<div className="card">
<div className="loading-indicator">Loading skills</div>
</div>
) : showing === 0 ? (
) : sorted.length === 0 ? (
<div className="card">No skills match that filter.</div>
) : view === 'cards' ? (
<div className="grid">
@@ -276,6 +382,26 @@ function SkillsIndex() {
})}
</div>
)}
{canLoadMore ? (
<div
ref={canAutoLoad ? loadMoreRef : null}
className="card"
style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}
>
{canAutoLoad ? (
isLoadingMore ? (
'Loading more…'
) : (
'Scroll to load more'
)
) : (
<button className="btn" type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? 'Loading…' : 'Load more'}
</button>
)}
</div>
) : null}
</main>
)
}
+7 -2
View File
@@ -2357,7 +2357,7 @@ code {
color: var(--accent-deep);
}
.markdown code {
.markdown :not(pre) > code {
background: rgba(255, 107, 74, 0.12);
padding: 2px 6px;
border-radius: 8px;
@@ -2397,11 +2397,16 @@ code {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .markdown code {
[data-theme="dark"] .markdown :not(pre) > code {
background: rgba(255, 131, 95, 0.2);
color: #ffe9de;
}
[data-theme="dark"] .markdown pre code {
background: transparent;
color: inherit;
}
.fade-up {
animation: fadeUp 0.6s ease forwards;
opacity: 0;