Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andBunsDev bb66bef7a6 chore: sync vite lockfile to 8.0.5
Agent-Logs-Url: https://github.com/openclaw/clawhub/sessions/2886fd10-88d8-47b9-8e4f-bb3ea5a09f33

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-04-13 04:13:56 +00:00
copilot-swe-agent[bot]andBunsDev ba5410caa0 Merge pr-1646-work into copilot/resolve-conflicts-and-convos
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-04-13 04:05:23 +00:00
copilot-swe-agent[bot]andBunsDev 29674a11cf fix: resolve PR 1646 review feedback
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-04-13 04:03:25 +00:00
copilot-swe-agent[bot]andBunsDev 4c4ba9b832 Merge staging into pr-1646-work
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-04-13 03:58:42 +00:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 1e1f5a6e2c Update src/components/layout/Container.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 22:32:01 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> f9e4b95002 Update src/routes/plugins/index.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 22:29:25 -05:00
ImLukeF b16861f422 test: cover package detail stats response 2026-04-12 22:18:01 +10:00
Saurabh Jain 6896e61fa1 feat: include stats in package detail API response
Expose package detail stats through the shared API contract and the app client.

This lands the original package detail stats work and folds in the follow-up cleanup to keep the response shape sourced from the shared schema instead of a hand-maintained app-local type.

Co-authored-by: Saurabh Jain <saurabhjain1592@gmail.com>
2026-04-12 21:59:38 +10:00
Luke cf137aa592 fix: improve admin user search coverage (#1466)
* fix admin user search coverage

* fix admin user search without full table scan
2026-04-12 21:18:19 +10:00
Luke a28d014d4f fix: reduce souls browse overfetch (#1637) 2026-04-12 21:04:12 +10:00
b2038fc931 fix(cli-auth): ensure fallback token renders before redirect on Windows/Chrome (#1486)
* fix(cli-auth): ensure fallback token renders before redirect on Windows/Chrome

React batches state updates, so setToken() and window.location.assign()
previously raced: the navigation could fire before React re-rendered the
fallback token UI. On Chrome/Windows this means a failed http:// redirect
(ERR_CONNECTION_REFUSED, HTTPS-first interference) would replace the page
with an error screen before the user ever saw the token.

Use flushSync() to render the token synchronously, then attempt
window.location.assign(). If the redirect fails the token and a "Retry
redirect to CLI" link are already painted on screen.

Fixes #1469

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: cover cli auth fallback redirect

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-04-12 19:58:11 +10:00
loong 4a72b543b2 fix: point plugin metadata help link to OpenClaw docs (#1399)
* fix: point plugin metadata help link to OpenClaw docs

* fix: open plugin metadata docs in a new tab
2026-04-12 18:05:57 +10:00
JhollyandImLukeF 54e99c8cc2 docs: document CLI config paths across platforms (#1252)
* docs: document CLI config paths across platforms

* docs: clarify legacy config fallback

---------

Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-04-12 18:02:41 +10:00
Val Alexander 2af0b416a8 Merge pull request #2 from BunsDev/v0/bunsdev-586a3c32
Redesign ClawHub marketplace with new OpenClaw branding
2026-04-10 07:19:34 -05:00
v0andVal Alexander 8dff99de19 feat: optimize layout and create DESIGN.md
Fix orphan cards, enforce equal card heights, add branding accents, improve visual hierarchy, add responsive breakpoints, create design document

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 12:16:47 +00:00
v0andVal Alexander bbbdad73c5 feat: add user preferences customization section
Add 'usePreferences' hook and new Switch component; enhance settings page with Customization section.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 09:05:22 +00:00
v0andVal Alexander 18aea044fc feat: highlight parent tabs with activePathPrefixes
Add activePathPrefixes to NavItem and update navigation to highlight parent tabs.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 09:00:59 +00:00
v0andVal Alexander 2a4295c3ac fix: add global overflow protection to detail pages
Add overflow prevention for text elements and links.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 08:59:15 +00:00
v0andVal Alexander d761eb768d refactor: optimize skill detail page layout
Redesign skill detail page to maximize space, remove sidebar, create metadata bar, and add responsive breakpoints.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 08:57:08 +00:00
v0andVal Alexander f274b337bd fix: add defensive checks to PluginsIndex for SSR errors
Handle undefined loader data in PluginsIndex component.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 08:53:37 +00:00
v0andVal Alexander cbdfc327be fix: add defensive checks in fetchPluginCatalog
Ensure proper handling of undefined and unexpected API responses.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 08:52:13 +00:00
v0andVal Alexander df531f4742 feat: use Vite's native tsconfig paths
Replace deprecated plugin with native option and remove unused import.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 08:50:29 +00:00
v0andVal Alexander 21d6d01f13 feat: update branding to OpenClaw's black, white, and red color scheme
Implement new color scheme across dark, light themes and interactive elements

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 07:21:43 +00:00
v0andVal Alexander 98eb954145 feat: handle errors in Package API calls to prevent SSR and HMR errors
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 07:19:17 +00:00
v0andVal Alexander adc017aa4b fix: resolve SSR error bubbling in TanStack Router
Ensure loader errors don't escape SSR and hydrate correctly.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 07:16:53 +00:00
v0andVal Alexander d6996379c1 fix: resolve 500 errors in TanStack Router loaders
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 06:50:38 +00:00
v0andVal Alexander c1eb58e51f style: polish marketplace UI with modern design updates
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 05:44:10 +00:00
v0andVal Alexander 21e0bb16f0 feat: redesign ClawHub marketplace with modern utility store theme
Update styles.css and index.tsx for new modern design

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-10 05:22:00 +00:00
pqdongandmomothemage 5826001795 feat: Add support for Chinese Japanese and Korean(CJK) skills search (#1596)
Merged via squash.

Prepared head SHA: ab58f01be7
Co-authored-by: pq-dong <40668796+pq-dong@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-10 12:20:40 +08:00
Momo 0708a43fde fix: prevent starring soft-deleted skills and fix star count reconciliation (#1605) 2026-04-09 22:16:51 +08:00
Luke 311a123fbe refactor: address actionable review cleanup (#1601) 2026-04-09 20:00:25 +10:00
ImLukeF 59e93862ed fix: restore ci checks after lockfile drift 2026-04-09 19:45:51 +10:00
Luke 8fcd53f899 fix: detect exposed resource identifiers in skill scans (#1598) 2026-04-09 19:32:55 +10:00
Luke ba2c73e180 fix: detect generated-source template injection in skill scans (#1597) 2026-04-09 19:10:39 +10:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fa87dc3509 build(deps-dev): bump vite in the npm_and_yarn group across 1 directory (#1561)
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.0.1 to 8.0.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 08:21:53 -05:00
54 changed files with 3966 additions and 1026 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
## 0.10.0 - 2026-04-05
### Added
+356
View File
@@ -0,0 +1,356 @@
# ClawHub Design System
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
---
## Color System
### Brand Palette (OpenClaw)
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
### Rules
1. **Never exceed 5 colors** without explicit design approval
2. **Never use purple/violet prominently** unless explicitly requested
3. **Always override text color** when changing background color to ensure contrast
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
---
## Typography
### Font Stack
```css
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-display: 'Geist', system-ui, sans-serif;
```
### Scale
| Token | Size | Usage |
|-------|------|-------|
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
### Rules
1. **Maximum 2 font families** per page
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
3. **Never use decorative fonts** for body text
4. **Minimum font size: 14px** for readability
5. Use `text-balance` or `text-pretty` for titles
---
## Layout
### Method Priority
Use this hierarchy for layout decisions:
1. **Flexbox** - Default for most layouts
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
3. **Never use floats** or absolute positioning unless absolutely necessary
### Spacing Scale
```css
--space-1: 0.25rem /* 4px */
--space-2: 0.5rem /* 8px */
--space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */
--space-5: 1.5rem /* 24px */
--space-6: 2rem /* 32px */
```
### Grid Patterns
#### Auto-fit Grid (Recommended for Cards)
```css
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
```
- Automatically adjusts columns based on container width
- Prevents orphan items on partial rows
- Maintains consistent card widths
#### Fixed Grid (When exact columns needed)
```css
/* 3-column at desktop, 2 at tablet, 1 at mobile */
grid-template-columns: repeat(3, minmax(0, 1fr));
@media (max-width: 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
```
### Container Widths
| Size | Max Width | Usage |
|------|-----------|-------|
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
---
## Components
### Cards
```css
.card {
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
}
```
**Rules:**
- Always use `display: flex; flex-direction: column;` for consistent height
- Add `flex: 1` to content area for equal-height cards in grids
- Include hover state with `border-color` and subtle `box-shadow`
### Buttons
| Variant | Usage |
|---------|-------|
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
**Rules:**
- Always include visible focus state
- Minimum touch target: 44x44px on mobile
- Include `aria-label` when icon-only
### Form Controls
- Labels above inputs (not inline)
- Error states use `--status-error-fg`
- Focus rings use `--accent` with 0.2 opacity
- Minimum input height: 40px
---
## Responsive Breakpoints
```css
/* Mobile first - base styles for mobile */
@media (min-width: 520px) {
/* Small tablets, large phones */
}
@media (min-width: 640px) {
/* Tablets */
}
@media (min-width: 860px) {
/* Small desktops, landscape tablets */
}
@media (min-width: 1024px) {
/* Desktops */
}
@media (min-width: 1280px) {
/* Large desktops */
}
```
### Rules
1. **Mobile-first approach** - Base styles target mobile
2. **Progressive enhancement** - Add complexity as viewport increases
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
4. **Never hide critical content** on mobile
---
## Accessibility
### Color Contrast
- Normal text: Minimum 4.5:1 ratio
- Large text (18px+): Minimum 3:1 ratio
- Interactive elements: Minimum 3:1 ratio
### Focus States
```css
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
```
### Screen Readers
- Use `sr-only` class for visually hidden but accessible text
- Always include `alt` text for images (empty `alt=""` for decorative)
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
### Motion
```css
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## Animation
### Timing
```css
--transition-fast: 150ms;
--transition-base: 200ms;
--transition-slow: 300ms;
```
### Easing
- Use `ease` or `ease-out` for most transitions
- Use `ease-in-out` for enter/exit animations
- Never use `linear` except for continuous animations
### Rules
1. **Subtle by default** - Avoid flashy animations
2. **Purpose-driven** - Animation should provide feedback
3. **Respect preferences** - Support `prefers-reduced-motion`
4. **Performance** - Use `transform` and `opacity` only
---
## Icons
### Usage
- Use Lucide icons consistently
- Standard sizes: 14px, 16px, 20px, 24px
- Include `aria-hidden="true"` for decorative icons
- Never use emojis as icons
### Placement
- Left of labels in buttons and navigation
- Right of labels for external links or dropdowns
- Centered when used alone with `aria-label`
---
## Dark Mode
### Implementation
```css
[data-theme="dark"] {
/* Dark mode overrides */
}
```
### Rules
1. Never use pure white (`#ffffff`) on dark backgrounds
2. Reduce shadow intensity in dark mode
3. Adjust image brightness if needed
4. Test contrast ratios in both modes
---
## Performance
### CSS
1. Use CSS custom properties for theming
2. Avoid deeply nested selectors (max 3 levels)
3. Use `will-change` sparingly
4. Prefer `transform` over `top/left` for animations
### Images
1. Always specify `width` and `height` attributes
2. Use `loading="lazy"` for below-fold images
3. Use appropriate formats (WebP with fallbacks)
4. Include placeholder or skeleton states
---
## Code Style
### CSS Class Naming
```css
/* Component */
.component-name { }
/* Component modifier */
.component-name.variant { }
/* Component child */
.component-name-child { }
/* State */
.component-name.is-active { }
.component-name[data-state="open"] { }
```
### File Organization
```
src/
components/
ui/ # Primitive components (Button, Input, Card)
layout/ # Layout components (Container, Header)
styles.css # Global styles and design tokens
lib/
theme.ts # Theme utilities
preferences.ts # User preference management
```
---
## Checklist
Before shipping any UI changes, verify:
- [ ] Color contrast meets WCAG AA standards
- [ ] Focus states are visible
- [ ] Layout works at all breakpoints
- [ ] Animations respect `prefers-reduced-motion`
- [ ] Text is readable at default browser zoom
- [ ] Interactive elements have 44px minimum touch target
- [ ] Semantic HTML is used appropriately
- [ ] Dark mode has been tested
+157 -25
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -19,6 +20,8 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -31,6 +34,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -40,6 +44,8 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -50,6 +56,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -72,7 +79,7 @@
"oxlint-tsgolint": "^0.17.4",
"typescript": "^5.9.3",
"undici": "^7.24.7",
"vite": "8.0.1",
"vite": "8.0.5",
"vitest": "^4.1.2",
},
},
@@ -265,6 +272,56 @@
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -281,6 +338,24 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
@@ -297,7 +372,7 @@
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="],
@@ -439,7 +514,9 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "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-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@@ -473,35 +550,35 @@
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", {"os":"android","cpu":"arm64"}, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", {"os":"darwin","cpu":"arm64"}, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", {"os":"darwin","cpu":"x64"}, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", {"os":"freebsd","cpu":"x64"}, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", {"os":"linux","cpu":"arm"}, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"arm64","libc":"glibc"}, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", {"os":"linux","cpu":"arm64","libc":"musl"}, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"ppc64","libc":"glibc"}, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"s390x","libc":"glibc"}, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"x64","libc":"glibc"}, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", {"os":"linux","cpu":"x64","libc":"musl"}, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", {"os":"openharmony","cpu":"arm64"}, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", {"dependencies":{"@napi-rs/wasm-runtime":"^1.1.1"},"cpu":"wasm32"}, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", {"os":"win32","cpu":"arm64"}, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", {"os":"win32","cpu":"x64"}, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
@@ -535,6 +612,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
@@ -743,6 +822,8 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clawhub": ["clawhub@workspace:packages/clawhub"],
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
@@ -751,6 +832,8 @@
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
@@ -1095,6 +1178,10 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
@@ -1147,7 +1234,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
@@ -1205,7 +1292,7 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
"rolldown": ["rolldown@1.0.0-rc.12", "", {"dependencies":{"@oxc-project/types":"=0.122.0","@rolldown/pluginutils":"1.0.0-rc.12"},"optionalDependencies":{"@rolldown/binding-darwin-x64":"1.0.0-rc.12","@rolldown/binding-freebsd-x64":"1.0.0-rc.12","@rolldown/binding-wasm32-wasi":"1.0.0-rc.12","@rolldown/binding-darwin-arm64":"1.0.0-rc.12","@rolldown/binding-android-arm64":"1.0.0-rc.12","@rolldown/binding-linux-x64-gnu":"1.0.0-rc.12","@rolldown/binding-linux-x64-musl":"1.0.0-rc.12","@rolldown/binding-win32-x64-msvc":"1.0.0-rc.12","@rolldown/binding-linux-arm64-gnu":"1.0.0-rc.12","@rolldown/binding-linux-ppc64-gnu":"1.0.0-rc.12","@rolldown/binding-linux-s390x-gnu":"1.0.0-rc.12","@rolldown/binding-linux-arm64-musl":"1.0.0-rc.12","@rolldown/binding-win32-arm64-msvc":"1.0.0-rc.12","@rolldown/binding-openharmony-arm64":"1.0.0-rc.12","@rolldown/binding-linux-arm-gnueabihf":"1.0.0-rc.12"},"bin":{"rolldown":"bin/cli.mjs"}}, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
@@ -1223,6 +1310,8 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
@@ -1263,6 +1352,8 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -1307,6 +1398,8 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
@@ -1345,7 +1438,7 @@
"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@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
"vite": ["vite@8.0.5", "", {"dependencies":{"postcss":"^8.5.8","rolldown":"1.0.0-rc.12","picomatch":"^4.0.4","tinyglobby":"^0.2.15","lightningcss":"^1.32.0"},"optionalDependencies":{"fsevents":"~2.3.3"},"peerDependencies":{"tsx":"^4.8.1","jiti":">=1.21.0","less":"^4.0.0","sass":"^1.70.0","yaml":"^2.4.2","stylus":">=0.54.8","terser":"^5.16.0","esbuild":"^0.27.0 || ^0.28.0","sugarss":"^5.0.0","@types/node":"^20.19.0 || >=22.12.0","sass-embedded":"^1.70.0","@vitejs/devtools":"^0.1.0"},"bin":{"vite":"bin/vite.js"}}, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
@@ -1399,10 +1492,14 @@
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@@ -1415,14 +1512,14 @@
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1435,6 +1532,12 @@
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1449,6 +1552,8 @@
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -1491,6 +1596,8 @@
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1502,9 +1609,34 @@
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
}
}
+2
View File
@@ -77,6 +77,7 @@ import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skillQuality from "../lib/skillQuality.js";
import type * as lib_skillSafety from "../lib/skillSafety.js";
@@ -193,6 +194,7 @@ declare const fullApi: ApiFromModules<{
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
"lib/skillPublish": typeof lib_skillPublish;
"lib/skillQuality": typeof lib_skillQuality;
"lib/skillSafety": typeof lib_skillSafety;
+68
View File
@@ -237,6 +237,74 @@ xuezh snapshot --profile default
xuezh review next --limit 10
xuezh audio process-voice --file ./utterance.wav
\`\`\`
`,
},
{
slug: "hanzi-helper",
displayName: "汉字助手",
summary: "汉字学习与分析工具,支持笔画查询、部首检索和组词生成。",
version: "0.1.0",
metadata: {
clawdbot: {
nix: {
plugin: "github:example/hanzi-helper",
systems: ["aarch64-darwin", "x86_64-linux"],
},
config: {
requiredEnv: ["HANZI_DB_PATH"],
stateDirs: [".config/hanzi"],
example:
'config = { env = { HANZI_DB_PATH = ".config/hanzi/db"; }; stateDirs = [ ".config/hanzi" ]; };',
},
cliHelp: `汉字助手 - Chinese character learning and analysis
Usage:
hanzi-helper [command]
Available Commands:
lookup 查询汉字信息(笔画、部首、释义)
radical 按部首检索汉字
stroke 按笔画数筛选汉字
words 生成汉字组词
practice 练习汉字书写
quiz 汉字听写测试
Flags:
-h, --help help for hanzi-helper
--json Output JSON
`,
},
},
rawSkillMd: `---
name: hanzi-helper
description: 汉字学习与分析工具,提供笔画查询、部首检索、组词生成和汉字听写练习功能。
---
# 汉字助手
## 功能介绍
汉字助手是一个强大的中文汉字学习工具,帮助用户深入了解每个汉字的结构和含义。
## CLI
\`\`\`bash
hanzi-helper lookup --char 学
hanzi-helper radical --name 木
hanzi-helper stroke --count 8
hanzi-helper words --char 大 --limit 20
\`\`\`
## 使用场景
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
- **组词生成**:输入一个汉字,自动生成常用词语和成语
- **听写练习**:随机生成汉字听写测试,巩固学习效果
## 学习建议
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
`,
},
];
+16
View File
@@ -448,6 +448,22 @@ const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
["SSH_KEY_DIR"],
["generate", "rotate", "deploy", "list", "revoke"],
),
// CJK Language Support (2)
makeSkill(
"nihongo-check",
"日本語チェッカー",
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
["NIHONGO_API_KEY"],
["check", "translate", "kanji", "grammar", "vocabulary"],
),
makeSkill(
"hangukgeo-helper",
"한국어 도우미",
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
["HANGUL_API_KEY"],
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
),
];
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
+45
View File
@@ -2552,6 +2552,51 @@ describe("httpApiV1 handlers", () => {
});
});
it("packages detail returns stats for plugins", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:demo-plugin",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
summary: "Plugin summary",
latestVersion: "1.2.3",
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
createdAt: 1,
updatedAt: 2,
},
latestRelease: null,
owner: { _id: "users:owner", handle: "owner", displayName: "Owner" },
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/packages/demo-plugin"),
);
if (response.status !== 200) throw new Error(await response.text());
await expect(response.json()).resolves.toMatchObject({
package: {
name: "demo-plugin",
latestVersion: "1.2.3",
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
},
owner: {
handle: "owner",
},
});
});
it("packages file serves SKILL.md for skill README requests", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) return null;
+1
View File
@@ -1433,6 +1433,7 @@ type PublicPackageDocLike = {
compatibility?: Doc<"packages">["compatibility"];
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
stats?: { downloads: number; installs: number; stars: number; versions: number };
createdAt: number;
updatedAt: number;
};
+3 -2
View File
@@ -4,6 +4,7 @@ import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
import type { LlmEvalDimension } from "../lib/securityPrompt";
import { publishVersionForUser } from "../skills";
import {
MAX_RAW_FILE_BYTES,
@@ -206,7 +207,7 @@ type SkillSecuritySnapshot = {
normalizedStatus: NormalizedSecurityStatus;
confidence: string | null;
summary: string | null;
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | null;
dimensions: LlmEvalDimension[] | null;
guidance: string | null;
findings: string | null;
model: string | null;
@@ -262,7 +263,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
}
function hasLlmDimensionWarnings(
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | undefined,
dimensions: LlmEvalDimension[] | undefined,
) {
if (!Array.isArray(dimensions)) return false;
return dimensions.some((dimension) => {
+4 -1
View File
@@ -22,7 +22,10 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
skillId: skill._id,
});
return json(result, 200, rate.headers);
} catch {
} catch (e) {
if (e instanceof Error && e.message === "Skill not found") {
return text("Skill not found", 404, rate.headers);
}
return text("Unauthorized", 401, rate.headers);
}
}
+167
View File
@@ -92,6 +92,173 @@ describe("moderationEngine", () => {
expect(result.status).toBe("suspicious");
});
it("flags raw user placeholders embedded in generated Python source within markdown", () => {
const result = runStaticModerationScan({
slug: "word-document-organizer",
displayName: "Word Document Organizer",
summary: "Organize and restyle Word documents",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 512 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Generate a Python helper like this:",
"```python",
'doc_path = "${document_path}"',
'output_path = "${output_path}" if "${output_path}" else doc_path',
'template = "${style_template}"',
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.generated_source_template_injection");
expect(result.status).toBe("suspicious");
});
it("does not flag ordinary placeholder usage outside generated source assignments", () => {
const result = runStaticModerationScan({
slug: "api-docs",
displayName: "API Docs",
summary: "Shows users how to call an API",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Use this request template:",
"```bash",
'curl "https://example.com/search?q=${query}"',
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).not.toContain("suspicious.generated_source_template_injection");
expect(result.status).toBe("clean");
});
it("flags hardcoded connection_id UUIDs in markdown examples", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Use this payload:",
"```json",
'{"connection_id": "21fd90f9-5935-43cd-b6c8-bde9d915ca80"}',
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
expect(result.status).toBe("suspicious");
expect(
result.findings.find((finding) => finding.message.includes("connection_id"))?.message,
).toContain("connection_id");
});
it("flags hardcoded Google Sheets spreadsheet IDs in markdown examples", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Call the Sheets bridge like this:",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
expect(result.status).toBe("suspicious");
expect(
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.message,
).toContain("spreadsheet ID");
});
it("does not flag placeholder resource identifiers in markdown examples", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Use placeholders in public docs:",
"```json",
'{"connection_id": "YOUR_CONNECTION_ID"}',
"```",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).not.toContain("suspicious.exposed_resource_identifier");
expect(result.status).toBe("clean");
});
it("flags a real spreadsheet ID even when a placeholder URL appears first", () => {
const result = runStaticModerationScan({
slug: "api-gateway",
displayName: "API Gateway",
summary: "Route API calls through an authenticated gateway",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 512 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Placeholder example first:",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
"```",
"Real leaked URL later:",
"```python",
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
"```",
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
expect(
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.line,
).toBe(7);
});
it("blocks obfuscated terminal install payload prompts in markdown", () => {
const result = runStaticModerationScan({
slug: "evil-installer",
+67
View File
@@ -50,6 +50,14 @@ const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
const GENERATED_SOURCE_CONTEXT_PATTERN =
/```(?:python|py|javascript|js|typescript|ts|shell|bash|sh)\b|cat\s*(?:>|>>)?\s*[^`\n]*\.(?:py|js|ts|sh)\b|python3?\b|node\b/i;
const HARDCODED_CONNECTION_ID_PATTERN =
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
function hasMaliciousInstallPrompt(content: string) {
const hasTerminalInstruction =
@@ -75,6 +83,10 @@ function truncateEvidence(evidence: string, maxLen = 160) {
return `${evidence.slice(0, maxLen)}...`;
}
function looksLikePlaceholderIdentifier(identifier: string) {
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
}
function addFinding(
findings: ModerationFinding[],
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
@@ -92,6 +104,14 @@ function findFirstLine(content: string, pattern: RegExp) {
return { line: 1, text: lines[0] ?? "" };
}
function findLineAtIndex(content: string, index: number) {
const line = content.slice(0, index).split("\n").length;
const lineStart = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
const nextNewline = content.indexOf("\n", index);
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
return { line, text: content.slice(lineStart, lineEnd) };
}
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
if (!CODE_EXTENSION.test(path)) return;
@@ -227,6 +247,53 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
evidence: match.text,
});
}
if (
GENERATED_SOURCE_PLACEHOLDER_PATTERN.test(content) &&
GENERATED_SOURCE_CONTEXT_PATTERN.test(content)
) {
const match = findFirstLine(content, GENERATED_SOURCE_PLACEHOLDER_PATTERN);
addFinding(findings, {
code: REASON_CODES.GENERATED_SOURCE_TEMPLATE,
severity: "critical",
file: path,
line: match.line,
message: "User-controlled placeholder is embedded directly into generated source code.",
evidence: match.text,
});
}
if (HARDCODED_CONNECTION_ID_PATTERN.test(content)) {
const match = findFirstLine(content, HARDCODED_CONNECTION_ID_PATTERN);
addFinding(findings, {
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
severity: "critical",
file: path,
line: match.line,
message: "Example code exposes a concrete connection_id instead of a placeholder.",
evidence: match.text,
});
}
const spreadsheetUrlPattern = new RegExp(
GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.source,
`${GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.flags.replaceAll("g", "")}g`,
);
for (const spreadsheetUrlMatch of content.matchAll(spreadsheetUrlPattern)) {
const spreadsheetId = spreadsheetUrlMatch[1];
if (!spreadsheetId || looksLikePlaceholderIdentifier(spreadsheetId)) continue;
const match = findLineAtIndex(content, spreadsheetUrlMatch.index ?? 0);
addFinding(findings, {
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
severity: "critical",
file: path,
line: match.line,
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
evidence: match.text,
});
break;
}
}
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
+3 -1
View File
@@ -12,11 +12,13 @@ export type ModerationFinding = {
evidence: string;
};
export const MODERATION_ENGINE_VERSION = "v2.2.0";
export const MODERATION_ENGINE_VERSION = "v2.4.0";
export const REASON_CODES = {
DANGEROUS_EXEC: "suspicious.dangerous_exec",
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
EXFILTRATION: "suspicious.potential_exfiltration",
OBFUSCATED_CODE: "suspicious.obfuscated_code",
+3 -1
View File
@@ -5,6 +5,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
}
type SharedPackageKey = Extract<keyof Doc<"packages">, keyof Doc<"packageSearchDigest">>;
const SHARED_KEYS = [
"name",
"normalizedName",
@@ -22,7 +24,7 @@ const SHARED_KEYS = [
"softDeletedAt",
"createdAt",
"updatedAt",
] as const satisfies readonly (keyof Doc<"packages"> & keyof Doc<"packageSearchDigest">)[];
] as const satisfies readonly SharedPackageKey[];
const CAPABILITY_SHARED_KEYS = [
"packageId",
+51
View File
@@ -47,4 +47,55 @@ describe("searchText", () => {
it("normalize uses lowercase", () => {
expect(__test.normalize("AbC")).toBe("abc");
});
// CJK (Chinese, Japanese, Korean) support tests
describe("CJK tokenization", () => {
it("tokenizes Chinese text using Intl.Segmenter", () => {
const tokens = tokenize("中文搜索");
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("中文");
expect(tokens).toContain("搜索");
});
it("tokenizes mixed Chinese and English text", () => {
const tokens = tokenize("React 组件开发");
expect(tokens).toContain("react");
expect(tokens.some((t) => t.includes("组") || t.includes("件"))).toBe(true);
});
it("matches Chinese query tokens against Chinese skill names", () => {
const queryTokens = tokenize("翻译");
const skillName = "AI翻译助手";
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
});
it("matches partial Chinese words", () => {
const queryTokens = tokenize("助手");
const skillName = "AI翻译助手";
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
});
it("handles Japanese text", () => {
const tokens = tokenize("こんにちは世界");
expect(tokens.length).toBeGreaterThan(0);
});
it("handles Korean text", () => {
const tokens = tokenize("안녕하세요");
expect(tokens.length).toBeGreaterThan(0);
});
it("returns empty array for empty or whitespace-only input", () => {
expect(tokenize("")).toEqual([]);
expect(tokenize(" ")).toEqual([]);
expect(tokenize("!!!")).toEqual([]);
});
it("detects CJK language correctly", () => {
expect(__test.detectCJKLanguage("中文")).toBe("zh");
expect(__test.detectCJKLanguage("こんにちは")).toBe("ja");
expect(__test.detectCJKLanguage("안녕하세요")).toBe("ko");
expect(__test.detectCJKLanguage("hello")).toBeNull();
});
});
});
+130 -3
View File
@@ -1,12 +1,135 @@
const WORD_RE = /[a-z0-9]+/g;
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]/;
const hasSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
let zhSegmenter: Intl.Segmenter | null = null;
let jaSegmenter: Intl.Segmenter | null = null;
let koSegmenter: Intl.Segmenter | null = null;
function getZhSegmenter(): Intl.Segmenter {
if (!zhSegmenter) {
zhSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
}
return zhSegmenter;
}
function getJaSegmenter(): Intl.Segmenter {
if (!jaSegmenter) {
jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });
}
return jaSegmenter;
}
function getKoSegmenter(): Intl.Segmenter {
if (!koSegmenter) {
koSegmenter = new Intl.Segmenter("ko", { granularity: "word" });
}
return koSegmenter;
}
/**
* Fallback: split CJK text into individual characters.
* Used when Intl.Segmenter is unavailable (e.g. stripped V8 runtime).
*/
function segmentCJKByChar(text: string): string[] {
const tokens: string[] = [];
for (const ch of text) {
if (CJK_RE.test(ch)) {
tokens.push(ch);
}
}
return tokens;
}
function normalize(value: string) {
return value.toLowerCase();
}
/**
* Detect the primary CJK language in a text
* Returns 'zh' for Chinese, 'ja' for Japanese, 'ko' for Korean, or null
*/
function detectCJKLanguage(text: string): "zh" | "ja" | "ko" | null {
const chineseCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
const hiraganaCount = (text.match(/[\u3040-\u309f]/g) || []).length;
const katakanaCount = (text.match(/[\u30a0-\u30ff]/g) || []).length;
const hangulCount = (text.match(/[\uac00-\ud7af]/g) || []).length;
if (hiraganaCount + katakanaCount > 0) {
return "ja";
}
if (hangulCount > 0) {
return "ko";
}
if (chineseCount > 0) {
return "zh";
}
return null;
}
/**
* Segment CJK text using Intl.Segmenter, falling back to character-level
* tokenization when the API is unavailable.
*/
function segmentCJK(text: string): string[] {
if (!hasSegmenter) return segmentCJKByChar(text);
const lang = detectCJKLanguage(text);
if (!lang) return [];
let segmenter: Intl.Segmenter;
switch (lang) {
case "ja":
segmenter = getJaSegmenter();
break;
case "ko":
segmenter = getKoSegmenter();
break;
default:
segmenter = getZhSegmenter();
}
const segments: string[] = [];
for (const { segment, isWordLike } of segmenter.segment(text)) {
const trimmed = segment.trim();
if (trimmed && isWordLike) {
segments.push(trimmed);
}
}
return segments;
}
/**
* Tokenize text for search, supporting both English and CJK languages
*
* For English: uses word boundaries (whitespace, punctuation)
* For CJK: uses Intl.Segmenter for proper word segmentation
*/
export function tokenize(value: string): string[] {
if (!value) return [];
return normalize(value).match(WORD_RE) ?? [];
const normalized = normalize(value);
if (!CJK_RE.test(normalized)) {
return normalized.match(/[a-z0-9]+/g) ?? [];
}
const tokens: string[] = [];
const parts = normalized.split(/([^\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]+)/g);
for (const part of parts) {
if (!part.trim()) continue;
if (CJK_RE.test(part)) {
const cjkTokens = segmentCJK(part);
tokens.push(...cjkTokens);
} else {
const asciiTokens = part.match(/[a-z0-9]+/g) ?? [];
tokens.push(...asciiTokens);
}
}
return tokens;
}
export function matchesExactTokens(
@@ -24,4 +147,8 @@ export function matchesExactTokens(
);
}
export const __test = { normalize, tokenize, matchesExactTokens };
export const __test = {
normalize,
detectCJKLanguage,
segmentCJKByChar,
};
+3 -1
View File
@@ -6,6 +6,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
}
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
/**
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
@@ -35,7 +37,7 @@ const SHARED_KEYS = [
"moderationReason",
"createdAt",
"updatedAt",
] as const satisfies readonly (keyof Doc<"skills"> & keyof Doc<"skillSearchDigest">)[];
] as const satisfies readonly SharedSkillKey[];
/** Fields stored in the skillSearchDigest table. */
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
+25 -11
View File
@@ -4,6 +4,7 @@ import {
type PackageChannel,
type PackageFamily,
type PackagePublishRequest,
type PackageVerificationTier,
} from "clawhub-schema";
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v } from "convex/values";
@@ -127,7 +128,7 @@ type PublicPackageListItem = {
latestVersion: string | null;
capabilityTags: string[];
executesCode: boolean;
verificationTier: Doc<"packageSearchDigest">["verificationTier"] | null;
verificationTier: PackageVerificationTier | null;
};
type PackageDigestLike = Pick<
Doc<"packageSearchDigest">,
@@ -163,6 +164,14 @@ type PublicPageCursorState = {
};
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
function stringifyId(value: Id<"users"> | Id<"publishers">): string {
return value;
}
function stringifyOptionalId(value: Id<"publishers"> | null | undefined): string | null {
return value ? stringifyId(value) : null;
}
async function runQueryRef<T>(
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
ref: unknown,
@@ -212,6 +221,7 @@ type PublicPackageDoc = {
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
scanStatus?: Doc<"packages">["scanStatus"];
stats: Doc<"packages">["stats"];
createdAt: number;
updatedAt: number;
};
@@ -317,6 +327,7 @@ function toPublicPackage(
capabilities: pkg.capabilities,
verification: pkg.verification,
scanStatus: pkg.scanStatus,
stats: pkg.stats,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
};
@@ -2133,6 +2144,11 @@ export const insertReleaseInternal = internalMutation({
args.channel ??
(existing?.channel === "private" ? "private" : publisherTrusted ? "official" : "community");
const nextIsOfficial = nextChannel === "official";
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
const nextOwnerUserId = stringifyId(args.ownerUserId);
const nextName = args.name;
const nextRuntimeId = args.runtimeId ?? null;
const nextVersion = args.version;
if (existing) {
const existingIsLegacyPersonalPackage =
!existing.ownerPublisherId &&
@@ -2144,18 +2160,18 @@ export const insertReleaseInternal = internalMutation({
const existingOwnerKey = existing.ownerPublisherId
? `publisher:${existing.ownerPublisherId}`
: existingIsLegacyPersonalPackage
? `publisher:${args.ownerPublisherId}`
? `publisher:${nextOwnerPublisherId}`
: `user:${existing.ownerUserId}`;
const nextOwnerKey = args.ownerPublisherId
? `publisher:${args.ownerPublisherId}`
: `user:${args.ownerUserId}`;
const nextOwnerKey = nextOwnerPublisherId
? `publisher:${nextOwnerPublisherId}`
: `user:${nextOwnerUserId}`;
if (existingOwnerKey !== nextOwnerKey) {
throw new ConvexError("Package already exists and belongs to another publisher");
}
}
if (existing && existing.family !== args.family) {
throw new ConvexError(
`Package "${args.name}" already exists as a ${existing.family}; family changes are not allowed`,
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
);
}
if (
@@ -2166,7 +2182,7 @@ export const insertReleaseInternal = internalMutation({
existing.runtimeId !== args.runtimeId
) {
throw new ConvexError(
`Package "${args.name}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
);
}
if (args.family === "code-plugin" && args.runtimeId) {
@@ -2175,9 +2191,7 @@ export const insertReleaseInternal = internalMutation({
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
.unique();
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
throw new ConvexError(
`Plugin id "${args.runtimeId}" is already claimed by another package`,
);
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
}
}
@@ -2214,7 +2228,7 @@ export const insertReleaseInternal = internalMutation({
q.eq("packageId", existing._id).eq("version", args.version),
)
.unique();
if (releaseExists) throw new ConvexError(`Version ${args.version} already exists`);
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
}
const priorReleases = existing
? await ctx.db
+2 -1
View File
@@ -388,7 +388,8 @@ const souls = defineTable({
.index("by_slug", ["slug"])
.index("by_owner", ["ownerUserId"])
.index("by_owner_publisher", ["ownerPublisherId"])
.index("by_updated", ["updatedAt"]);
.index("by_updated", ["updatedAt"])
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
const skillVersions = defineTable({
skillId: v.id("skills"),
+123 -1
View File
@@ -5,7 +5,10 @@ vi.mock("@convex-dev/auth/server", () => ({
authTables: {},
}));
import { getPendingScanSkillsInternal } from "./skills";
import {
getActiveSkillBatchForStaticScanBackfillInternal,
getPendingScanSkillsInternal,
} from "./skills";
type PendingScanResult = Array<{
skillId: string;
@@ -25,6 +28,17 @@ const getPendingScanSkillsHandler = (
>
)._handler;
const getStaticScanBackfillBatchHandler = (
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
Record<string, unknown>,
{
skills: Array<{ skillId: string; versionId: string; slug: string }>;
nextCursor: number;
done: boolean;
}
>
)._handler;
describe("skills.getPendingScanSkillsInternal", () => {
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
const recentSkills = [
@@ -215,6 +229,114 @@ describe("skills.getPendingScanSkillsInternal", () => {
});
});
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
it("includes latest active skills with missing or stale static scan engine versions", async () => {
const skills = [
{
_id: "skills:missing-static",
_creationTime: 10,
softDeletedAt: undefined,
moderationStatus: "active",
latestVersionId: "skillVersions:missing-static",
slug: "missing-static",
},
{
_id: "skills:stale-static",
_creationTime: 20,
softDeletedAt: undefined,
moderationStatus: "active",
latestVersionId: "skillVersions:stale-static",
slug: "stale-static",
},
{
_id: "skills:current-static",
_creationTime: 30,
softDeletedAt: undefined,
moderationStatus: "active",
latestVersionId: "skillVersions:current-static",
slug: "current-static",
},
{
_id: "skills:hidden-static",
_creationTime: 40,
softDeletedAt: undefined,
moderationStatus: "hidden",
latestVersionId: "skillVersions:hidden-static",
slug: "hidden-static",
},
];
const versions = new Map<string, unknown>([
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
[
"skillVersions:stale-static",
{
_id: "skillVersions:stale-static",
staticScan: { engineVersion: "v2.2.0" },
},
],
[
"skillVersions:current-static",
{
_id: "skillVersions:current-static",
staticScan: { engineVersion: "v2.4.0" },
},
],
[
"skillVersions:hidden-static",
{
_id: "skillVersions:hidden-static",
staticScan: { engineVersion: "v2.2.0" },
},
],
]);
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table !== "skills") throw new Error(`unexpected table ${table}`);
return {
withIndex: (
indexName: string,
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
) => {
builder({ gt: () => ({}) });
if (indexName !== "by_creation_time") {
throw new Error(`unexpected index ${indexName}`);
}
return {
order: () => ({
take: async () => skills,
}),
};
},
};
}),
get: vi.fn(async (id: string) => versions.get(id) ?? null),
},
};
const result = await getStaticScanBackfillBatchHandler(ctx, {
batchSize: 10,
cursor: 0,
});
expect(result.skills).toEqual([
{
skillId: "skills:missing-static",
versionId: "skillVersions:missing-static",
slug: "missing-static",
},
{
skillId: "skills:stale-static",
versionId: "skillVersions:stale-static",
slug: "stale-static",
},
]);
expect(result.done).toBe(true);
});
});
function makeSkill(
id: string,
versionId: string,
+218 -3
View File
@@ -38,6 +38,7 @@ import { deriveModerationFlags } from "./lib/moderation";
import { buildModerationSnapshot } from "./lib/moderationEngine";
import {
legacyFlagsFromVerdict,
MODERATION_ENGINE_VERSION,
summarizeReasonCodes,
verdictFromCodes,
} from "./lib/moderationReasonCodes";
@@ -73,6 +74,7 @@ import {
publishVersionForUser,
queueHighlightedWebhook,
} from "./lib/skillPublish";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
import {
@@ -460,7 +462,7 @@ async function syncSkillModerationFromLatestVersion(
function buildConflictingSkillUrl(
skill: Doc<"skills">,
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
owner: SkillOwnerRef,
) {
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
const ownerParam = owner.handle?.trim() || String(owner._id);
@@ -470,7 +472,7 @@ function buildConflictingSkillUrl(
function buildSlugTakenErrorMessage(
skill: Doc<"skills">,
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
owner: SkillOwnerRef,
) {
if (!owner || owner.deletedAt || owner.deactivatedAt) {
return (
@@ -486,7 +488,7 @@ function buildSlugTakenErrorMessage(
function buildAliasTakenErrorMessage(
skill: Doc<"skills">,
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
owner: SkillOwnerRef,
) {
const base = "Slug redirects to an existing skill. Choose a different slug.";
const url = buildConflictingSkillUrl(skill, owner);
@@ -498,6 +500,16 @@ function normalizeSkillSlugKey(slug: string) {
return slug.trim().toLowerCase();
}
type SkillOwnerRef =
| {
_id: Id<"users"> | Id<"publishers">;
handle?: string | null;
deletedAt?: number | null;
deactivatedAt?: number | null;
}
| null
| undefined;
function normalizeSkillSlugForWrite(slug: string) {
const normalized = normalizeSkillSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
@@ -3686,6 +3698,56 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
},
});
/**
* Get active latest skill versions whose static scan is missing or uses an older engine version.
* Used to backfill new static rules onto already-published skills.
*/
export const getActiveSkillBatchForStaticScanBackfillInternal = internalQuery({
args: {
cursor: v.optional(v.number()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 25;
const cursor = args.cursor ?? 0;
const candidates = await ctx.db
.query("skills")
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
.order("asc")
.take(batchSize * 4);
const results: Array<{
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
slug: string;
}> = [];
let nextCursor = cursor;
for (const skill of candidates) {
nextCursor = skill._creationTime;
if (results.length >= batchSize) break;
if (skill.softDeletedAt) continue;
if ((skill.moderationStatus ?? "active") !== "active") continue;
if (!skill.latestVersionId) continue;
const version = await ctx.db.get(skill.latestVersionId);
if (!version) continue;
if (version.staticScan?.engineVersion === MODERATION_ENGINE_VERSION) continue;
results.push({
skillId: skill._id,
versionId: version._id,
slug: skill.slug,
});
}
const done = candidates.length < batchSize * 4;
return { skills: results, nextCursor, done };
},
});
/**
* Get skills with stale moderationReason that have vtAnalysis cached.
* Used to sync moderationReason with cached VT results.
@@ -3784,6 +3846,159 @@ export const getPendingVTSkillsInternal = internalQuery({
},
});
export const updateSkillVersionStaticScanInternal = internalMutation({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
staticScan: v.object({
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
reasonCodes: v.array(v.string()),
findings: v.array(
v.object({
code: v.string(),
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
file: v.string(),
line: v.number(),
message: v.string(),
evidence: v.string(),
}),
),
summary: v.string(),
engineVersion: v.string(),
checkedAt: v.number(),
}),
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
await ctx.db.patch(version._id, {
staticScan: args.staticScan,
});
const skill = await ctx.db.get(args.skillId);
if (!skill) return { ok: true as const, skipped: "missing" as const };
if (skill.latestVersionId !== version._id) {
return { ok: true as const, skipped: "not_latest" as const };
}
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
const now = Date.now();
const updatedVersion = { ...version, staticScan: args.staticScan };
const basePatch = buildScannerModerationPatchFromVersion({
owner,
version: updatedVersion,
now,
});
const patch = applySkillManualOverrideToSkillPatch({
skill,
basePatch: {
...basePatch,
updatedAt: now,
},
now,
});
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
await ctx.scheduler.runAfter(0, internal.users.placeUserUnderModerationInternal, {
ownerUserId: skill.ownerUserId,
slug: skill.slug,
reason:
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.")) ??
"malicious.static_scan",
});
}
return { ok: true as const, status: args.staticScan.status };
},
});
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
},
handler: async (ctx, args) => {
const [skill, version] = await Promise.all([
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
]);
if (!skill || !version) {
return { ok: true as const, skipped: "missing" as const };
}
const staticScan = await runStaticPublishScan(ctx, {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary ?? undefined,
frontmatter: version.parsed?.frontmatter ?? {},
metadata: version.parsed?.metadata,
files: version.files,
});
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
skillId: skill._id,
versionId: version._id,
staticScan,
});
},
});
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
args: {
cursor: v.optional(v.number()),
batchSize: v.optional(v.number()),
rescanned: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
cursor: args.cursor,
batchSize,
});
let rescanned = args.rescanned ?? 0;
for (const skill of batch.skills) {
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
skillId: skill.skillId,
versionId: skill.versionId,
});
rescanned += 1;
}
if (!batch.done) {
await ctx.scheduler.runAfter(0, internal.skills.backfillSkillStaticScansInternal, {
cursor: batch.nextCursor,
batchSize,
rescanned,
});
}
return {
rescanned,
nextCursor: batch.nextCursor,
done: batch.done,
};
},
});
export const backfillSkillStaticScans: ReturnType<typeof action> = action({
args: {
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const { user } = await requireUserFromAction(ctx);
assertAdmin(user);
return await ctx.runAction(internal.skills.backfillSkillStaticScansInternal, {
batchSize: args.batchSize,
});
},
});
/**
* Emergency escalation by skillId for legacy rows without sha256hash.
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
+74 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { getSoulBySlugInternal, insertVersion } from "./souls";
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
@@ -10,6 +10,7 @@ const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<
const getSoulBySlugInternalHandler = (
getSoulBySlugInternal as unknown as WrappedHandler<{ slug: string }>
)._handler;
const listHandler = (list as unknown as WrappedHandler<{ ownerUserId?: string; limit?: number }>)._handler;
describe("souls.insertVersion", () => {
it("throws a soul-specific ownership error for non-owners", async () => {
@@ -139,3 +140,75 @@ describe("souls.insertVersion", () => {
);
});
});
describe("souls.list", () => {
it("uses the active browse index and only takes the requested limit", async () => {
let requestedIndex: string | null = null;
let requestedSoftDeletedAt: number | undefined;
let requestedLimit: number | null = null;
const result = await listHandler(
{
db: {
query: vi.fn((table: string) => {
if (table !== "souls") throw new Error(`unexpected table ${table}`);
return {
withIndex: (
name: string,
build:
| ((q: { eq: (field: string, value: undefined) => unknown }) => unknown)
| undefined,
) => {
requestedIndex = name;
const q = {
eq: (field: string, value: undefined) => {
if (field !== "softDeletedAt") throw new Error(`unexpected field ${field}`);
requestedSoftDeletedAt = value;
return q;
},
};
build?.(q);
return {
order: () => ({
take: async (limit: number) => {
requestedLimit = limit;
return [
{
_id: "souls:1",
_creationTime: 1,
slug: "demo-soul",
displayName: "Demo Soul",
summary: "A demo soul",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
stats: { downloads: 1, stars: 2, versions: 3, comments: 4 },
createdAt: 1,
updatedAt: 2,
},
];
},
}),
};
},
};
}),
},
} as never,
{ limit: 7 } as never,
);
expect(requestedIndex).toBe("by_active_updated");
expect(requestedSoftDeletedAt).toBeUndefined();
expect(requestedLimit).toBe(7);
expect(result).toEqual([
expect.objectContaining({
_id: "souls:1",
slug: "demo-soul",
displayName: "Demo Soul",
}),
]);
});
});
+2 -3
View File
@@ -138,11 +138,10 @@ export const list = query({
}
const entries = await ctx.db
.query("souls")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.take(limit * 5);
.take(limit);
return entries
.filter((soul) => !soul.softDeletedAt)
.slice(0, limit)
.map((soul) => toPublicSoul(soul))
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
},
+3 -1
View File
@@ -34,6 +34,8 @@ export const toggle = mutation({
return { starred: false };
}
if (skill.softDeletedAt) throw new Error("Skill not found");
await ctx.db.insert("stars", {
skillId: args.skillId,
userId,
@@ -70,7 +72,7 @@ export const addStarInternal = internalMutation({
args: { userId: v.id("users"), skillId: v.id("skills") },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId);
if (!skill) throw new Error("Skill not found");
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
+4 -1
View File
@@ -230,8 +230,11 @@ export const reconcileSkillStarCounts = internalMutation({
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let scanned = 0;
let patched = 0;
for (const skill of page) {
if (skill.softDeletedAt) continue;
scanned += 1;
// Count actual star records for this skill
const starRecords = await ctx.db
.query("stars")
@@ -263,7 +266,7 @@ export const reconcileSkillStarCounts = internalMutation({
}
return {
scanned: page.length,
scanned,
patched,
cursor: isDone ? null : continueCursor,
isDone,
+75
View File
@@ -1075,6 +1075,81 @@ describe("users.list", () => {
expect(result.items[0]?.handle).toBe("alice");
});
it("includes an exact older handle match outside the bounded scan", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const users = [
...Array.from({ length: 500 }, (_value, index) => ({
_id: `users:recent-${index}`,
_creationTime: 10_000 - index,
handle: `recent-${index}`,
role: "user",
})),
{ _id: "users:older", _creationTime: 1, handle: "alice", role: "user" },
];
const { ctx, take, collect } = makeListCtx(users);
const listHandler = (
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
)._handler;
const result = (await listHandler(ctx, { limit: 50, search: "alice" })) as {
items: Array<Record<string, unknown>>;
total: number;
};
expect(take).toHaveBeenCalledWith(500);
expect(collect).not.toHaveBeenCalled();
expect(result.total).toBe(1);
expect(result.items[0]?._id).toBe("users:older");
});
it("includes an exact personal publisher handle match without a full collect", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const users = [{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" }];
const { ctx, take, collect } = makeListCtx(users, {
publishersByHandle: {
lmlukef: {
_id: "publishers:lmlukef",
kind: "user",
handle: "lmlukef",
linkedUserId: "users:owner",
},
},
usersById: {
"users:owner": {
_id: "users:owner",
_creationTime: 1,
handle: "luke",
name: "different-gh-login",
displayName: "Luke",
role: "user",
},
},
});
const listHandler = (
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
)._handler;
const result = (await listHandler(ctx, { limit: 50, search: "lmLukeF" })) as {
items: Array<Record<string, unknown>>;
total: number;
};
expect(take).toHaveBeenCalledWith(500);
expect(collect).not.toHaveBeenCalled();
expect(result.total).toBe(1);
expect(result.items[0]).toMatchObject({
_id: "users:owner",
handle: "luke",
displayName: "Luke",
});
});
it("clamps large limit and search scan size", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
+3
View File
@@ -62,6 +62,9 @@ When no proxy variable is set, behavior is unchanged (direct connections).
Stores your API token + cached registry URL.
- macOS: `~/Library/Application Support/clawhub/config.json`
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
- Windows: `%APPDATA%\\clawhub\\config.json`
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path
- override: `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`)
## Commands
+8 -1
View File
@@ -7,6 +7,7 @@
"type": "module",
"scripts": {
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
@@ -45,6 +46,8 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -57,6 +60,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.34.1",
@@ -66,6 +70,8 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -76,6 +82,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
@@ -98,7 +105,7 @@
"oxlint-tsgolint": "^0.17.4",
"typescript": "^5.9.3",
"undici": "^7.24.7",
"vite": "8.0.1",
"vite": "8.0.5",
"vitest": "^4.1.2"
}
}
+6 -1
View File
@@ -27,7 +27,12 @@ clawhub login --token clh_...
Notes:
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
- Default config path:
- macOS: `~/Library/Application Support/clawhub/config.json`
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
- Windows: `%APPDATA%\\clawhub\\config.json`
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path.
- Override via `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`).
## Examples
+9
View File
@@ -57,6 +57,14 @@ export const PackageVerificationSummarySchema = type({
});
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export const PackageStatsSchema = type({
downloads: "number",
installs: "number",
stars: "number",
versions: "number",
});
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export const PackageVtAnalysisSchema = type({
status: "string",
verdict: "string?",
@@ -188,6 +196,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
handle: "string|null",
+13
View File
@@ -46,6 +46,13 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
}, {}>;
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export declare const PackageStatsSchema: import("arktype/internal/variants/object.ts").ObjectType<{
downloads: number;
installs: number;
stars: number;
versions: number;
}, {}>;
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
status: string;
checkedAt: number;
@@ -261,6 +268,12 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
hasProvenance?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
} | null | undefined;
stats?: {
downloads: number;
installs: number;
stars: number;
versions: number;
} | undefined;
} | null;
owner: {
handle: string | null;
+7
View File
@@ -40,6 +40,12 @@ export const PackageVerificationSummarySchema = type({
hasProvenance: "boolean?",
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
});
export const PackageStatsSchema = type({
downloads: "number",
installs: "number",
stars: "number",
versions: "number",
});
export const PackageVtAnalysisSchema = type({
status: "string",
verdict: "string?",
@@ -151,6 +157,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
handle: "string|null",
File diff suppressed because one or more lines are too long
+9
View File
@@ -57,6 +57,14 @@ export const PackageVerificationSummarySchema = type({
});
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
export const PackageStatsSchema = type({
downloads: "number",
installs: "number",
stars: "number",
versions: "number",
});
export type PackageStats = (typeof PackageStatsSchema)[inferred];
export const PackageVtAnalysisSchema = type({
status: "string",
verdict: "string?",
@@ -190,6 +198,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
handle: "string|null",
@@ -277,6 +277,12 @@ describe("plugins publish route", () => {
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i });
expect(docsLink.getAttribute("href")).toBe(
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata",
);
expect(docsLink.getAttribute("target")).toBe("_blank");
expect(docsLink.getAttribute("rel")).toBe("noopener noreferrer");
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
});
+22 -11
View File
@@ -1,5 +1,5 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
@@ -42,6 +42,7 @@ export default function Header() {
const isSoulMode = siteMode === "souls";
const clawHubUrl = getClawHubSiteUrl();
const navigate = useNavigate();
const location = useLocation();
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
const handle = me?.handle ?? me?.displayName ?? "user";
@@ -277,12 +278,16 @@ export default function Header() {
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
@@ -291,16 +296,22 @@ export default function Header() {
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
))}
{secondaryItems.map((item) => {
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
data-status={isActiveByPrefix ? "active" : undefined}
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
);
})}
</div>
</nav>
</div>
+9 -1
View File
@@ -8,6 +8,9 @@ import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
const OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL =
'https://docs.openclaw.ai/plugins/sdk-setup#package-metadata';
export function PackageSourceChooser(props: {
files: File[];
totalBytes: number;
@@ -155,7 +158,12 @@ export function PackageSourceChooser(props: {
<Badge variant="accent">
Missing required OpenClaw package metadata: {props.codePluginFieldIssues.join(", ")}. Add
these fields to <code>package.json</code> before publishing. See{" "}
<a href="/plugins/sdk-setup#package-metadata" className="underline">
<a
href={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Plugin Setup and Config
</a>
.
+66 -68
View File
@@ -401,78 +401,76 @@ export function SkillDetailPage({
/>
) : null}
<div className="detail-layout">
<div className="detail-main">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
</div>
+61 -82
View File
@@ -3,7 +3,7 @@ import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Package, Star } from "lucide-react";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
@@ -39,104 +39,83 @@ export function SkillMetadataSidebar({
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<aside className="detail-sidebar">
{/* Download / Install */}
{!nixPlugin && !isMalwareBlocked && !isRemoved ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Download</h3>
<Button asChild variant="primary" className="w-full justify-center">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
Download zip
</a>
</Button>
<div className="detail-meta-bar">
{/* Stats row */}
<div className="meta-bar-stats">
<div className="meta-stat">
<Download size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
<span className="meta-stat-label">downloads</span>
</div>
) : null}
{/* Stats */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Stats</h3>
<div className="sidebar-stat-grid">
<div className="sidebar-stat">
<span className="sidebar-stat-value">
<Package size={14} aria-hidden="true" />
{formatCompactStat(skill.stats.downloads)}
</span>
<span className="sidebar-stat-label">Downloads</span>
</div>
<div className="sidebar-stat">
<span className="sidebar-stat-value">
<Star size={14} aria-hidden="true" />
{formatCompactStat(skill.stats.stars)}
</span>
<span className="sidebar-stat-label">Stars</span>
</div>
<div className="sidebar-stat">
<span className="sidebar-stat-value">
{formatCompactStat(skill.stats.versions ?? 0)}
</span>
<span className="sidebar-stat-label">Versions</span>
</div>
<div className="meta-stat">
<Star size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
<span className="meta-stat-label">stars</span>
</div>
<div className="meta-stat">
<Package size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
<span className="meta-stat-label">versions</span>
</div>
</div>
{/* Metadata */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Details</h3>
<dl className="sidebar-metadata">
<div className="sidebar-metadata-row">
<dt>Updated</dt>
<dd>{timeAgo(skill.updatedAt)}</dd>
{/* Details row */}
<div className="meta-bar-details">
<div className="meta-detail">
<Calendar size={12} aria-hidden="true" />
<span>Updated {timeAgo(skill.updatedAt)}</span>
</div>
{latestVersion?.version ? (
<div className="meta-detail">
<Tag size={12} aria-hidden="true" />
<span>v{latestVersion.version}</span>
</div>
<div className="sidebar-metadata-row">
<dt>Created</dt>
<dd>{timeAgo(skill.createdAt)}</dd>
) : null}
<div className="meta-detail">
<Scale size={12} aria-hidden="true" />
<span>{PLATFORM_SKILL_LICENSE}</span>
</div>
{osLabels.length > 0 ? (
<div className="meta-detail">
<span>{osLabels.join(", ")}</span>
</div>
{latestVersion?.version ? (
<div className="sidebar-metadata-row">
<dt>Version</dt>
<dd>v{latestVersion.version}</dd>
</div>
) : null}
<div className="sidebar-metadata-row">
<dt>License</dt>
<dd>{PLATFORM_SKILL_LICENSE} ({PLATFORM_SKILL_LICENSE_SUMMARY})</dd>
</div>
{osLabels.length ? (
<div className="sidebar-metadata-row">
<dt>Platforms</dt>
<dd>{osLabels.join(", ")}</dd>
</div>
) : null}
</dl>
) : null}
</div>
{/* Tags */}
{tagEntries.length > 0 ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Tags</h3>
<div className="sidebar-tags">
{/* Tags and Publisher row */}
<div className="meta-bar-footer">
<div className="meta-bar-publisher">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="sm"
showName
/>
</div>
{tagEntries.length > 0 ? (
<div className="meta-bar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
</div>
) : null}
) : null}
{/* Owner */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Publisher</h3>
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
/>
{showDownload ? (
<Button asChild variant="primary" size="sm">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
<Download size={14} aria-hidden="true" />
Download
</a>
</Button>
) : null}
</div>
</aside>
</div>
);
}
+14 -14
View File
@@ -2,23 +2,23 @@ import * as React from "react";
import { cn } from "../../lib/utils";
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "default" | "narrow" | "wide";
size?: "default" | "narrow" | "wide";
}
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7 overflow-x-hidden break-words",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
);
Container.displayName = "Container";
+32
View File
@@ -0,0 +1,32 @@
import * as SwitchPrimitive from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
"transition-transform duration-200 ease-out",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };
+5
View File
@@ -23,6 +23,8 @@ export interface NavItem {
soulModeOnly: boolean;
/** Link hidden when siteMode === "souls" */
soulModeHide: boolean;
/** Additional path prefixes that should also highlight this nav item (e.g. /skill for /skills) */
activePathPrefixes?: string[];
}
// ---------------------------------------------------------------------------
@@ -68,6 +70,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
activePathPrefixes: ["/skill/"],
},
{
label: "Plugins",
@@ -77,6 +80,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
activePathPrefixes: ["/plugin/"],
},
{
label: "Souls",
@@ -88,6 +92,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
soulModeOnly: false,
// In soul-mode this is the primary tab; in skills-mode it is also shown.
soulModeHide: false,
activePathPrefixes: ["/soul/"],
},
];
+39
View File
@@ -162,6 +162,45 @@ describe("fetchPackages", () => {
});
});
it("preserves package stats from package detail responses", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 2,
tags: {},
stats: {
downloads: 7,
installs: 3,
stars: 2,
versions: 4,
},
},
owner: null,
}),
{ status: 200 },
),
);
await expect(fetchPackageDetail("demo-plugin")).resolves.toMatchObject({
package: {
stats: {
downloads: 7,
installs: 3,
stars: 2,
versions: 4,
},
},
});
});
it("forwards request cookies and includes credentials for package detail fetches", async () => {
vi.stubEnv("VITE_CONVEX_SITE_URL", "https://app.example");
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
+3 -25
View File
@@ -1,4 +1,5 @@
import type {
ApiV1PackageResponse,
PackageCapabilitySummary,
PackageCompatibility,
PackageVerificationSummary,
@@ -24,30 +25,7 @@ export type PackageListItem = {
verificationTier?: string | null;
};
export type PackageDetailResponse = {
package: {
_id?: string;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
runtimeId?: string | null;
channel: "official" | "community" | "private";
isOfficial: boolean;
summary?: string | null;
latestVersion?: string | null;
createdAt: number;
updatedAt: number;
tags: Record<string, string>;
compatibility?: PackageCompatibility | null;
capabilities?: PackageCapabilitySummary | null;
verification?: PackageVerificationSummary | null;
} | null;
owner: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
};
export type PackageDetailResponse = ApiV1PackageResponse;
export type PackageVersionDetail = {
package: {
@@ -353,7 +331,7 @@ export async function fetchPluginCatalog(params: {
return await fetchJson<PluginCatalogResult>(url);
}
export async function fetchPackageDetail(name: string) {
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404) {
+171
View File
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";
export type LayoutDensity = "comfortable" | "compact";
export type ListViewMode = "grid" | "list";
export type SidebarPosition = "left" | "right";
export type CodeFontSize = "small" | "medium" | "large";
export type AnimationLevel = "full" | "reduced" | "none";
export interface UserPreferences {
layoutDensity: LayoutDensity;
listViewMode: ListViewMode;
showDescriptions: boolean;
showStats: boolean;
showTags: boolean;
advancedMode: boolean;
sidebarPosition: SidebarPosition;
stickyHeader: boolean;
codeFontSize: CodeFontSize;
lineNumbers: boolean;
wordWrap: boolean;
animationLevel: AnimationLevel;
reducedMotion: boolean;
highContrast: boolean;
emailNotifications: boolean;
browserNotifications: boolean;
experimentalFeatures: boolean;
}
const defaultPreferences: UserPreferences = {
layoutDensity: "comfortable",
listViewMode: "grid",
showDescriptions: true,
showStats: true,
showTags: true,
advancedMode: false,
sidebarPosition: "right",
stickyHeader: true,
codeFontSize: "medium",
lineNumbers: true,
wordWrap: true,
animationLevel: "full",
reducedMotion: false,
highContrast: false,
emailNotifications: true,
browserNotifications: false,
experimentalFeatures: false,
};
const listeners = new Set<() => void>();
let cachedStoredPreferences: string | null = null;
let cachedSnapshot = defaultPreferences;
function readPreferencesSnapshot(stored: string | null): UserPreferences {
if (!stored) return defaultPreferences;
try {
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
return { ...defaultPreferences, ...parsed };
} catch {
return defaultPreferences;
}
}
function getStoredPreferences(): UserPreferences {
if (typeof window === "undefined") return defaultPreferences;
const stored = window.localStorage.getItem(PREFERENCES_KEY);
if (stored === cachedStoredPreferences) {
return cachedSnapshot;
}
cachedStoredPreferences = stored;
cachedSnapshot = readPreferencesSnapshot(stored);
return cachedSnapshot;
}
function notifyListeners() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
const handleStorage = (event: StorageEvent) => {
if (event.key !== PREFERENCES_KEY) return;
cachedStoredPreferences = event.newValue;
cachedSnapshot = readPreferencesSnapshot(event.newValue);
listener();
};
if (typeof window !== "undefined") {
window.addEventListener("storage", handleStorage);
}
return () => {
listeners.delete(listener);
if (typeof window !== "undefined") {
window.removeEventListener("storage", handleStorage);
}
};
}
function savePreferences(prefs: UserPreferences) {
if (typeof window === "undefined") return;
try {
const serialized = JSON.stringify(prefs);
window.localStorage.setItem(PREFERENCES_KEY, serialized);
cachedStoredPreferences = serialized;
cachedSnapshot = prefs;
notifyListeners();
} catch {
// Storage might be full or disabled.
}
}
function getServerSnapshot(): UserPreferences {
return defaultPreferences;
}
export function usePreferences() {
const preferences = useSyncExternalStore(subscribe, getStoredPreferences, getServerSnapshot);
const updatePreference = useCallback(
<K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
const current = getStoredPreferences();
const updated = { ...current, [key]: value };
savePreferences(updated);
},
[],
);
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
const current = getStoredPreferences();
const updated = { ...current, ...updates };
savePreferences(updated);
}, []);
const resetPreferences = useCallback(() => {
savePreferences(defaultPreferences);
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.density = preferences.layoutDensity;
root.dataset.animation = preferences.animationLevel;
root.classList.toggle("high-contrast", preferences.highContrast);
root.classList.toggle(
"reduce-motion",
preferences.reducedMotion || preferences.animationLevel === "none",
);
root.style.setProperty(
"--code-font-size",
preferences.codeFontSize === "small"
? "12px"
: preferences.codeFontSize === "large"
? "16px"
: "14px",
);
}, [preferences]);
return {
preferences,
updatePreference,
updatePreferences,
resetPreferences,
isAdvancedMode: preferences.advancedMode,
};
}
export { defaultPreferences };
+10 -14
View File
@@ -6,33 +6,29 @@ let authError: string | null = null;
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) listener();
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getAuthErrorSnapshot() {
return authError;
return authError;
}
export function setAuthError(error: string | null) {
if (authError === error) return;
authError = error;
emitChange();
if (authError === error) return;
authError = error;
emitChange();
}
export function clearAuthError() {
setAuthError(null);
setAuthError(null);
}
export function useAuthError() {
const error = useSyncExternalStore(
subscribe,
getAuthErrorSnapshot,
getAuthErrorSnapshot,
);
return { error, clear: clearAuthError };
const error = useSyncExternalStore(subscribe, getAuthErrorSnapshot, getAuthErrorSnapshot);
return { error, clear: clearAuthError };
}
+113
View File
@@ -0,0 +1,113 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const createTokenMock = vi.fn();
const clearAuthErrorMock = vi.fn();
let mockSearch: {
redirect_uri?: string;
label?: string;
label_b64?: string;
state?: string;
} = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component: unknown }) => ({
...config,
useSearch: () => mockSearch,
}),
}));
vi.mock("convex/react", () => ({
useMutation: () => createTokenMock,
}));
vi.mock("../../../convex/_generated/api", () => ({
api: {
tokens: {
create: "tokens.create",
},
},
}));
vi.mock("../../lib/useAuthStatus", () => ({
useAuthStatus: () => ({
isAuthenticated: true,
isLoading: false,
me: { _id: "user_123" },
}),
}));
vi.mock("../../lib/useAuthError", () => ({
useAuthError: () => ({
error: null,
clear: clearAuthErrorMock,
}),
}));
vi.mock("../../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
normalizeClawHubSiteOrigin: () => "https://clawhub.ai",
}));
vi.mock("../../components/layout/Container", () => ({
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../components/SignInButton", () => ({
SignInButton: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
}));
vi.mock("../../components/ui/card", () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
}));
const { CliAuth } = await import("./auth");
describe("CliAuth", () => {
const assignSpy = vi.fn();
beforeEach(() => {
createTokenMock.mockReset();
clearAuthErrorMock.mockReset();
assignSpy.mockReset();
mockSearch = {
redirect_uri: "http://127.0.0.1:43110/callback",
state: "state_123",
label: "CLI token",
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the fallback token and retry link before attempting redirect", async () => {
createTokenMock.mockResolvedValue({ token: "clh_test_token" });
render(<CliAuth navigate={assignSpy} />);
await waitFor(() => {
expect(createTokenMock).toHaveBeenCalledWith({ label: "CLI token" });
});
await waitFor(() => {
expect(assignSpy).toHaveBeenCalledWith(
"http://127.0.0.1:43110/callback#token=clh_test_token&registry=https%3A%2F%2Fclawhub.ai&state=state_123",
);
});
expect(screen.getByText(/copy this token and run/i)).toBeTruthy();
expect(screen.getByText("clh_test_token")).toBeTruthy();
expect(screen.getByText(/Redirecting to CLI/i)).toBeTruthy();
expect(screen.getByRole("link", { name: /Retry redirect to CLI/i }).getAttribute("href")).toBe(
"http://127.0.0.1:43110/callback#token=clh_test_token&registry=https%3A%2F%2Fclawhub.ai&state=state_123",
);
});
});
+28 -6
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { api } from "../../../convex/_generated/api";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
@@ -13,7 +14,11 @@ export const Route = createFileRoute("/cli/auth")({
component: CliAuth,
});
function CliAuth() {
type CliAuthProps = {
navigate?: (url: string) => void;
};
export function CliAuth({ navigate = (url: string) => window.location.assign(url) }: CliAuthProps = {}) {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { error: authError, clear: clearAuthError } = useAuthError();
const createToken = useMutation(api.tokens.create);
@@ -26,6 +31,7 @@ function CliAuth() {
};
const [status, setStatus] = useState<string>("Preparing...");
const [token, setToken] = useState<string | null>(null);
const [callbackUrl, setCallbackUrl] = useState<string | null>(null);
const hasRun = useRef(false);
const redirectUri = search.redirect_uri ?? "";
@@ -51,13 +57,21 @@ function CliAuth() {
const run = async () => {
setStatus("Creating token...");
const result = await createToken({ label });
setToken(result.token);
setStatus("Redirecting to CLI...");
const hash = new URLSearchParams();
hash.set("token", result.token);
hash.set("registry", registry);
hash.set("state", state);
window.location.assign(`${redirectUri}#${hash.toString()}`);
const redirectUrl = `${redirectUri}#${hash.toString()}`;
// Render the fallback token before attempting navigation so it is
// always visible if the browser blocks or fails the http:// redirect
// (e.g. ERR_CONNECTION_REFUSED when the CLI server has already shut
// down, or Chrome's HTTPS-first mode interfering with localhost).
flushSync(() => {
setToken(result.token);
setCallbackUrl(redirectUrl);
setStatus("Redirecting to CLI…");
});
navigate(redirectUrl);
};
void run().catch((error) => {
@@ -65,7 +79,7 @@ function CliAuth() {
setStatus(message);
setToken(null);
});
}, [createToken, isAuthenticated, label, me, redirectUri, registry, safeRedirect, state]);
}, [createToken, isAuthenticated, label, me, navigate, redirectUri, registry, safeRedirect, state]);
if (!safeRedirect) {
return (
@@ -159,8 +173,16 @@ function CliAuth() {
<p className="text-sm text-[color:var(--ink-soft)]">{status}</p>
{token ? (
<div className="text-sm text-[color:var(--ink-soft)] overflow-x-auto">
<div className="mb-2">If redirect fails, copy this token:</div>
<div className="mb-2">
If the redirect did not complete, copy this token and run{" "}
<code>clawhub login --token &lt;token&gt;</code>:
</div>
<code className="font-mono text-xs">{token}</code>
{callbackUrl ? (
<div className="mt-2">
<a href={callbackUrl}>Retry redirect to CLI</a>
</div>
) : null}
</div>
) : null}
</CardContent>
+248 -83
View File
@@ -1,5 +1,22 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import {
ArrowRight,
BarChart3,
Brain,
Code2,
Ghost,
Image,
Palette,
Search,
Settings,
Shield,
Sparkles,
Star,
TrendingUp,
Users,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { SkillCard } from "../components/SkillCard";
@@ -7,7 +24,6 @@ import { SkillListItem } from "../components/SkillListItem";
import { SkillStatsTripletLine } from "../components/SkillStats";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { UserBadge } from "../components/UserBadge";
import { convexHttp } from "../convex/client";
@@ -25,6 +41,19 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
const popularSearches = ["AI Writing", "Screenshot", "Productivity", "Analytics", "Automation"];
const categories = [
{ name: "Productivity", icon: Zap, count: 324, className: "productivity" },
{ name: "AI & ML", icon: Brain, count: 218, className: "ai" },
{ name: "Developer Tools", icon: Code2, count: 456, className: "developer" },
{ name: "Design", icon: Palette, count: 189, className: "design" },
{ name: "Analytics", icon: BarChart3, count: 142, className: "analytics" },
{ name: "Security", icon: Shield, count: 98, className: "security" },
{ name: "Automation", icon: Settings, count: 276, className: "automation" },
{ name: "Media", icon: Image, count: 167, className: "media" },
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@@ -37,6 +66,8 @@ function SkillsHome() {
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
const [skillCount, setSkillCount] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const navigate = Route.useNavigate();
useEffect(() => {
let cancelled = false;
@@ -44,13 +75,13 @@ function SkillsHome() {
Promise.all([
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 8,
numItems: 6,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
}),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 8,
numItems: 6,
sort: "updated",
dir: "desc",
nonSuspiciousOnly: true,
@@ -71,51 +102,79 @@ function SkillsHome() {
};
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
if (!q) return;
void navigate({
to: "/search",
search: { q, type: undefined },
});
};
return (
<main>
{/* Hero Section */}
<section className="home-hero">
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
<div className="home-hero-kicker">Discovery hub</div>
<h1 className="home-hero-title">The collaborative hub for agent skills</h1>
<p className="home-hero-subtitle">
{skillCount != null
? `${formatCompactStat(skillCount)} public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own.`
: "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
</p>
<div className="home-hero-actions">
<Button asChild variant="primary">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse All Skills & Plugins
</Link>
</Button>
<Button asChild className="home-hero-publish-btn">
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
>
+ Publish Yours
</Link>
</Button>
{/* Badge */}
<div className="home-hero-kicker">
<Sparkles size={14} className="home-hero-kicker-icon" />
<span>
{skillCount != null
? `${formatCompactStat(skillCount)} curated tools`
: "Thousands of curated tools"}
</span>
</div>
<p className="home-hero-explainer">
Sharp filters. Clean listings. Discovery that feels more like a real index and less
like a sad spreadsheet.
{/* Headline */}
<h1 className="home-hero-title">
Discover tools that <span className="home-hero-title-accent">power your work</span>
</h1>
{/* Subheadline */}
<p className="home-hero-subtitle">
The modern marketplace for internet tools. Find, compare, and install the best
software to supercharge your productivity.
</p>
{/* Search */}
<form className="home-hero-search" onSubmit={handleSearch}>
<div className="home-hero-search-wrapper">
<Search size={20} className="home-hero-search-icon" />
<input
type="text"
className="home-hero-search-input"
placeholder="Search for tools, categories, or features..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button type="submit" className="home-hero-search-btn">
<span>Search</span>
<ArrowRight size={16} />
</button>
</div>
</form>
{/* Popular searches */}
<div className="home-hero-popular">
<span className="home-hero-popular-label">Popular:</span>
{popularSearches.map((search) => (
<button
key={search}
type="button"
className="home-hero-popular-tag"
onClick={() => setSearchQuery(search)}
>
{search}
</button>
))}
</div>
</div>
{/* Discovery Panels */}
<div className="home-hero-panels" id="home-discovery">
<Link
to="/skills"
@@ -130,19 +189,25 @@ function SkillsHome() {
}}
className="home-hero-panel"
>
<span className="home-hero-panel-label">Skills</span>
<strong>Browse ranked skill bundles</strong>
<span>Popular installs, fresh updates, staff picks.</span>
<div className="home-hero-panel-icon">
<Zap size={20} />
</div>
<strong>Skills</strong>
<span>Browse ranked skill bundles</span>
</Link>
<Link to="/plugins" className="home-hero-panel">
<span className="home-hero-panel-label">Plugins</span>
<strong>Find agent-ready packages</strong>
<span>Code plugins, bundles, and verified publishers.</span>
<div className="home-hero-panel-icon">
<Code2 size={20} />
</div>
<strong>Plugins</strong>
<span>Agent-ready packages</span>
</Link>
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
<span className="home-hero-panel-label">Users</span>
<strong>Meet the builders</strong>
<span>Profiles, bios, and the people shipping useful stuff.</span>
<div className="home-hero-panel-icon">
<Users size={20} />
</div>
<strong>Builders</strong>
<span>Meet the creators</span>
</Link>
<Link
to="/souls"
@@ -155,20 +220,39 @@ function SkillsHome() {
}}
className="home-hero-panel"
>
<span className="home-hero-panel-label">Souls</span>
<strong>SOUL.md discovery is coming</strong>
<span>Holding page for the next catalog surface.</span>
<div className="home-hero-panel-icon">
<Ghost size={20} />
</div>
<strong>Souls</strong>
<span>SOUL.md discovery</span>
</Link>
</div>
</div>
</div>
</section>
{/* Stats Bar */}
{skillCount != null ? (
<section className="home-section">
<div className="home-stats">
<div className="home-stat">
<div className="home-stat-value">{formatCompactStat(skillCount)}+</div>
<div className="home-stat-label">Curated Tools</div>
</div>
</div>
</section>
) : null}
{/* Trending */}
{trending.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Trending</h2>
<h2 className="home-section-title">
<span className="home-section-title-icon trending">
<TrendingUp size={16} />
</span>
Trending Now
</h2>
<Link
to="/skills"
search={{
@@ -182,7 +266,8 @@ function SkillsHome() {
}}
className="home-section-link"
>
See all
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="results-list">
@@ -202,7 +287,12 @@ function SkillsHome() {
{recent.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Recently updated</h2>
<h2 className="home-section-title">
<span className="home-section-title-icon recent">
<Sparkles size={16} />
</span>
Recently Updated
</h2>
<Link
to="/skills"
search={{
@@ -216,7 +306,8 @@ function SkillsHome() {
}}
className="home-section-link"
>
See all
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="results-list">
@@ -236,7 +327,12 @@ function SkillsHome() {
{highlighted.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Staff picks</h2>
<h2 className="home-section-title">
<span className="home-section-title-icon featured">
<Star size={16} />
</span>
Staff Picks
</h2>
<Link
to="/skills"
search={{
@@ -250,12 +346,12 @@ function SkillsHome() {
}}
className="home-section-link"
>
See all
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="grid">
{
highlighted.map((entry) => (
{highlighted.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
@@ -280,41 +376,96 @@ function SkillsHome() {
</section>
) : null}
{/* Categories */}
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Browse by Category</h2>
</div>
<div className="home-categories">
{categories.map((category) => (
<Link
key={category.name}
to="/skills"
search={{
q: category.name,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-category-card"
>
<div className={`home-category-icon ${category.className}`}>
<category.icon size={20} aria-hidden="true" />
</div>
<div className="home-category-content">
<div className="home-category-name">{category.name}</div>
<div className="home-category-count">{category.count} tools</div>
</div>
</Link>
))}
</div>
</section>
{/* Quick links */}
<section className="home-section">
<div className="home-quick-links">
<Link
to="/skills"
search={{ q: undefined, sort: "stars" as const, dir: "desc" as const, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
search={{
q: undefined,
sort: "stars" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-quick-link"
>
<Star size={14} className="home-quick-link-icon" />
Most starred
</Link>
<Link
to="/skills"
search={{ q: undefined, sort: "newest" as const, dir: undefined, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
search={{
q: undefined,
sort: "newest" as const,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-quick-link"
>
<Sparkles size={14} className="home-quick-link-icon" />
New this week
</Link>
<Link to="/plugins" className="home-quick-link">
<Code2 size={14} className="home-quick-link-icon" />
Browse plugins
</Link>
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
<Users size={14} className="home-quick-link-icon" />
Browse users
</Link>
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
className="home-quick-link"
>
Souls coming soon
</Link>
<Link
to="/skills"
search={{ q: undefined, sort: undefined, dir: undefined, highlighted: true, nonSuspicious: undefined, view: undefined, focus: undefined }}
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="home-quick-link"
>
<Star size={14} className="home-quick-link-icon" />
Staff picks
</Link>
</div>
@@ -323,7 +474,6 @@ function SkillsHome() {
);
}
function OnlyCrabsHome() {
const navigate = Route.useNavigate();
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
@@ -344,8 +494,13 @@ function OnlyCrabsHome() {
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
<div className="home-hero-kicker">OnlyCrabs</div>
<h1 className="home-hero-title">SoulHub, where system lore lives.</h1>
<div className="home-hero-kicker">
<Ghost size={14} className="home-hero-kicker-icon" />
<span>OnlyCrabs</span>
</div>
<h1 className="home-hero-title">
<span className="home-hero-title-accent">SoulHub</span>, where system lore lives.
</h1>
<p className="home-hero-subtitle">
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
public place.
@@ -366,16 +521,20 @@ function OnlyCrabsHome() {
});
}}
>
<input
className="home-hero-search-input"
type="text"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<Button variant="primary" type="submit">
Search
</Button>
<div className="home-hero-search-wrapper">
<Search size={20} className="home-hero-search-icon" />
<input
className="home-hero-search-input"
type="text"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit" className="home-hero-search-btn">
<span>Search</span>
<ArrowRight size={16} />
</button>
</div>
</form>
</div>
</div>
@@ -384,7 +543,12 @@ function OnlyCrabsHome() {
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Latest souls</h2>
<h2 className="home-section-title">
<span className="home-section-title-icon recent">
<Sparkles size={16} />
</span>
Latest Souls
</h2>
<Link
to="/souls"
search={{
@@ -396,7 +560,8 @@ function OnlyCrabsHome() {
}}
className="home-section-link"
>
See all
View all
<ArrowRight size={14} />
</Link>
</div>
<div className="grid">
+287 -275
View File
@@ -20,12 +20,10 @@ import {
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
type PluginDetailRateLimitState =
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
type PluginDetailRateLimitState = {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
@@ -297,286 +295,300 @@ function PluginDetailRoute() {
return (
<main className="section">
<div className="flex flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
<div className="flex flex-wrap gap-1.5 mb-2">
<Badge>{familyLabel(pkg.family)}</Badge>
{verification?.tier ? (
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
) : null}
{rateLimited?.scope === "metadata" ? (
<Badge variant="compact">Some metadata is temporarily unavailable</Badge>
) : null}
{pkg.isOfficial ? (
<Badge className="bg-[rgba(59,130,246,0.15)] text-[#3b82f6]">
<VerifiedBadge />
</Badge>
) : null}
</div>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
{pkg.displayName}
{pkg.latestVersion ? (
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
v{pkg.latestVersion}
</span>
) : null}
</h1>
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="opacity-40">&middot;</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
) : null}
</div>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent" className="mt-3 self-start">
Community code plugin. Review compatibility and verification before install.
<div className="flex flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
<div className="flex flex-wrap gap-1.5 mb-2">
<Badge>{familyLabel(pkg.family)}</Badge>
{verification?.tier ? (
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
) : null}
{rateLimited?.scope === "metadata" ? (
<Badge variant="compact">Some metadata is temporarily unavailable</Badge>
) : null}
{pkg.isOfficial ? (
<Badge className="bg-[rgba(59,130,246,0.15)] text-[#3b82f6]">
<VerifiedBadge />
</Badge>
) : null}
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
</div>
</div>
{/* Latest Release */}
</div>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
{pkg.displayName}
{pkg.latestVersion ? (
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
v{pkg.latestVersion}
</span>
) : null}
</h1>
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="opacity-40">&middot;</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
) : null}
</div>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent" className="mt-3 self-start">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
</div>
</div>
{/* Latest Release */}
{pkg.latestVersion ? (
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<Button
asChild
variant="outline"
size="sm"
className="w-full no-underline sm:w-auto"
>
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
) : null}
</CardContent>
</Card>
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Security Scan */}
{latestRelease ? (
<Card>
<CardContent>
<SecurityScanResults
sha256hash={latestRelease.sha256hash ?? undefined}
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
staticFindings={latestRelease.staticScan?.findings ?? []}
/>
</CardContent>
</Card>
) : null}
{/* Security Scan */}
{latestRelease ? (
<Card>
<CardContent>
<SecurityScanResults
sha256hash={latestRelease.sha256hash ?? undefined}
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
staticFindings={latestRelease.staticScan?.findings ?? []}
/>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Readme */}
{readme ? (
<Card>
<CardContent>
<MarkdownPreview>{readme}</MarkdownPreview>
</CardContent>
</Card>
) : null}
</div>
{/* Readme */}
{readme ? (
<Card>
<CardContent>
<MarkdownPreview>{readme}</MarkdownPreview>
</CardContent>
</Card>
) : null}
</div>
</main>
);
}
+4 -9
View File
@@ -47,9 +47,7 @@ export const Route = createFileRoute("/plugins/")({
? true
: undefined,
executesCode:
search.executesCode === true ||
search.executesCode === "true" ||
search.executesCode === "1"
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
? true
: undefined,
}),
@@ -76,7 +74,7 @@ export const Route = createFileRoute("/plugins/")({
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: (error as { retryAfterSeconds?: number }).retryAfterSeconds ?? null,
retryAfterSeconds: error.retryAfterSeconds,
} satisfies PluginsLoaderData;
}
throw error;
@@ -118,8 +116,7 @@ export function PluginsIndex() {
};
const handleFamilySort = (value: string) => {
const family =
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
void navigate({
search: (prev) => ({
...prev,
@@ -205,9 +202,7 @@ export function PluginsIndex() {
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
<p className="empty-state-body">
Try again {formatRetryDelay(retryAfterSeconds)}.
</p>
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
</div>
) : items.length === 0 ? (
<div className="empty-state">
+351 -1
View File
@@ -1,15 +1,28 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import {
Eye,
Grid3X3,
LayoutGrid,
List,
Monitor,
Moon,
RotateCcw,
Settings2,
Sparkles,
Sun,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { SignInButton } from "../components/SignInButton";
import {
Dialog,
DialogContent,
@@ -21,9 +34,25 @@ import {
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { Switch } from "../components/ui/switch";
import { Textarea } from "../components/ui/textarea";
import { gravatarUrl } from "../lib/gravatar";
import {
type AnimationLevel,
type CodeFontSize,
type LayoutDensity,
type ListViewMode,
usePreferences,
} from "../lib/preferences";
import { useThemeMode } from "../lib/theme";
export const Route = createFileRoute("/settings")({
component: Settings,
@@ -33,6 +62,8 @@ export function Settings() {
const me = useQuery(api.users.me);
const updateProfile = useMutation(api.users.updateProfile);
const deleteAccount = useMutation(api.users.deleteAccount);
const { mode: themeMode, setMode: setThemeMode } = useThemeMode();
const { preferences, updatePreference, resetPreferences, isAdvancedMode } = usePreferences();
const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as
| Array<{
_id: Id<"apiTokens">;
@@ -208,6 +239,325 @@ export function Settings() {
</form>
</Card>
{/* Customization */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Settings2 size={18} />
Customization
</CardTitle>
<CardDescription>
Personalize your ClawHub experience
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
Advanced
</Label>
<Switch
id="advanced-mode"
checked={isAdvancedMode}
onCheckedChange={(checked) => updatePreference("advancedMode", checked)}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Theme Section */}
<div className="space-y-3">
<Label className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
<div className="flex gap-2">
<Button
variant={themeMode === "light" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("light")}
className="flex items-center gap-2"
>
<Sun size={14} />
Light
</Button>
<Button
variant={themeMode === "dark" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("dark")}
className="flex items-center gap-2"
>
<Moon size={14} />
Dark
</Button>
<Button
variant={themeMode === "system" ? "primary" : "ghost"}
size="sm"
onClick={() => setThemeMode("system")}
className="flex items-center gap-2"
>
<Monitor size={14} />
System
</Button>
</div>
</div>
<Separator />
{/* Layout Section */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
Density
</Label>
<Select
value={preferences.layoutDensity}
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
>
<SelectTrigger id="layout-density">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comfortable">
<span className="flex items-center gap-2">
<LayoutGrid size={14} />
Comfortable
</span>
</SelectItem>
<SelectItem value="compact">
<span className="flex items-center gap-2">
<Grid3X3 size={14} />
Compact
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="list-view" className="text-xs text-[color:var(--ink-soft)]">
Default view
</Label>
<Select
value={preferences.listViewMode}
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
>
<SelectTrigger id="list-view">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid">
<span className="flex items-center gap-2">
<LayoutGrid size={14} />
Grid
</span>
</SelectItem>
<SelectItem value="list">
<span className="flex items-center gap-2">
<List size={14} />
List
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
<Switch
id="show-descriptions"
checked={preferences.showDescriptions}
onCheckedChange={(checked) => updatePreference("showDescriptions", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
<Switch
id="show-stats"
checked={preferences.showStats}
onCheckedChange={(checked) => updatePreference("showStats", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
<Switch
id="show-tags"
checked={preferences.showTags}
onCheckedChange={(checked) => updatePreference("showTags", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
<Switch
id="sticky-header"
checked={preferences.stickyHeader}
onCheckedChange={(checked) => updatePreference("stickyHeader", checked)}
/>
</div>
</div>
</div>
{isAdvancedMode ? (
<>
<Separator />
{/* Code & Content Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--accent)]" />
Code &amp; Content
</Label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
Code font size
</Label>
<Select
value={preferences.codeFontSize}
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
>
<SelectTrigger id="code-font-size">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Small (12px)</SelectItem>
<SelectItem value="medium">Medium (14px)</SelectItem>
<SelectItem value="large">Large (16px)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
Animation level
</Label>
<Select
value={preferences.animationLevel}
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
>
<SelectTrigger id="animation-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">
<span className="flex items-center gap-2">
<Zap size={14} />
Full
</span>
</SelectItem>
<SelectItem value="reduced">Reduced</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
<Switch
id="line-numbers"
checked={preferences.lineNumbers}
onCheckedChange={(checked) => updatePreference("lineNumbers", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
<Switch
id="word-wrap"
checked={preferences.wordWrap}
onCheckedChange={(checked) => updatePreference("wordWrap", checked)}
/>
</div>
</div>
</div>
<Separator />
{/* Accessibility Section - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Eye size={14} className="text-[color:var(--accent)]" />
Accessibility
</Label>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
</div>
<Switch
id="reduced-motion"
checked={preferences.reducedMotion}
onCheckedChange={(checked) => updatePreference("reducedMotion", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
</div>
<Switch
id="high-contrast"
checked={preferences.highContrast}
onCheckedChange={(checked) => updatePreference("highContrast", checked)}
/>
</div>
</div>
</div>
<Separator />
{/* Experimental Features - Advanced */}
<div className="space-y-4">
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
<Sparkles size={14} className="text-[color:var(--gold)]" />
Experimental
</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they&apos;re released</p>
</div>
<Switch
id="experimental-features"
checked={preferences.experimentalFeatures}
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
/>
</div>
</div>
</>
) : null}
<Separator />
{/* Reset Section */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
resetPreferences();
toast.success("Preferences reset to defaults");
}}
className="flex items-center gap-2"
>
<RotateCcw size={14} />
Reset
</Button>
</div>
</CardContent>
</Card>
{/* Organizations */}
<Card>
<CardHeader>
+831 -373
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -6,7 +6,6 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig, type Plugin } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
const require = createRequire(import.meta.url);
@@ -166,6 +165,8 @@ const config = defineConfig({
"convex/values": convexValuesPath,
"@convex-dev/auth/react": convexAuthReactPath,
},
// Use native Vite tsconfig paths resolution instead of the plugin
tsconfigPaths: true,
},
optimizeDeps: {
include: ["convex/react", "convex/browser"],
@@ -179,10 +180,6 @@ const config = defineConfig({
onwarn: handleRollupWarning,
},
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart(),
viteReact(),