Compare commits

..
Author SHA1 Message Date
momothemage edb702bb57 feat: add org support to skill ownership transfers — changelog entry 2026-04-10 17:15:40 +08:00
momothemage 6c8ace3755 fix: clarify org-transfer sender deferral and surface org-targeted incoming transfers 2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 162f4d24f8 fix: honor sender's target publisher on accept, disallow override
When the sender specified a target publisher (toPublisherId), the
acceptor's publisherId arg is now ignored. The publisher override
only applies to user-targeted transfers where the recipient chooses
where to assign ownership.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 3e7deacce8 fix: validate target publisher is active before creating transfer
requestTransferInternal now checks that toPublisherId is not
deleted/deactivated before persisting the transfer. Prevents
creating dead transfers that can never be accepted.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš a90563d9d2 fix: accept empty JSON body on transfer accept and enforce org role on cancel
- Accept handler no longer rejects empty JSON bodies. If parsing fails
  (empty body, no content-type), publisherId stays undefined and the
  transfer proceeds with the default publisher. Fixes clients that
  send Content-Type: application/json with no body.
- Cancel always validates org membership when fromPublisherId is set,
  even for the original requester. Prevents cancelled-after-removal
  where a user who lost org admin role could still cancel.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 74455bb860 fix: update transfer test for object body instead of pre-stringified JSON
The existing test expected body as a JSON string, but apiRequest
already calls JSON.stringify internally — passing a pre-stringified
body causes double-encoding. Updated test to verify the body object
directly, matching the corrected CLI behavior.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 704d9457aa refactor: scope CLI to skill-only transfers in PR 1
Remove package auto-detection, --type flag, and package-specific
response schema fields. PR 1 only adds skill org support — package
transfer CLI support belongs in PR 2 alongside the backend endpoints.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš bf7a1d32c9 fix: address review feedback on skill transfer org support
- Add clarifying comment that ownerUserId reflects the accepting admin,
  not the original toUserId, for org-targeted transfers
- Remove redundant .map() type remapping in transfer listing — the
  query already returns type: "skill"
- Fix double JSON encoding in CLI transfer accept body
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš d467ae3d75 feat: add org (publisher) support to skill ownership transfers
Add fromPublisherId/toPublisherId fields to skillOwnershipTransfers schema
and make toUserId optional to support org-to-org, user-to-org, and
org-to-user transfer flows.

- Extract shared transfer helpers into convex/lib/transfers.ts
- Update skillTransfers.ts with org-aware request/accept/reject/cancel
- Add publisher handle resolution and org admin fallback in skillsV1.ts
- Add type:"skill" field to transfer listing responses in transfersV1.ts
- Update CLI with --type and --publisher flags for transfer commands
- Update response schemas, docs (http-api, cli, orgs)
2026-04-10 17:07:59 +08:00
144 changed files with 5156 additions and 14830 deletions
-4
View File
@@ -27,13 +27,9 @@ jobs:
- name: Test
run: bun run test
env:
VITE_CONVEX_URL: https://example.invalid
- name: Coverage
run: bun run coverage
env:
VITE_CONVEX_URL: https://example.invalid
- name: ClawHub CLI Verify
run: bun run --cwd packages/clawhub verify
-4
View File
@@ -24,7 +24,3 @@ coverage
playwright-report
test-results
.playwright
convex/_generated/
skills-lock.json
*/skills/*
skills/*
-8
View File
@@ -87,11 +87,3 @@
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
+1
View File
@@ -5,6 +5,7 @@
### Changed
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- Skill ownership transfers now support org targets: `transfer request` and `transfer accept` accept a `--publisher` flag to send or receive a skill on behalf of an org. Org admins can see incoming org-targeted transfers via `GET /api/v1/transfers/incoming`. (#1603 thanks @TommYDeeee)
## 0.10.0 - 2026-04-05
-8
View File
@@ -45,11 +45,3 @@
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
-356
View File
@@ -1,356 +0,0 @@
# 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
+3 -139
View File
@@ -20,8 +20,6 @@
"@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",
@@ -34,7 +32,6 @@
"@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",
@@ -44,8 +41,6 @@
"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",
@@ -56,7 +51,6 @@
"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",
@@ -272,56 +266,6 @@
"@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=="],
@@ -338,24 +282,6 @@
"@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=="],
@@ -514,9 +440,7 @@
"@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.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-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-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=="],
@@ -612,8 +536,6 @@
"@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=="],
@@ -822,8 +744,6 @@
"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"],
@@ -832,8 +752,6 @@
"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=="],
@@ -1178,10 +1096,6 @@
"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=="],
@@ -1310,8 +1224,6 @@
"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=="],
@@ -1352,8 +1264,6 @@
"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=="],
@@ -1398,8 +1308,6 @@
"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=="],
@@ -1492,14 +1400,10 @@
"@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=="],
@@ -1512,14 +1416,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=="],
@@ -1532,12 +1436,6 @@
"@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=="],
@@ -1552,8 +1450,6 @@
"@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=="],
@@ -1596,11 +1492,7 @@
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
<<<<<<< staging
"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=="],
=======
"nitro/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=="],
>>>>>>> main
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1618,33 +1510,6 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
<<<<<<< staging
"@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=="],
=======
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vitest/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=="],
@@ -1718,6 +1583,5 @@
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
>>>>>>> main
}
}
-2
View File
@@ -100,7 +100,6 @@ import type * as publishers from "../publishers.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedDemo from "../seedDemo.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
@@ -217,7 +216,6 @@ declare const fullApi: ApiFromModules<{
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
seedDemo: typeof seedDemo;
seedSouls: typeof seedSouls;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
-5
View File
@@ -469,11 +469,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
-45
View File
@@ -2552,51 +2552,6 @@ 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,7 +1433,6 @@ 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;
};
+4 -6
View File
@@ -1,4 +1,4 @@
import { CliPublishRequestSchema, normalizeTextContentType, parseArk } from "clawhub-schema";
import { CliPublishRequestSchema, parseArk } from "clawhub-schema";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
@@ -25,9 +25,7 @@ export function safeTextFileResponse(params: {
size: number;
headers?: HeadersInit;
}) {
const contentType =
normalizeTextContentType(params.path, params.contentType) ?? params.contentType;
const isSvg = isSvgLike(contentType, params.path);
const isSvg = isSvgLike(params.contentType, params.path);
// For any text response that a browser might try to render, lock it down.
// In particular, this prevents SVG <foreignObject> script execution from reading
@@ -35,8 +33,8 @@ export function safeTextFileResponse(params: {
const headers = mergeHeaders(
params.headers,
{
"Content-Type": contentType
? `${contentType}; charset=utf-8`
"Content-Type": params.contentType
? `${params.contentType}; charset=utf-8`
: "text/plain; charset=utf-8",
"Cache-Control": "private, max-age=60",
ETag: params.sha256,
+73 -21
View File
@@ -1,5 +1,4 @@
import { api, internal } from "../_generated/api";
import { normalizeTextContentType } from "clawhub-schema";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
@@ -735,7 +734,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
contentType: file.contentType ?? null,
})),
security: security ?? undefined,
},
@@ -970,11 +969,26 @@ async function handleTransferRequest(
if (!toUserHandleRaw) return text("toUserHandle required", 400, headers);
const message = typeof parsed.payload.message === "string" ? parsed.payload.message : undefined;
// Resolve optional publisher handle to a publisher ID
const toPublisherHandleRaw =
typeof parsed.payload.toPublisherHandle === "string"
? parsed.payload.toPublisherHandle.trim()
: "";
let toPublisherId: Id<"publishers"> | undefined;
if (toPublisherHandleRaw) {
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle: toPublisherHandleRaw,
});
if (!publisher) return text("Publisher not found", 404, headers);
toPublisherId = publisher._id;
}
try {
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
actorUserId: transferContext.userId,
skillId: transferContext.skill._id,
toUserHandle: toUserHandleRaw,
toPublisherId,
message,
});
return json(result, 200, headers);
@@ -993,30 +1007,68 @@ async function handleTransferDecision(
const transferContext = await resolveTransferContext(ctx, request, slug, headers);
if (!transferContext.ok) return transferContext.response;
const pendingTransfer =
decision === "cancel"
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
skillId: transferContext.skill._id,
fromUserId: transferContext.userId,
})
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
skillId: transferContext.skill._id,
toUserId: transferContext.userId,
});
let pendingTransfer;
if (decision === "cancel") {
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal,
{ skillId: transferContext.skill._id, fromUserId: transferContext.userId },
);
if (!pendingTransfer) {
// Fallback: allow org admins to cancel transfers initiated by other admins
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillInternal,
{ skillId: transferContext.skill._id },
);
}
} else {
// Try user-specific lookup first, then fall back to any pending transfer
// for the skill (allows org admins other than toUserId to accept/reject)
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillAndUserInternal,
{ skillId: transferContext.skill._id, toUserId: transferContext.userId },
);
if (!pendingTransfer) {
pendingTransfer = await ctx.runQuery(
internal.skillTransfers.getPendingTransferBySkillInternal,
{ skillId: transferContext.skill._id },
);
}
}
if (!pendingTransfer) return text("No pending transfer found", 404, headers);
const mutation =
decision === "accept"
? internal.skillTransfers.acceptTransferInternal
: decision === "reject"
? internal.skillTransfers.rejectTransferInternal
: internal.skillTransfers.cancelTransferInternal;
try {
const result = await ctx.runMutation(mutation, {
// For accept, resolve optional publisher handle to forward publisherId
let publisherId: Id<"publishers"> | undefined;
if (decision === "accept") {
const parsed = await parseJsonPayload(request, headers);
if (parsed.ok) {
const publisherHandleRaw =
typeof parsed.payload.publisherHandle === "string"
? parsed.payload.publisherHandle.trim()
: "";
if (publisherHandleRaw) {
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle: publisherHandleRaw,
});
if (!publisher) return text("Publisher not found", 404, headers);
publisherId = publisher._id;
}
}
}
const baseArgs = {
actorUserId: transferContext.userId,
transferId: pendingTransfer._id,
});
};
const result = await (decision === "accept"
? ctx.runMutation(internal.skillTransfers.acceptTransferInternal, {
...baseArgs,
publisherId,
})
: decision === "reject"
? ctx.runMutation(internal.skillTransfers.rejectTransferInternal, baseArgs)
: ctx.runMutation(internal.skillTransfers.cancelTransferInternal, baseArgs));
return json(result, 200, headers);
} catch (error) {
return transferErrorToResponse(error, headers);
+6 -1
View File
@@ -16,9 +16,14 @@ export async function transfersGetRouterV1Handler(ctx: ActionCtx, request: Reque
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const transfers =
const skillTransfers =
direction === "incoming"
? await ctx.runQuery(internal.skillTransfers.listIncomingInternal, { userId: auth.userId })
: await ctx.runQuery(internal.skillTransfers.listOutgoingInternal, { userId: auth.userId });
const transfers = skillTransfers.sort(
(a, b) => (b.requestedAt ?? 0) - (a.requestedAt ?? 0),
);
return json({ transfers }, 200, rate.headers);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
verifyGitHubActionsTrustedPublishJwt,
-2
View File
@@ -1,5 +1,4 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -114,7 +113,6 @@ export async function publishVersionForUser(
const sanitizedFiles = args.files.map((file) => ({
...file,
path: sanitizePath(file.path),
contentType: normalizeTextContentType(file.path, file.contentType),
}));
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError("Invalid file paths");
+1 -6
View File
@@ -1,5 +1,4 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import semver from "semver";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -102,11 +101,7 @@ export async function publishSoulVersionForUser(
const sanitizedFiles = args.files.map((file) => {
const path = sanitizePath(file.path);
if (!path) throw new ConvexError("Invalid file paths");
return {
...file,
path,
contentType: normalizeTextContentType(file.path, file.contentType),
};
return { ...file, path };
});
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path));
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from "vitest";
import {
TRANSFER_EXPIRY_MS,
isTransferExpired,
normalizeTransferHandle,
validateTransferOwnership,
validateTransferAcceptPermission,
} from "./transfers";
describe("transfers", () => {
it("TRANSFER_EXPIRY_MS is 7 days", () => {
expect(TRANSFER_EXPIRY_MS).toBe(7 * 24 * 60 * 60 * 1000);
});
describe("isTransferExpired", () => {
it("returns true when expiresAt is in the past", () => {
expect(isTransferExpired({ expiresAt: 1000 }, 2000)).toBe(true);
});
it("returns false when expiresAt is in the future", () => {
expect(isTransferExpired({ expiresAt: 3000 }, 2000)).toBe(false);
});
it("returns false when expiresAt equals now", () => {
expect(isTransferExpired({ expiresAt: 2000 }, 2000)).toBe(false);
});
});
describe("normalizeTransferHandle", () => {
it("trims whitespace", () => {
expect(normalizeTransferHandle(" alice ")).toBe("alice");
});
it("strips leading @ and lowercases", () => {
expect(normalizeTransferHandle("@Alice")).toBe("alice");
});
it("strips multiple leading @ signs", () => {
expect(normalizeTransferHandle("@@Bob")).toBe("bob");
});
it("lowercases without @", () => {
expect(normalizeTransferHandle("Charlie")).toBe("charlie");
});
});
describe("validateTransferOwnership", () => {
it("passes for direct owner (personal, no publisher)", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:1" as never,
ownerPublisherId: undefined,
}),
).resolves.toBeUndefined();
});
it("passes for org admin", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "admin",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("passes for org owner role", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "owner",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("rejects non-admin org member (publisher role)", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "publisher",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("Forbidden");
});
it("rejects non-owner non-member", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => null,
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:1" as never,
ownerUserId: "users:99" as never,
ownerPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("Forbidden");
});
it("rejects personal item when actor is not the owner", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferOwnership(ctx as never, {
actorUserId: "users:2" as never,
ownerUserId: "users:1" as never,
ownerPublisherId: undefined,
}),
).rejects.toThrow("Forbidden");
});
});
describe("validateTransferAcceptPermission", () => {
it("passes for personal target when actor is the target user", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:1" as never,
toPublisherId: undefined,
}),
).resolves.toBeUndefined();
});
it("rejects personal target when actor is not the target user", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
query: vi.fn(),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:2" as never,
toUserId: "users:1" as never,
toPublisherId: undefined,
}),
).rejects.toThrow("No pending transfer found");
});
it("passes for org target when actor is admin", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
get: vi.fn(async () => ({
_id: "publishers:org1",
kind: "org",
handle: "myorg",
})),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "admin",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:99" as never,
toPublisherId: "publishers:org1" as never,
}),
).resolves.toBeUndefined();
});
it("rejects org target when actor is not admin/owner", async () => {
const ctx = {
db: {
normalizeId: vi.fn(),
get: vi.fn(async () => ({
_id: "publishers:org1",
kind: "org",
handle: "myorg",
})),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:1",
role: "publisher",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
validateTransferAcceptPermission(ctx as never, {
actorUserId: "users:1" as never,
toUserId: "users:99" as never,
toPublisherId: "publishers:org1" as never,
}),
).rejects.toThrow("No pending transfer found");
});
});
});
+91
View File
@@ -0,0 +1,91 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { QueryCtx, MutationCtx } from "../_generated/server";
import { getPublisherMembership, isPublisherActive, isPublisherRoleAllowed } from "./publishers";
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
/** 7 days in milliseconds */
export const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
/** Returns true if the transfer has expired (expiresAt is strictly less than now). */
export function isTransferExpired(transfer: { expiresAt: number }, now: number): boolean {
return transfer.expiresAt < now;
}
/**
* Trims whitespace, strips leading `@` characters, and lowercases:
* `"@Alice"` -> `"alice"`, `"@@Bob"` -> `"bob"`
*/
export function normalizeTransferHandle(value: string): string {
return value.trim().replace(/^@+/, "").toLowerCase();
}
/**
* Validates that `actorUserId` has permission to initiate a transfer.
*
* - If the item is personally owned (`ownerPublisherId` is null/undefined):
* actor must be the `ownerUserId`.
* - If the item is org-owned: actor must be an admin or owner in that org's
* publisherMembers.
*
* @throws {Error} "Forbidden" on failure
*/
export async function validateTransferOwnership(
ctx: DbCtx,
params: {
actorUserId: Id<"users">;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers"> | null;
},
): Promise<void> {
if (!params.ownerPublisherId) {
// Personally owned — actor must be the owner
if (params.actorUserId !== params.ownerUserId) {
throw new Error("Forbidden");
}
return;
}
// Org-owned — actor must be admin or owner in the org
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actorUserId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new Error("Forbidden");
}
}
/**
* Validates that `actorUserId` can accept a transfer.
*
* - If `toPublisherId` is null (personal target): actor must be `toUserId`.
* - If `toPublisherId` is set (org target): actor must be admin/owner of that org.
*
* @throws {Error} "No pending transfer found" on failure
*/
export async function validateTransferAcceptPermission(
ctx: DbCtx,
params: {
actorUserId: Id<"users">;
toUserId?: Id<"users"> | null;
toPublisherId?: Id<"publishers"> | null;
},
): Promise<void> {
if (!params.toPublisherId) {
// Personal target — actor must be the target user
if (params.actorUserId !== params.toUserId) {
throw new Error("No pending transfer found");
}
return;
}
// Org target — publisher must be active and actor must be admin or owner
const db = (ctx as { db: { get: (id: Id<"publishers">) => Promise<Doc<"publishers"> | null> } })
.db;
const publisher = await db.get(params.toPublisherId);
if (!isPublisherActive(publisher)) {
throw new Error("Publisher not found");
}
const membership = await getPublisherMembership(ctx, params.toPublisherId, params.actorUserId);
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
throw new Error("No pending transfer found");
}
}
-67
View File
@@ -1,67 +0,0 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
function getSkillContribution(skill: Doc<"skills">) {
if (skill.softDeletedAt) {
return { publishedSkills: 0, totalStars: 0, totalDownloads: 0 };
}
return {
publishedSkills: 1,
totalStars: skill.stats?.stars ?? 0,
totalDownloads: skill.stats?.downloads ?? 0,
};
}
async function patchUserStats(
ctx: Pick<MutationCtx, "db">,
userId: Id<"users">,
delta: { publishedSkills: number; totalStars: number; totalDownloads: number },
) {
const user = await ctx.db.get(userId);
if (!user) return;
await ctx.db.patch(userId, {
publishedSkills: Math.max(0, (user.publishedSkills ?? 0) + delta.publishedSkills),
totalStars: Math.max(0, (user.totalStars ?? 0) + delta.totalStars),
totalDownloads: Math.max(0, (user.totalDownloads ?? 0) + delta.totalDownloads),
});
}
export async function adjustUserSkillStatsForSkillChange(
ctx: Pick<MutationCtx, "db">,
previousSkill: Doc<"skills"> | null | undefined,
nextSkill: Doc<"skills"> | null | undefined,
) {
if (!previousSkill && !nextSkill) return;
const prevOwnerId = previousSkill?.ownerUserId ?? null;
const nextOwnerId = nextSkill?.ownerUserId ?? null;
const prevContribution = previousSkill ? getSkillContribution(previousSkill) : null;
const nextContribution = nextSkill ? getSkillContribution(nextSkill) : null;
if (prevOwnerId && prevOwnerId === nextOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
});
return;
}
if (prevOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: -(prevContribution?.publishedSkills ?? 0),
totalStars: -(prevContribution?.totalStars ?? 0),
totalDownloads: -(prevContribution?.totalDownloads ?? 0),
});
}
if (nextOwnerId) {
await patchUserStats(ctx, nextOwnerId, {
publishedSkills: nextContribution?.publishedSkills ?? 0,
totalStars: nextContribution?.totalStars ?? 0,
totalDownloads: nextContribution?.totalDownloads ?? 0,
});
}
}
-2
View File
@@ -221,7 +221,6 @@ type PublicPackageDoc = {
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
scanStatus?: Doc<"packages">["scanStatus"];
stats: Doc<"packages">["stats"];
createdAt: number;
updatedAt: number;
};
@@ -327,7 +326,6 @@ function toPublicPackage(
capabilities: pkg.capabilities,
verification: pkg.verification,
scanStatus: pkg.scanStatus,
stats: pkg.stats,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
};
+6 -8
View File
@@ -28,9 +28,6 @@ const users = defineTable({
githubFetchedAt: v.optional(v.number()),
githubProfileSyncedAt: v.optional(v.number()),
trustedPublisher: v.optional(v.boolean()),
publishedSkills: v.optional(v.number()),
totalStars: v.optional(v.number()),
totalDownloads: v.optional(v.number()),
personalPublisherId: v.optional(v.id("publishers")),
requiresModerationAt: v.optional(v.number()),
requiresModerationReason: v.optional(v.string()),
@@ -43,8 +40,7 @@ const users = defineTable({
})
.index("email", ["email"])
.index("phone", ["phone"])
.index("handle", ["handle"])
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
.index("handle", ["handle"]);
const publishers = defineTable({
kind: v.union(v.literal("user"), v.literal("org")),
@@ -388,8 +384,7 @@ const souls = defineTable({
.index("by_slug", ["slug"])
.index("by_owner", ["ownerUserId"])
.index("by_owner_publisher", ["ownerPublisherId"])
.index("by_updated", ["updatedAt"])
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
.index("by_updated", ["updatedAt"]);
const skillVersions = defineTable({
skillId: v.id("skills"),
@@ -1291,7 +1286,9 @@ const userSkillRootInstalls = defineTable({
const skillOwnershipTransfers = defineTable({
skillId: v.id("skills"),
fromUserId: v.id("users"),
toUserId: v.id("users"),
toUserId: v.optional(v.id("users")),
fromPublisherId: v.optional(v.id("publishers")),
toPublisherId: v.optional(v.id("publishers")),
status: v.union(
v.literal("pending"),
v.literal("accepted"),
@@ -1308,6 +1305,7 @@ const skillOwnershipTransfers = defineTable({
.index("by_from_user", ["fromUserId"])
.index("by_to_user", ["toUserId"])
.index("by_to_user_status", ["toUserId", "status"])
.index("by_to_publisher_status", ["toPublisherId", "status"])
.index("by_from_user_status", ["fromUserId", "status"])
.index("by_skill_status", ["skillId", "status"]);
-419
View File
@@ -1,419 +0,0 @@
import type { Id } from "./_generated/dataModel";
import { internalMutation } from "./functions";
const DEMO_SKILLS = [
{
slug: "mcp-github",
displayName: "MCP GitHub",
summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
downloads: 14200,
stars: 342,
installs: 8100,
},
{
slug: "claude-memory",
displayName: "Claude Memory",
summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
downloads: 11800,
stars: 287,
installs: 6400,
},
{
slug: "web-scraper-pro",
displayName: "Web Scraper Pro",
summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
downloads: 9400,
stars: 198,
installs: 5200,
},
{
slug: "sql-analyst",
displayName: "SQL Analyst",
summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
downloads: 8700,
stars: 221,
installs: 4800,
},
{
slug: "pytest-agent",
displayName: "Pytest Agent",
summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
downloads: 7200,
stars: 156,
installs: 3900,
},
{
slug: "docker-compose-helper",
displayName: "Docker Compose Helper",
summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
downloads: 6800,
stars: 134,
installs: 3600,
},
{
slug: "api-docs-generator",
displayName: "API Docs Generator",
summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
downloads: 5900,
stars: 178,
installs: 3100,
},
{
slug: "slack-bot-builder",
displayName: "Slack Bot Builder",
summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
downloads: 5400,
stars: 112,
installs: 2800,
},
{
slug: "terraform-assistant",
displayName: "Terraform Assistant",
summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
downloads: 4800,
stars: 145,
installs: 2400,
},
{
slug: "regex-wizard",
displayName: "Regex Wizard",
summary: "Natural language to regex with live testing, explanation, and edge case generation.",
downloads: 4200,
stars: 89,
installs: 2100,
},
{
slug: "git-history-explorer",
displayName: "Git History Explorer",
summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
downloads: 3900,
stars: 102,
installs: 1900,
},
{
slug: "cron-scheduler",
displayName: "Cron Scheduler",
summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
downloads: 3400,
stars: 67,
installs: 1600,
},
{
slug: "jwt-debugger",
displayName: "JWT Debugger",
summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
downloads: 3100,
stars: 78,
installs: 1400,
},
{
slug: "graphql-builder",
displayName: "GraphQL Builder",
summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
downloads: 2800,
stars: 94,
installs: 1200,
},
{
slug: "security-scanner",
displayName: "Security Scanner",
summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
downloads: 2500,
stars: 156,
installs: 1100,
},
{
slug: "markdown-slides",
displayName: "Markdown Slides",
summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
downloads: 2200,
stars: 45,
installs: 900,
},
{
slug: "env-manager",
displayName: "Env Manager",
summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
downloads: 1800,
stars: 56,
installs: 800,
},
{
slug: "csv-transform",
displayName: "CSV Transform",
summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
downloads: 1500,
stars: 34,
installs: 600,
},
{
slug: "ssh-config-manager",
displayName: "SSH Config Manager",
summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
downloads: 1200,
stars: 42,
installs: 500,
},
{
slug: "changelog-writer",
displayName: "Changelog Writer",
summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
downloads: 980,
stars: 28,
installs: 400,
},
];
const DEMO_OWNERS = [
{ handle: "anthropic", displayName: "Anthropic", highlighted: true },
{ handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
{ handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
{ handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
{ handle: "dataflow", displayName: "DataFlow", highlighted: false },
];
export const seedDemoSkills = internalMutation({
args: {},
handler: async (ctx) => {
// Check if we already seeded
const existingSkill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
.first();
if (existingSkill) {
return { seeded: false, reason: "already seeded" };
}
// Create a seed user
const seedUserId = await ctx.db.insert("users", {
name: "ClawHub Demo",
displayName: "ClawHub Demo",
handle: "clawhub-demo",
image: undefined,
role: "admin",
});
// Create publisher accounts
const publisherIds: string[] = [];
for (const owner of DEMO_OWNERS) {
const pubId = await ctx.db.insert("publishers", {
kind: "org",
handle: owner.handle,
displayName: owner.displayName,
linkedUserId: seedUserId,
createdAt: Date.now(),
updatedAt: Date.now(),
});
publisherIds.push(pubId);
// Add membership
await ctx.db.insert("publisherMembers", {
publisherId: pubId as Id<"publishers">,
userId: seedUserId,
role: "owner",
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
const now = Date.now();
const DAY = 86400000;
let totalPublishedSkills = 0;
let totalStars = 0;
let totalDownloads = 0;
for (let i = 0; i < DEMO_SKILLS.length; i++) {
const s = DEMO_SKILLS[i];
const ownerIdx = i % publisherIds.length;
const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
const createdAt = now - createdDaysAgo * DAY;
const updatedAt = now - updatedDaysAgo * DAY;
const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
const isHighlighted = i < 6;
const badges = isHighlighted
? { highlighted: { byUserId: seedUserId, at: now } }
: undefined;
const numVersions = Math.floor(Math.random() * 8) + 1;
const numComments = Math.floor(Math.random() * 15);
// Create skill first (without latestVersionId)
const skillId = await ctx.db.insert("skills", {
slug: s.slug,
displayName: s.displayName,
summary: s.summary,
ownerUserId: seedUserId,
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
tags: {},
badges,
moderationStatus: "active",
moderationVerdict: "clean",
stats: {
downloads: s.downloads,
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
},
statsDownloads: s.downloads,
statsStars: s.stars,
statsInstallsCurrent: Math.floor(s.installs * 0.3),
statsInstallsAllTime: s.installs,
createdAt,
updatedAt,
});
// Create skillBadges entry for highlighted skills
if (isHighlighted) {
await ctx.db.insert("skillBadges", {
skillId,
kind: "highlighted",
byUserId: seedUserId,
at: now,
});
}
// Now create version with real skillId
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version,
changelog: `Release ${version} — improvements and bug fixes.`,
files: [],
parsed: { frontmatter: {} },
createdBy: seedUserId,
createdAt: updatedAt,
});
// Patch skill with version info
await ctx.db.patch(skillId, {
latestVersionId: versionId,
latestVersionSummary: {
version,
createdAt: updatedAt,
changelog: `Release ${version}`,
},
tags: { latest: versionId },
});
totalPublishedSkills += 1;
totalStars += s.stars;
totalDownloads += s.downloads;
// Create digest for search
await ctx.db.insert("skillSearchDigest", {
skillId,
slug: s.slug,
displayName: s.displayName,
summary: s.summary,
ownerUserId: seedUserId,
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
ownerHandle: DEMO_OWNERS[ownerIdx].handle,
ownerName: DEMO_OWNERS[ownerIdx].displayName,
ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
ownerImage: undefined,
latestVersionId: versionId,
latestVersionSummary: {
version,
createdAt: updatedAt,
changelog: `Release ${version}`,
},
tags: { latest: versionId },
badges,
stats: {
downloads: s.downloads,
installsCurrent: Math.floor(s.installs * 0.3),
installsAllTime: s.installs,
stars: s.stars,
versions: numVersions,
comments: numComments,
},
versions: numVersions,
comments: numComments,
moderationReason: undefined,
isSuspicious: false,
createdAt,
updatedAt,
});
}
await ctx.db.patch(seedUserId, {
publishedSkills: totalPublishedSkills,
totalStars,
totalDownloads,
});
return { seeded: true, count: DEMO_SKILLS.length };
},
});
// Repair globalStats count to match actual seeded data
export const repairGlobalStats = internalMutation({
args: {},
handler: async (ctx) => {
// Count active digests — push filter server-side
const digests = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.filter((q) => q.eq(q.field("moderationStatus"), "active"))
.collect();
const count = digests.length;
// Update globalStats
const stats = await ctx.db
.query("globalStats")
.filter((q) => q.eq(q.field("key"), "default"))
.first();
if (stats) {
await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
} else {
await ctx.db.insert("globalStats", {
key: "default",
activeSkillsCount: count,
updatedAt: Date.now(),
});
}
return { count };
},
});
// Repair function to add missing skillBadges for already-seeded data
export const repairHighlightedBadges = internalMutation({
args: {},
handler: async (ctx) => {
const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
let fixed = 0;
for (const slug of highlightedSlugs) {
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (!skill) continue;
// Check if badge already exists
const existing = await ctx.db
.query("skillBadges")
.withIndex("by_skill_kind", (q) =>
q.eq("skillId", skill._id).eq("kind", "highlighted"),
)
.first();
if (existing) continue;
await ctx.db.insert("skillBadges", {
skillId: skill._id,
kind: "highlighted",
byUserId: skill.ownerUserId,
at: Date.now(),
});
fixed++;
}
return { fixed };
},
});
-2
View File
@@ -23,7 +23,6 @@ import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { applySkillStatDeltas, bumpDailySkillStats } from "./lib/skillStats";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
/**
* Event types that affect skill stats:
@@ -260,7 +259,6 @@ export const processSkillStatEventsInternal = internalMutation({
// Don't update `updatedAt` — stat changes shouldn't move the
// skill's position in the by_active_updated index.
await ctx.db.patch(skill._id, patch);
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ...patch });
}
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
+75
View File
@@ -389,4 +389,79 @@ describe("skillTransfers", () => {
expect.objectContaining({ ownerUserId: "users:2" }),
);
});
it("requestTransferInternal allows org admin to request transfer", async () => {
// Org admin (users:2) can request transfer of a skill owned by org (publishers:org1)
// even though ownerUserId is users:1
const insert = vi.fn(async (table: string) => {
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
return "auditLogs:1";
});
const result = (await requestTransferInternalHandler(
{
db: {
normalizeId: vi.fn(),
get: vi.fn(async (id: string) => {
if (id === "users:2") return { _id: "users:2", handle: "orgadmin" };
if (id === "skills:1") {
return {
_id: "skills:1",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:1",
ownerPublisherId: "publishers:org1",
};
}
if (id === "publishers:org1") {
return { _id: "publishers:org1", kind: "org", handle: "myorg" };
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: () => ({
unique: async () => ({
_id: "users:3",
handle: "recipient",
displayName: "Recipient",
}),
}),
};
}
if (table === "skillOwnershipTransfers") {
return { withIndex: () => ({ collect: async () => [] }) };
}
if (table === "publisherMembers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org1",
userId: "users:2",
role: "admin",
}),
}),
};
}
if (table === "publishers") {
return { withIndex: () => ({ unique: async () => null }) };
}
throw new Error(`unexpected table ${table}`);
}),
patch: vi.fn(async () => {}),
insert,
},
} as never,
{
actorUserId: "users:2",
skillId: "skills:1",
toUserHandle: "@recipient",
} as never,
)) as { ok: boolean; transferId: string };
expect(result.ok).toBe(true);
expect(result.transferId).toBe("skillOwnershipTransfers:new");
});
});
+174 -37
View File
@@ -5,18 +5,16 @@ import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
import {
TRANSFER_EXPIRY_MS,
isTransferExpired,
normalizeTransferHandle,
validateTransferOwnership,
validateTransferAcceptPermission,
} from "./lib/transfers";
type TransferDoc = Doc<"skillOwnershipTransfers">;
function normalizeHandle(value: string) {
return value.trim().replace(/^@+/, "").toLowerCase();
}
function isExpired(transfer: TransferDoc, now: number) {
return transfer.expiresAt < now;
}
async function requireActiveUserById(ctx: unknown, userId: Id<"users">) {
const db = (ctx as { db: { get: (id: Id<"users">) => Promise<Doc<"users"> | null> } }).db;
const user = await db.get(userId);
@@ -53,7 +51,7 @@ async function getActivePendingTransferForSkill(ctx: unknown, skillId: Id<"skill
let active: TransferDoc | null = null;
for (const transfer of transfers) {
if (isExpired(transfer, now)) {
if (isTransferExpired(transfer, now)) {
await db.patch(transfer._id, { status: "expired", respondedAt: now });
continue;
}
@@ -83,14 +81,26 @@ async function validatePendingTransferForActor(
const transfer = await db.get(params.transferId);
if (!transfer) throw new Error("Transfer not found");
if (params.role === "recipient" && transfer.toUserId !== params.actorUserId) {
if (
params.role === "recipient" &&
transfer.toUserId &&
transfer.toUserId !== params.actorUserId &&
!transfer.toPublisherId
) {
// For org-targeted transfers (toPublisherId is set), skip this check —
// validateTransferAcceptPermission handles org membership validation separately
throw new Error("No pending transfer found");
}
if (params.role === "sender" && transfer.fromUserId !== params.actorUserId) {
throw new Error("No pending transfer found");
if (params.role === "sender") {
if (!transfer.fromPublisherId && transfer.fromUserId !== params.actorUserId) {
// Personal transfer: actor must be the original sender
throw new Error("No pending transfer found");
}
// Org-owned transfer: actor's org membership is verified by the caller
// (e.g. cancelTransferInternal calls validateTransferOwnership after this)
}
if (transfer.status !== "pending") throw new Error("No pending transfer found");
if (isExpired(transfer, params.now)) {
if (isTransferExpired(transfer, params.now)) {
await db.patch(transfer._id, { status: "expired", respondedAt: params.now });
throw new Error("Transfer has expired");
}
@@ -102,6 +112,7 @@ export const requestTransferInternal = internalMutation({
actorUserId: v.id("users"),
skillId: v.id("skills"),
toUserHandle: v.string(),
toPublisherId: v.optional(v.id("publishers")),
message: v.optional(v.string()),
},
handler: async (ctx, args) => {
@@ -110,14 +121,28 @@ export const requestTransferInternal = internalMutation({
const skill = await ctx.db.get(args.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
if (skill.ownerUserId !== args.actorUserId) throw new Error("Forbidden");
const toHandle = normalizeHandle(args.toUserHandle);
await validateTransferOwnership(ctx, {
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
actorUserId: args.actorUserId,
});
const toHandle = normalizeTransferHandle(args.toUserHandle);
if (!toHandle) throw new Error("toUserHandle required");
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
if (!toUser) throw new Error("User not found");
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
if (toUser._id === args.actorUserId && !args.toPublisherId) {
throw new Error("Cannot transfer to yourself");
}
if (args.toPublisherId) {
const toPublisher = await ctx.db.get(args.toPublisherId);
if (!toPublisher || toPublisher.deletedAt || toPublisher.deactivatedAt) {
throw new Error("Target publisher not found");
}
}
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
if (activePending) throw new Error("A transfer is already pending for this skill");
@@ -128,6 +153,8 @@ export const requestTransferInternal = internalMutation({
skillId: skill._id,
fromUserId: args.actorUserId,
toUserId: toUser._id,
fromPublisherId: skill.ownerPublisherId,
toPublisherId: args.toPublisherId,
status: "pending",
message: message || undefined,
requestedAt: now,
@@ -155,6 +182,7 @@ export const acceptTransferInternal = internalMutation({
args: {
actorUserId: v.id("users"),
transferId: v.id("skillOwnershipTransfers"),
publisherId: v.optional(v.id("publishers")),
},
handler: async (ctx, args) => {
const now = Date.now();
@@ -167,19 +195,45 @@ export const acceptTransferInternal = internalMutation({
now,
});
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toUserId: transfer.toUserId ?? undefined,
toPublisherId: transfer.toPublisherId,
});
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
if (skill.ownerUserId !== transfer.fromUserId) {
const ownerChanged = transfer.fromPublisherId
? skill.ownerPublisherId !== transfer.fromPublisherId
: skill.ownerUserId !== transfer.fromUserId;
if (ownerChanged) {
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
throw new Error("Transfer is no longer valid");
}
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
// Determine target publisher: sender's choice > recipient override > personal
// When the sender specified a target publisher, honor it unconditionally
let targetPublisherId: Id<"publishers">;
if (transfer.toPublisherId) {
targetPublisherId = transfer.toPublisherId;
} else if (args.publisherId) {
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toPublisherId: args.publisherId,
});
targetPublisherId = args.publisherId;
} else {
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
targetPublisherId = newPublisher._id;
}
// For org-targeted transfers, ownerUserId is set to whichever admin accepts,
// not necessarily the original toUserId. The toUserHandle on the request just
// routes the transfer — actual ownership reflects who acted on it.
await ctx.db.patch(skill._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: newPublisher._id,
ownerPublisherId: targetPublisherId,
updatedAt: now,
});
@@ -190,12 +244,16 @@ export const acceptTransferInternal = internalMutation({
for (const alias of aliases) {
await ctx.db.patch(alias._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: newPublisher._id,
ownerPublisherId: targetPublisherId,
updatedAt: now,
});
}
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
await ctx.db.patch(transfer._id, {
status: "accepted",
respondedAt: now,
toUserId: args.actorUserId,
});
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -229,6 +287,12 @@ export const rejectTransferInternal = internalMutation({
now,
});
await validateTransferAcceptPermission(ctx, {
actorUserId: args.actorUserId,
toUserId: transfer.toUserId ?? undefined,
toPublisherId: transfer.toPublisherId,
});
await ctx.db.patch(transfer._id, { status: "rejected", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -259,6 +323,15 @@ export const cancelTransferInternal = internalMutation({
now,
});
// For org-owned transfers, always verify actor still has admin/owner role
if (transfer.fromPublisherId) {
await validateTransferOwnership(ctx, {
ownerUserId: transfer.fromUserId,
ownerPublisherId: transfer.fromPublisherId,
actorUserId: args.actorUserId,
});
}
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
@@ -279,28 +352,63 @@ export const listIncomingInternal = internalQuery({
const now = Date.now();
await requireActiveUserById(ctx, args.userId);
const transfers = await ctx.db
// Query transfers directed at this user personally
const userTransfers = await ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_to_user_status", (q) => q.eq("toUserId", args.userId).eq("status", "pending"))
.collect();
// Query transfers directed at orgs where this user is an admin or owner
const memberships = await ctx.db
.query("publisherMembers")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const adminPublisherIds = memberships
.filter((m) => m.role === "owner" || m.role === "admin")
.map((m) => m.publisherId);
const orgTransferArrays = await Promise.all(
adminPublisherIds.map((publisherId) =>
ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_to_publisher_status", (q) =>
q.eq("toPublisherId", publisherId).eq("status", "pending"),
)
.collect(),
),
);
const orgTransfers = orgTransferArrays.flat();
// Merge and deduplicate by transfer ID
const seen = new Set<string>();
const allTransfers: TransferDoc[] = [];
for (const t of [...userTransfers, ...orgTransfers]) {
if (!seen.has(t._id)) {
seen.add(t._id);
allTransfers.push(t);
}
}
const results: Array<{
type: "skill";
_id: Id<"skillOwnershipTransfers">;
skill: { _id: Id<"skills">; slug: string; displayName: string };
fromUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
toPublisherId?: Id<"publishers">;
message: string | undefined;
requestedAt: number;
expiresAt: number;
}> = [];
for (const transfer of transfers) {
if (isExpired(transfer, now)) continue;
for (const transfer of allTransfers) {
if (isTransferExpired(transfer, now)) continue;
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) continue;
const fromUser = await ctx.db.get(transfer.fromUserId);
if (!fromUser || fromUser.deletedAt || fromUser.deactivatedAt) continue;
results.push({
type: "skill" as const,
_id: transfer._id,
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
fromUser: {
@@ -308,6 +416,7 @@ export const listIncomingInternal = internalQuery({
handle: fromUser.handle ?? null,
displayName: fromUser.displayName ?? null,
},
toPublisherId: transfer.toPublisherId ?? undefined,
message: transfer.message,
requestedAt: transfer.requestedAt,
expiresAt: transfer.expiresAt,
@@ -332,29 +441,41 @@ export const listOutgoingInternal = internalQuery({
.collect();
const results: Array<{
type: "skill";
_id: Id<"skillOwnershipTransfers">;
skill: { _id: Id<"skills">; slug: string; displayName: string };
toUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
toUser?: { _id: Id<"users">; handle: string | null; displayName: string | null };
toPublisherId?: Id<"publishers">;
message: string | undefined;
requestedAt: number;
expiresAt: number;
}> = [];
for (const transfer of transfers) {
if (isExpired(transfer, now)) continue;
if (isTransferExpired(transfer, now)) continue;
const skill = await ctx.db.get(transfer.skillId);
if (!skill || skill.softDeletedAt) continue;
const toUser = await ctx.db.get(transfer.toUserId);
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) continue;
let toUser:
| { _id: Id<"users">; handle: string | null; displayName: string | null }
| undefined;
if (transfer.toUserId) {
const tu = await ctx.db.get(transfer.toUserId);
if (tu && !tu.deletedAt && !tu.deactivatedAt) {
toUser = {
_id: tu._id,
handle: tu.handle ?? null,
displayName: tu.displayName ?? null,
};
}
}
results.push({
type: "skill" as const,
_id: transfer._id,
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
toUser: {
_id: toUser._id,
handle: toUser.handle ?? null,
displayName: toUser.displayName ?? null,
},
toUser,
toPublisherId: transfer.toPublisherId ?? undefined,
message: transfer.message,
requestedAt: transfer.requestedAt,
expiresAt: transfer.expiresAt,
@@ -365,6 +486,22 @@ export const listOutgoingInternal = internalQuery({
},
});
export const getPendingTransferBySkillInternal = internalQuery({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const now = Date.now();
const transfer = await ctx.db
.query("skillOwnershipTransfers")
.withIndex("by_skill_status", (q) => q.eq("skillId", args.skillId).eq("status", "pending"))
.first();
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
export const getPendingTransferBySkillAndUserInternal = internalQuery({
args: {
skillId: v.id("skills"),
@@ -378,7 +515,7 @@ export const getPendingTransferBySkillAndUserInternal = internalQuery({
.filter((q) => q.eq(q.field("toUserId"), args.toUserId))
.first();
if (!transfer || isExpired(transfer, now)) return null;
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
@@ -396,7 +533,7 @@ export const getPendingTransferBySkillAndFromUserInternal = internalQuery({
.filter((q) => q.eq(q.field("fromUserId"), args.fromUserId))
.first();
if (!transfer || isExpired(transfer, now)) return null;
if (!transfer || isTransferExpired(transfer, now)) return null;
return transfer;
},
});
-72
View File
@@ -35,12 +35,6 @@ const getBySlugHandler = (
image: string | null;
bio?: string | null;
} | null;
latestVersion?: {
files?: Array<{
path: string;
contentType?: string;
}>;
} | null;
} | null
>
)._handler;
@@ -177,70 +171,4 @@ describe("skills.getBySlug", () => {
expect(result).toBeNull();
});
it("normalizes misleading file MIME types in public version metadata", async () => {
const ctx = makeCtx({
skill: {
_id: "skills:1",
_creationTime: 1,
slug: "demo",
displayName: "Demo",
summary: "Public demo skill",
ownerUserId: "users:1",
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: "skillVersions:1",
tags: {},
stats: {
downloads: 10,
installsCurrent: 2,
installsAllTime: 5,
stars: 3,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
moderationStatus: "active",
moderationFlags: undefined,
softDeletedAt: undefined,
},
owner: {
_id: "users:1",
_creationTime: 1,
handle: "demo-owner",
name: "Demo Owner",
displayName: "Demo Owner",
image: null,
},
latestVersion: {
_id: "skillVersions:1",
_creationTime: 2,
skillId: "skills:1",
version: "1.0.0",
fingerprint: "abc",
changelog: "",
changelogSource: "user",
files: [
{
path: "src/index.ts",
size: 10,
sha256: "deadbeef",
contentType: "video/mp2t",
},
],
createdBy: "users:1",
createdAt: 2,
},
});
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
expect(result?.latestVersion?.files).toEqual([
expect.objectContaining({
path: "src/index.ts",
contentType: "application/typescript",
}),
]);
});
});
+1 -10
View File
@@ -1,5 +1,4 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { normalizeTextContentType } from "clawhub-schema";
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v, type Value } from "convex/values";
@@ -84,7 +83,6 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -759,7 +757,6 @@ async function hardDeleteSkillStep(
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
}
switch (phase) {
@@ -1232,7 +1229,7 @@ function toPublicSkillVersion(
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: normalizeTextContentType(file.path, file.contentType),
contentType: file.contentType,
})),
parsed: version.parsed
? {
@@ -2531,7 +2528,6 @@ export const report = mutation({
const nextSkill = { ...skill, ...updates };
await ctx.db.patch(skill._id, updates);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
if (shouldAutoHide) {
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
@@ -4258,7 +4254,6 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
}
@@ -4370,7 +4365,6 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
restoredCount += 1;
@@ -5310,7 +5304,6 @@ export const setSoftDeleted = mutation({
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
@@ -5349,7 +5342,6 @@ export const changeOwner = mutation({
lastReviewedAt: now,
updatedAt: now,
});
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
for (const embedding of embeddings) {
@@ -6430,7 +6422,6 @@ export const insertVersion = internalMutation({
// Digest sync is handled after the version patch below (line ~4222),
// which captures the final state including latestVersionId and tags.
await adjustGlobalPublicCountForSkillChange(ctx, null, skill);
await adjustUserSkillStatsForSkillChange(ctx, null, skill);
}
}
+1 -74
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
import { getSoulBySlugInternal, insertVersion } from "./souls";
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
@@ -10,7 +10,6 @@ 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 () => {
@@ -140,75 +139,3 @@ 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",
}),
]);
});
});
+3 -2
View File
@@ -138,10 +138,11 @@ export const list = query({
}
const entries = await ctx.db
.query("souls")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.take(limit);
.take(limit * 5);
return entries
.filter((soul) => !soul.softDeletedAt)
.slice(0, limit)
.map((soul) => toPublicSoul(soul))
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
},
-75
View File
@@ -1075,81 +1075,6 @@ 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",
+20 -58
View File
@@ -1,17 +1,22 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
} from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { toPublicUser } from "./lib/public";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -386,23 +393,6 @@ export const list = query({
},
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 40, 1, 100);
const result = await queryUsersForPublicList(ctx, {
limit,
search: args.search,
});
return {
items: result.items
.map((user) => toPublicUser(user))
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
total: result.total,
};
},
});
function normalizeSearchQuery(search?: string) {
const trimmed = search?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
@@ -413,7 +403,13 @@ function computeUserSearchScanLimit(limit: number) {
}
async function queryUsersForAdminList(
ctx: Pick<QueryCtx, "db">,
ctx: {
db: {
query: (table: "users") => {
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
};
};
},
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
@@ -435,27 +431,6 @@ async function queryUsersForAdminList(
};
}
async function queryUsersForPublicList(
ctx: Pick<QueryCtx, "db">,
args: { limit: number; search?: string },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
const scanLimit = normalizedSearch
? computeUserSearchScanLimit(args.limit)
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
const scannedUsers = await ctx.db
.query("users")
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
.order("desc")
.take(scanLimit);
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
const result = buildUserSearchResults(activeUsers, normalizedSearch);
return {
items: result.items.slice(0, args.limit),
total: result.total,
};
}
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(Math.trunc(value), min), max);
}
@@ -467,20 +442,6 @@ export const getByHandle = query({
},
});
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
export const getHoverStats = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
return {
publishedSkills: user?.publishedSkills ?? 0,
totalStars: user?.totalStars ?? 0,
totalDownloads: user?.totalDownloads ?? 0,
};
},
});
export const getReservedHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
@@ -899,7 +860,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
+5 -6
View File
@@ -62,9 +62,6 @@ 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
@@ -180,13 +177,15 @@ Stores your API token + cached registry URL.
### `transfer`
- Ownership transfer workflow.
- Skill ownership transfer workflow.
- Supports user-to-user, user-to-org, org-to-user, and org-to-org transfers.
- Subcommands:
- `transfer request <slug> <handle> [--message "..."] [--yes]`
- `transfer request <slug> <handle> [--message "..."] [--publisher @org] [--yes]`
- `transfer list [--outgoing]`
- `transfer accept <slug> [--yes]`
- `transfer accept <slug> [--publisher @org] [--yes]`
- `transfer reject <slug> [--yes]`
- `transfer cancel <slug> [--yes]`
- `--publisher @org`: on request, targets the transfer to an org (recipient must be org admin to accept). On accept, assigns ownership to the org instead of the accepting user's personal publisher.
- Endpoints:
- `POST /api/v1/skills/{slug}/transfer`
- `POST /api/v1/skills/{slug}/transfer/accept`
+19 -2
View File
@@ -478,16 +478,33 @@ Notes:
### Transfer ownership endpoints
Transfers support user-to-user, user-to-org, org-to-user, and org-to-org flows for skills. Org-targeted transfers require the actor to hold `admin` or `owner` role on the relevant publisher.
#### Skill transfers
- `POST /api/v1/skills/{slug}/transfer`
- Body: `{ "toUserHandle": "target_handle", "message": "optional" }`
- Body: `{ "toUserHandle": "target_handle", "message": "optional", "toPublisherHandle": "optional_org_handle" }`
- When `toPublisherHandle` is provided, the transfer targets the org. The recipient (or any org admin) accepts on behalf of the org.
- Response: `{ "ok": true, "transferId": "skillOwnershipTransfers:...", "toUserHandle": "target_handle", "expiresAt": 1730000000000 }`
- `POST /api/v1/skills/{slug}/transfer/accept`
- Optional body: `{ "publisherHandle": "org_handle" }` — assign the skill to an org instead of the accepting user's personal publisher.
- `POST /api/v1/skills/{slug}/transfer/reject`
- `POST /api/v1/skills/{slug}/transfer/cancel`
- Response (accept/reject/cancel): `{ "ok": true, "skillSlug": "demo-skill?" }`
#### Transfer listing
- `GET /api/v1/transfers/incoming`
- `GET /api/v1/transfers/outgoing`
- Response shape: `{ "transfers": [{ "_id": "...", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
- Returns skill transfers, sorted by `requestedAt` descending.
- Response shape: `{ "transfers": [{ "_id": "...", "type": "skill", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
#### Transfer rules
- Pending transfers expire after 7 days.
- Only one pending transfer per skill at a time.
- Ownership is re-validated at accept time; if ownership changed since the request, the transfer is auto-cancelled.
- Org transfers require `admin` or `owner` role on the source/target publisher.
### `POST /api/v1/users/ban`
+6 -9
View File
@@ -325,12 +325,9 @@ Semantics:
## Transfer Model
Current transfers are user-to-user only. That is too narrow.
New transfer target should be a publisher.
Support:
Transfers support publisher-based flows for both skills and packages:
- user publisher -> user publisher
- user publisher -> org publisher
- org publisher -> user publisher
- org publisher -> org publisher
@@ -339,12 +336,12 @@ Transfer acceptance rule:
- actor must have `owner` or `admin` on target publisher
Audit should record:
Audit records:
- actor user id
- source publisher id
- target publisher id
- resource id
- source publisher id (`fromPublisherId`)
- target publisher id (`toPublisherId`)
- resource id (skill or package)
## Search Digest Changes
-6
View File
@@ -46,8 +46,6 @@
"@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",
@@ -60,7 +58,6 @@
"@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",
@@ -70,8 +67,6 @@
"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",
@@ -82,7 +77,6 @@
"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"
+1 -6
View File
@@ -27,12 +27,7 @@ clawhub login --token clh_...
Notes:
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
- 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`).
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
## Examples
+6 -4
View File
@@ -503,10 +503,11 @@ const transfer = program.command("transfer").description("Transfer skill ownersh
transfer
.command("request")
.description("Request skill transfer to another user")
.description("Request skill transfer to another user or organization")
.argument("<slug>", "Skill slug")
.argument("<handle>", "Recipient handle (e.g., @username)")
.option("--message <text>", "Optional message for recipient")
.option("--publisher <handle>", "Target org publisher handle")
.option("--yes", "Skip confirmation")
.action(async (slug, handle, options) => {
const opts = await resolveGlobalOpts();
@@ -524,8 +525,9 @@ transfer
transfer
.command("accept")
.description("Accept incoming transfer for a skill")
.description("Accept incoming skill transfer")
.argument("<slug>", "Skill slug")
.option("--publisher <handle>", "Accept to org publisher instead of personal")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
@@ -534,7 +536,7 @@ transfer
transfer
.command("reject")
.description("Reject incoming transfer for a skill")
.description("Reject incoming skill transfer")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
@@ -544,7 +546,7 @@ transfer
transfer
.command("cancel")
.description("Cancel outgoing transfer for a skill")
.description("Cancel outgoing skill transfer")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
@@ -64,8 +64,10 @@ describe("transfer commands", () => {
}),
expect.anything(),
);
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as {
body?: Record<string, string>;
};
expect(requestArgs.body?.toUserHandle).toBe("alice");
});
it("list calls incoming transfers endpoint", async () => {
+20 -8
View File
@@ -63,7 +63,7 @@ export async function cmdTransferRequest(
opts: GlobalOpts,
slugArg: string,
toHandleArg: string,
options: ConfirmOptions & { message?: string },
options: ConfirmOptions & { message?: string; publisher?: string },
inputAllowed: boolean,
) {
const slug = normalizeSlug(slugArg);
@@ -73,7 +73,7 @@ export async function cmdTransferRequest(
const confirmed = await requireYesOrConfirm(
options,
inputAllowed,
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
`Transfer skill "${slug}" to @${toHandle}? Recipient must accept.`,
);
if (!confirmed) return;
@@ -82,16 +82,21 @@ export async function cmdTransferRequest(
const spinner = createSpinner(`Requesting transfer of ${slug} to @${toHandle}`);
try {
const body: Record<string, string | undefined> = {
toUserHandle: toHandle,
message: options.message,
};
if (options.publisher) {
body.toPublisherHandle = options.publisher.replace(/^@+/, "");
}
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer`,
token,
body: JSON.stringify({
toUserHandle: toHandle,
message: options.message,
}),
body,
},
ApiV1TransferRequestResponseSchema,
);
@@ -150,11 +155,12 @@ export async function cmdTransferList(opts: GlobalOpts, options: { outgoing?: bo
async function runTransferDecision(
opts: GlobalOpts,
slugArg: string,
options: ConfirmOptions,
options: ConfirmOptions & { publisher?: string },
inputAllowed: boolean,
spec: DecisionSpec,
) {
const slug = normalizeSlug(slugArg);
const confirmed = await requireYesOrConfirm(
options,
inputAllowed,
@@ -167,12 +173,18 @@ async function runTransferDecision(
const spinner = createSpinner(`${spec.progress} transfer of ${slug}`);
try {
const body: Record<string, string> | undefined =
spec.action === "accept" && options.publisher
? { publisherHandle: options.publisher.replace(/^@+/, "") }
: undefined;
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer/${spec.action}`,
token,
...(body ? { body } : {}),
},
ApiV1TransferDecisionResponseSchema,
);
@@ -188,7 +200,7 @@ async function runTransferDecision(
export function cmdTransferAccept(
opts: GlobalOpts,
slugArg: string,
options: ConfirmOptions,
options: ConfirmOptions & { publisher?: string },
inputAllowed: boolean,
) {
return runTransferDecision(opts, slugArg, options, inputAllowed, DECISION_SPECS.accept);
-9
View File
@@ -57,14 +57,6 @@ 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?",
@@ -196,7 +188,6 @@ 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,13 +46,6 @@ 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;
@@ -268,12 +261,6 @@ 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,12 +40,6 @@ 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?",
@@ -157,7 +151,6 @@ 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
-2
View File
@@ -3,5 +3,3 @@ export declare const TEXT_FILE_EXTENSION_SET: Set<string>;
export declare const TEXT_CONTENT_TYPES: readonly ["application/json", "application/xml", "application/yaml", "application/x-yaml", "application/toml", "application/javascript", "application/typescript", "application/markdown", "image/svg+xml"];
export declare const TEXT_CONTENT_TYPE_SET: Set<string>;
export declare function isTextContentType(contentType: string): boolean;
export declare function guessTextContentType(path: string): string | undefined;
export declare function normalizeTextContentType(path: string, contentType?: string | null): string | undefined;
-35
View File
@@ -53,26 +53,6 @@ const RAW_TEXT_CONTENT_TYPES = [
];
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
export const TEXT_CONTENT_TYPE_SET = new Set(TEXT_CONTENT_TYPES);
const CANONICAL_TEXT_CONTENT_TYPES = {
md: "text/markdown",
mdx: "text/markdown",
txt: "text/plain",
json: "application/json",
json5: "application/json",
yaml: "application/yaml",
yml: "application/yaml",
toml: "application/toml",
js: "application/javascript",
cjs: "application/javascript",
mjs: "application/javascript",
jsx: "application/javascript",
ts: "application/typescript",
mts: "application/typescript",
cts: "application/typescript",
tsx: "application/typescript",
xml: "application/xml",
svg: "image/svg+xml",
};
export function isTextContentType(contentType) {
if (!contentType)
return false;
@@ -83,19 +63,4 @@ export function isTextContentType(contentType) {
return true;
return TEXT_CONTENT_TYPE_SET.has(normalized);
}
export function guessTextContentType(path) {
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext))
return undefined;
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
}
export function normalizeTextContentType(path, contentType) {
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
const guessed = guessTextContentType(path);
if (!guessed)
return normalized || undefined;
if (isTextContentType(normalized))
return normalized;
return guessed;
}
//# sourceMappingURL=textFiles.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,4BAA4B,GAA2B;IAC3D,EAAE,EAAE,eAAe;IACnB,GAAG,EAAE,eAAe;IACpB,GAAG,EAAE,YAAY;IACjB,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,kBAAkB;IACzB,IAAI,EAAE,kBAAkB;IACxB,GAAG,EAAE,kBAAkB;IACvB,IAAI,EAAE,kBAAkB;IACxB,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,eAAe;CACrB,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9D,IAAI,CAAC,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IAChE,OAAO,4BAA4B,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,IAAY,EAAE,WAA2B;IAChF,MAAM,UAAU,GAAG,WAAW,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7E,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO;QAAE,OAAO,UAAU,IAAI,SAAS,CAAC;IAC7C,IAAI,iBAAiB,CAAC,UAAU,CAAC;QAAE,OAAO,UAAU,CAAC;IACrD,OAAO,OAAO,CAAC;AACjB,CAAC"}
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC"}
-9
View File
@@ -57,14 +57,6 @@ 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?",
@@ -198,7 +190,6 @@ 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",
+1 -23
View File
@@ -2,12 +2,7 @@
import { describe, expect, it } from "vitest";
import * as schema from ".";
import {
guessTextContentType,
isTextContentType,
normalizeTextContentType,
TEXT_FILE_EXTENSION_SET,
} from "./textFiles";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "./textFiles";
describe("clawhub-schema textFiles", () => {
it("exports text-file extension set", () => {
@@ -21,25 +16,8 @@ describe("clawhub-schema textFiles", () => {
expect(isTextContentType("application/octet-stream")).toBe(false);
});
it("guesses canonical content types for text files", () => {
expect(guessTextContentType("src/index.ts")).toBe("application/typescript");
expect(guessTextContentType("README.md")).toBe("text/markdown");
expect(guessTextContentType("image.png")).toBeUndefined();
});
it("normalizes misleading MIME types for text files", () => {
expect(normalizeTextContentType("src/index.ts", "video/mp2t")).toBe("application/typescript");
expect(normalizeTextContentType("README.md", "text/markdown; charset=utf-8")).toBe(
"text/markdown",
);
expect(normalizeTextContentType("image.png", "image/png")).toBe("image/png");
});
it("re-exports helpers from index", () => {
expect(typeof schema.isTextContentType).toBe("function");
expect(schema.isTextContentType("application/markdown")).toBe(true);
expect(schema.normalizeTextContentType("src/index.ts", "video/mp2t")).toBe(
"application/typescript",
);
});
});
-35
View File
@@ -57,27 +57,6 @@ const RAW_TEXT_CONTENT_TYPES = [
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
export const TEXT_CONTENT_TYPE_SET = new Set<string>(TEXT_CONTENT_TYPES);
const CANONICAL_TEXT_CONTENT_TYPES: Record<string, string> = {
md: "text/markdown",
mdx: "text/markdown",
txt: "text/plain",
json: "application/json",
json5: "application/json",
yaml: "application/yaml",
yml: "application/yaml",
toml: "application/toml",
js: "application/javascript",
cjs: "application/javascript",
mjs: "application/javascript",
jsx: "application/javascript",
ts: "application/typescript",
mts: "application/typescript",
cts: "application/typescript",
tsx: "application/typescript",
xml: "application/xml",
svg: "image/svg+xml",
};
export function isTextContentType(contentType: string) {
if (!contentType) return false;
const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
@@ -85,17 +64,3 @@ export function isTextContentType(contentType: string) {
if (normalized.startsWith("text/")) return true;
return TEXT_CONTENT_TYPE_SET.has(normalized);
}
export function guessTextContentType(path: string) {
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext)) return undefined;
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
}
export function normalizeTextContentType(path: string, contentType?: string | null) {
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
const guessed = guessTextContentType(path);
if (!guessed) return normalized || undefined;
if (isTextContentType(normalized)) return normalized;
return guessed;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 28 KiB

-45
View File
@@ -1,45 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" rx="0" fill="none"/>
<!-- Body segments -->
<g fill="#F03020" stroke="#fff" stroke-width="6" stroke-linejoin="round">
<!-- Tail fan - center -->
<ellipse cx="256" cy="460" rx="42" ry="30"/>
<!-- Tail fan - left -->
<ellipse cx="210" cy="455" rx="32" ry="24" transform="rotate(25 210 455)"/>
<!-- Tail fan - right -->
<ellipse cx="302" cy="455" rx="32" ry="24" transform="rotate(-25 302 455)"/>
<!-- Lower body segment -->
<rect x="208" y="380" width="96" height="55" rx="12"/>
<!-- Mid-lower body -->
<rect x="204" y="335" width="104" height="55" rx="12"/>
<!-- Mid body -->
<rect x="200" y="290" width="112" height="55" rx="14"/>
<!-- Upper body / thorax -->
<ellipse cx="256" cy="255" rx="72" ry="65"/>
<!-- Head -->
<circle cx="256" cy="195" r="58"/>
<!-- Left claw arm -->
<path d="M195 230 Q150 190 120 170 Q100 155 95 140" stroke-width="18" fill="none" stroke-linecap="round"/>
<!-- Right claw arm -->
<path d="M317 230 Q362 190 392 170 Q412 155 417 140" stroke-width="18" fill="none" stroke-linecap="round"/>
<!-- Left claw -->
<ellipse cx="88" cy="115" rx="52" ry="42" transform="rotate(-20 88 115)"/>
<!-- Right claw -->
<ellipse cx="424" cy="115" rx="52" ry="42" transform="rotate(20 424 115)"/>
<!-- Left claw notch -->
<path d="M65 95 Q78 80 72 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
<!-- Right claw notch -->
<path d="M447 95 Q434 80 440 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
<!-- Left legs -->
<path d="M205 290 Q175 295 155 305" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M202 320 Q172 330 152 340" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M205 350 Q178 358 158 370" stroke-width="10" fill="none" stroke-linecap="round"/>
<!-- Right legs -->
<path d="M307 290 Q337 295 357 305" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M310 320 Q340 330 360 340" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M307 350 Q334 358 354 370" stroke-width="10" fill="none" stroke-linecap="round"/>
</g>
<!-- Eyes -->
<circle cx="236" cy="185" r="10" fill="#000"/>
<circle cx="276" cy="185" r="10" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

+16 -67
View File
@@ -1,25 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
const siteModeMock = vi.fn(() => "souls");
vi.mock("@tanstack/react-router", () => ({
Link: (props: {
children: ReactNode;
className?: string;
hash?: string;
to?: string;
}) => (
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
</a>
),
useLocation: () => ({ pathname: "/" }),
useNavigate: () => vi.fn(),
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
}));
vi.mock("@convex-dev/auth/react", () => ({
@@ -29,30 +16,19 @@ vi.mock("@convex-dev/auth/react", () => ({
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => authStatusMock(),
useAuthStatus: () => ({
isAuthenticated: false,
isLoading: false,
me: null,
}),
}));
const setThemeMock = vi.fn();
const setModeMock = vi.fn();
vi.mock("../lib/theme", () => ({
applyTheme: vi.fn(),
THEME_OPTIONS: [
{ value: "claw", label: "Claw", description: "" },
{ value: "hub", label: "Hub", description: "" },
],
useThemeMode: () => ({
theme: "hub",
mode: "system",
setTheme: setThemeMock,
setMode: setModeMock,
setMode: vi.fn(),
}),
}));
@@ -80,10 +56,14 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => siteModeMock(),
getSiteMode: () => "souls",
getSiteName: () => "OnlyCrabs",
}));
vi.mock("../lib/convexError", () => ({
getUserFacingConvexError: vi.fn(),
}));
vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
@@ -98,46 +78,15 @@ vi.mock("../components/ui/dropdown-menu", () => ({
vi.mock("../components/ui/toggle-group", () => ({
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
}));
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
render(<Header />);
expect(screen.getByText("Theme")).toBeTruthy();
expect(screen.getByRole("button", { name: "Claw" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Hub" })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Souls")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hub" }));
expect(setThemeMock).toHaveBeenCalledWith("hub");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme family/i }));
expect(setThemeMock).toHaveBeenCalledWith("claw");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Souls")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
});
});
-1
View File
@@ -17,7 +17,6 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => {
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
@@ -26,7 +26,6 @@ const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useQuery: () => undefined,
@@ -277,12 +276,6 @@ 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();
});
+72 -47
View File
@@ -1,74 +1,99 @@
import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
__config: config,
}),
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
redirect: (options: unknown) => ({ redirect: options }),
Link: "a",
useNavigate: () => vi.fn(),
}));
import { Route } from "../routes/search";
function runValidateSearch(search: Record<string, unknown>) {
function runBeforeLoad(
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
hostname = "clawdhub.com",
) {
const route = Route as unknown as {
__config: {
validateSearch?: (search: Record<string, unknown>) => unknown;
beforeLoad?: (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
};
};
const validateSearch = route.__config.validateSearch;
return validateSearch ? validateSearch(search) : {};
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
let thrown: unknown;
try {
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
} catch (error) {
thrown = error;
}
return thrown;
}
describe("search route", () => {
it("validates search with query", () => {
expect(runValidateSearch({ q: "crab" })).toEqual({
q: "crab",
type: undefined,
it("redirects skills host to the skills index", () => {
expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
redirect: {
to: "/skills",
search: {
q: "crab",
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
},
replace: true,
},
});
});
it("validates search with type filter", () => {
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
q: "crab",
type: "skills",
it("forwards nonSuspicious filter to skills index", () => {
expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
redirect: {
to: "/skills",
search: {
q: "crab",
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
},
replace: true,
},
});
});
it("ignores invalid type filter", () => {
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
q: "crab",
type: undefined,
it("redirects souls host with query to home search", () => {
expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
redirect: {
to: "/",
search: {
q: "crab",
highlighted: undefined,
search: undefined,
},
replace: true,
},
});
});
it("accepts the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
it("redirects souls host without query to home with search mode", () => {
expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
redirect: {
to: "/",
search: {
q: undefined,
highlighted: undefined,
search: true,
},
replace: true,
},
});
});
it("strips empty query", () => {
expect(runValidateSearch({ q: " " })).toEqual({
q: undefined,
type: undefined,
});
});
it("has a component (not a redirect-only route)", () => {
const route = Route as unknown as {
__config: { component?: unknown };
};
expect(route.__config.component).toBeDefined();
});
});
+194 -53
View File
@@ -6,18 +6,6 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
const navigateMock = vi.fn();
const useAuthStatusMock = vi.fn();
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../components/UserBadge", () => ({
UserBadge: () => null,
}));
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => navigateMock,
@@ -27,7 +15,6 @@ const useQueryMock = vi.fn();
const getReadmeMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
@@ -37,8 +24,8 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillCommentsPanel", () => ({
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
}));
describe("SkillDetailPage", () => {
@@ -71,8 +58,11 @@ describe("SkillDetailPage", () => {
return undefined;
});
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
const { container } = render(<SkillDetailPage slug="weather" />);
// Loading state now renders a skeleton, not text
expect(
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -145,11 +135,174 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
// With initialData, should render content instead of skeleton
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
});
it("shows capability tags on the skill page without other scan findings", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
render(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Crypto payments for AI skills.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
sha256hash: "abc123",
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
expect(screen.getByText("Capability signals")).toBeTruthy();
expect(screen.getByText("Crypto")).toBeTruthy();
expect(screen.getByText("Requires wallet")).toBeTruthy();
expect(screen.getByText("Can make purchases")).toBeTruthy();
});
it("prefers the full frontmatter description over the shortened summary in the header", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
const fullDescription =
"Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill.";
render(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: {
license: "MIT-0",
frontmatter: {
description: fullDescription,
},
},
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
// The header now always shows skill.summary (not frontmatter.description)
expect(
screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."),
).toBeTruthy();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -222,7 +375,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(getReadmeMock).not.toHaveBeenCalled();
});
@@ -241,17 +394,17 @@ describe("SkillDetailPage", () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: {
_id: "publishers:steipete",
_creationTime: 0,
@@ -264,8 +417,9 @@ describe("SkillDetailPage", () => {
};
});
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
// Loading state now renders a skeleton, not text
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -370,7 +524,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(navigateMock).not.toHaveBeenCalled();
});
@@ -480,24 +634,10 @@ describe("SkillDetailPage", () => {
it("defers compare version query until compare tab is requested", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (
args &&
typeof args === "object" &&
"skillId" in args &&
"limit" in args &&
(args as { limit: number }).limit === 50
) {
return [
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
];
}
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
if ((args as { limit: number }).limit === 200) return [];
}
if (args && typeof args === "object" && "limit" in args) {
return [];
}
if (args && typeof args === "object" && "skillId" in args) return [];
if (args && typeof args === "object" && "slug" in args) {
return {
skill: {
@@ -526,7 +666,6 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(await screen.findByText("Weather")).toBeTruthy();
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
expect(
useQueryMock.mock.calls.some((call) => {
@@ -540,7 +679,9 @@ describe("SkillDetailPage", () => {
}),
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
const compareTab = screen.getByRole("tab", { name: /compare/i });
fireEvent.mouseEnter(compareTab);
fireEvent.click(compareTab);
await waitFor(() => {
expect(
-22
View File
@@ -17,7 +17,6 @@ vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
component?: unknown;
head?: unknown;
@@ -32,7 +31,6 @@ vi.mock("../lib/skillPage", () => ({
async function loadRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
head?: (args: {
params: { owner: string; slug: string };
@@ -47,14 +45,6 @@ async function loadRoute() {
};
}
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as ((args: {
params: { owner: string; slug: string };
}) => unknown) | undefined;
return beforeLoad?.({ params });
}
async function runLoader(params: { owner: string; slug: string }) {
const route = await loadRoute();
const loader = route.__config.loader as (args: {
@@ -81,18 +71,6 @@ function runHead(
}
describe("skill route loader", () => {
it("allows numeric owner handles in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
});
it("allows raw owner ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
});
it("allows raw publisher ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
});
@@ -23,7 +23,6 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+48 -16
View File
@@ -23,7 +23,6 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
@@ -67,7 +66,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.getByText("No skills match that filter")).toBeTruthy();
});
it("shows loading state before fetch completes", async () => {
@@ -75,9 +74,9 @@ describe("SkillsIndex", () => {
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
render(<SkillsIndex />);
await act(async () => {});
// Results area shows skeleton or dash while loading
expect(screen.getByText("\u2014")).toBeTruthy();
expect(screen.queryByText("No skills found")).toBeNull();
// Header subtitle shows "Loading skills..."
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("No skills match that filter")).toBeNull();
});
it("shows empty state immediately when search returns no results", async () => {
@@ -92,8 +91,8 @@ describe("SkillsIndex", () => {
});
// Should show empty state, not loading
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/Loading skills/)).toBeNull();
expect(screen.getByText("No skills match that filter")).toBeTruthy();
expect(screen.queryByText("Loading skills...")).toBeNull();
});
it("skips list fetch and calls search when query is set", async () => {
@@ -137,7 +136,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -162,7 +161,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -252,12 +251,9 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
const titles = Array.from(
document.querySelectorAll(".skill-list-item-name"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
expect(titles[1]).toBe("Newer Low Score");
const links = screen.getAllByRole("link");
expect(links[0]?.textContent).toContain("Older High Score");
expect(links[1]?.textContent).toContain("Newer Low Score");
});
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
@@ -292,6 +288,42 @@ describe("SkillsIndex", () => {
);
});
it("passes capabilityTag to list query when tag filter is active", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
expect(convexHttpMock.query).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
capabilityTag: "crypto",
}),
);
});
it("shows and clears the active capability tag filter", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
expect(capabilityChip).toBeTruthy();
await act(async () => {
fireEvent.click(capabilityChip);
});
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.replace).toBe(true);
expect(lastCall.search({ tag: "crypto" })).toEqual({
tag: undefined,
});
});
it("shows load-more button when more results are available", async () => {
vi.stubGlobal("IntersectionObserver", undefined);
convexHttpMock.query.mockResolvedValue({
@@ -324,7 +356,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
expect(screen.getByText(/Loading/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
});
});
-1
View File
@@ -22,7 +22,6 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => generateUploadUrl,
useAction: () => {
+4 -7
View File
@@ -3,7 +3,6 @@ import { useEffect, useRef } from "react";
import { convex } from "../convex/client";
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { TooltipProvider } from "./ui/tooltip";
import { UserBootstrap } from "./UserBootstrap";
function getPendingAuthCode() {
@@ -83,12 +82,10 @@ export function AuthErrorHandler() {
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</TooltipProvider>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</ConvexAuthProvider>
);
}
-120
View File
@@ -1,120 +0,0 @@
import {
Database,
GitBranch,
MessageSquare,
Package,
Plug,
Shield,
Wrench,
Zap,
} from "lucide-react";
import type { SkillCategory } from "../lib/categories";
type FilterItem = {
key: string;
label: string;
active: boolean;
};
type SortOption = {
value: string;
label: string;
};
type BrowseSidebarProps = {
categories?: SkillCategory[];
activeCategory?: string;
onCategoryChange?: (slug: string | undefined) => void;
sortOptions: SortOption[];
activeSort: string;
onSortChange: (value: string) => void;
filters: FilterItem[];
onFilterToggle: (key: string) => void;
};
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
"mcp-tools": <Plug size={15} />,
prompts: <MessageSquare size={15} />,
workflows: <GitBranch size={15} />,
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <Zap size={15} />,
other: <Package size={15} />,
};
export function BrowseSidebar({
categories,
activeCategory,
onCategoryChange,
sortOptions,
activeSort,
onSortChange,
filters,
onFilterToggle,
}: BrowseSidebarProps) {
return (
<aside className="browse-sidebar" aria-label="Browse filters">
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
<legend className="sidebar-title">Sort by</legend>
{sortOptions.map((opt) => (
<button
key={opt.value}
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeSort === opt.value}
onClick={() => onSortChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
{categories && onCategoryChange ? (
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
<legend className="sidebar-title">Categories</legend>
<button
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={!activeCategory}
onClick={() => onCategoryChange(undefined)}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.slug}
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeCategory === cat.slug}
onClick={() => onCategoryChange(cat.slug)}
>
<span className="sidebar-option-icon" aria-hidden="true">
{CATEGORY_ICONS[cat.slug]}
</span>
{cat.label}
</button>
))}
</fieldset>
) : null}
<fieldset className="sidebar-section" aria-label="Toggle filters">
<legend className="sidebar-title">Filters</legend>
{filters.map((f) => (
<label key={f.key} className="sidebar-checkbox">
<input
type="checkbox"
checked={f.active}
onChange={() => onFilterToggle(f.key)}
aria-label={f.label}
/>
<span>{f.label}</span>
</label>
))}
</fieldset>
</aside>
);
}
+11 -1
View File
@@ -69,7 +69,17 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
style={{
margin: "16px auto 0",
width: "min(1100px, calc(100vw - 32px))",
border: "1px solid #f59e0b",
background: "#fff7ed",
color: "#9a3412",
borderRadius: "14px",
padding: "12px 16px",
fontSize: "0.95rem",
lineHeight: 1.4,
}}
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+24 -41
View File
@@ -1,51 +1,34 @@
import { Link } from "@tanstack/react-router";
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
import { getSiteName } from "../lib/site";
import { Separator } from "./ui/separator";
export function Footer() {
const siteName = getSiteName();
return (
<footer className="site-footer" role="contentinfo">
<div className="site-footer-inner">
<div className="site-footer-divider" aria-hidden="true" />
<div className="footer-grid">
{FOOTER_NAV_SECTIONS.map((section) => (
<div key={section.title} className="footer-col">
<h4 className="footer-col-title">{section.title}</h4>
{section.items.map((item) => {
if (item.kind === "link") {
return (
<Link key={item.label} to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
);
}
if (item.kind === "external") {
return (
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
{item.label}
</a>
);
}
// kind === "text"
return <span key={item.label}>{item.label}</span>;
})}
</div>
))}
</div>
<div className="footer-bottom">
<span>
{siteName} An{" "}
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
OpenClaw
</a>{" "}
project by{" "}
<a href="https://steipete.me" target="_blank" rel="noreferrer">
Peter Steinberger
</a>
</span>
<footer className="mt-auto px-7 pb-8 pt-12">
<div className="mx-auto max-w-[1200px]">
<Separator className="mb-6" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
<FooterLink href="https://vercel.com">Vercel</FooterLink>
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
</div>
</div>
</footer>
);
}
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
>
{children}
</a>
);
}
+264 -404
View File
@@ -1,21 +1,16 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
import { useMemo, useRef } from "react";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -24,444 +19,309 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
plug: Plug,
ghost: Ghost,
};
const THEME_FAMILY_ICONS: Record<string, ComponentType<{ size?: number; className?: string }>> = {
claw: Ghost,
hub: Plug,
};
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode, setTheme } = useThemeMode();
const { signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
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";
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
const hasResolvedUser = Boolean(me);
const navCtx = useMemo(
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const themeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "Claw";
const ThemeFamilyIcon = THEME_FAMILY_ICONS[theme] ?? Wrench;
const ThemeModeIcon = getThemeModeIcon(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
const setTheme = (next: "system" | "light" | "dark") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode, theme);
applyTheme(nextMode);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
const setThemeFamily = (nextTheme: string) => {
applyTheme(mode, nextTheme);
setTheme(nextTheme);
};
const cycleThemeFamily = () => {
const currentIndex = Math.max(
0,
THEME_OPTIONS.findIndex((option) => option.value === theme),
);
const nextTheme = THEME_OPTIONS[(currentIndex + 1) % THEME_OPTIONS.length]?.value ?? "claw";
setThemeFamily(nextTheme);
};
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
};
const handleNavSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = navSearchQuery.trim();
if (!q) return;
void navigate({
to: "/search",
search: { q, type: undefined },
});
setNavSearchQuery("");
setMobileSearchOpen(false);
};
const navLinks = (
<>
{isSoulMode ? (
<a
href={clawHubUrl}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
ClawHub
</a>
) : null}
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Skills
</Link>
)}
{isSoulMode ? null : (
<Link
to="/plugins"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Plugins
</Link>
)}
<Link
to={isSoulMode ? "/souls" : "/skills"}
search={
isSoulMode
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: "search",
}
}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
>
<Search className="h-3.5 w-3.5" />
Search
</Link>
{isSoulMode ? null : (
<Link
to="/about"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
About
</Link>
)}
{me ? (
<Link
to="/stars"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Stars
</Link>
) : null}
{isStaff ? (
<Link
to="/management"
search={{ skill: undefined }}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Management
</Link>
) : null}
</>
);
return (
<header className="navbar">
<div className="navbar-inner">
{/* Row 1: Brand + Search + Actions */}
<div className="navbar-top">
<div className="nav-mobile">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<button
className="nav-mobile-trigger"
type="button"
aria-label="Open menu"
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
<SheetContent side="left" className="mobile-nav-sheet">
<SheetHeader className="pr-10">
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
{/* Brand */}
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="h-full w-full rounded-full object-cover"
/>
</span>
<span>{siteName}</span>
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
{/* Actions */}
<div className="flex items-center gap-3">
{/* Publish CTA (desktop, authenticated) */}
{isAuthenticated && me && (
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="hidden sm:block"
>
<Button variant="primary" size="sm">
<Plus className="h-3.5 w-3.5" />
Publish
</Button>
</Link>
)}
{/* Mobile nav trigger */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-72">
<SheetHeader>
<SheetTitle>{siteName}</SheetTitle>
<SheetDescription>
Browse sections, switch theme, and access account actions.
</SheetDescription>
</SheetHeader>
<div className="mobile-nav-section">
{isSoulMode ? (
<SheetClose asChild>
<a href={clawHubUrl} className="mobile-nav-link">
ClawHub
</a>
</SheetClose>
) : null}
{primaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label}
</Link>
</SheetClose>
))}
{secondaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label === "Management" ? "Manage" : item.label}
</Link>
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme family</div>
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeFamily(option.value);
setMobileMenuOpen(false);
}}
>
<span>{option.label}</span>
{theme === option.value ? <span className="mobile-nav-meta">Selected</span> : null}
</button>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("system");
setMobileMenuOpen(false);
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
{/* Mobile theme toggle */}
<div className="mt-6 flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
Theme
</span>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("light");
setMobileMenuOpen(false);
}}
>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("dark");
setMobileMenuOpen(false);
}}
>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
</button>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Mobile publish link */}
{isAuthenticated && me && (
<div className="mt-6">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="primary" className="w-full">
<Plus className="h-4 w-4" />
Publish Skill
</Button>
</Link>
</div>
)}
</SheetContent>
</Sheet>
</div>
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
</span>
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</form>
<div className="nav-actions">
<button
className="navbar-search-mobile-trigger"
type="button"
aria-label="Search"
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
{/* Desktop theme toggle */}
<div className="theme-toggle hidden md:block" ref={toggleRef}>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-picker-desktop" aria-label={`Theme family, current ${themeLabel}`}>
<div className="theme-family-toggle" role="group" aria-label="Theme family">
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className="theme-family-button"
data-state={theme === option.value ? "on" : "off"}
aria-pressed={theme === option.value}
onClick={() => setThemeFamily(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="theme-cycle-group" aria-label="Theme controls">
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* User menu / Sign in */}
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="theme-cycle-button theme-cycle-button-family"
onClick={cycleThemeFamily}
aria-label={`Cycle theme family. Current: ${themeLabel}`}
title={`Theme family: ${themeLabel}`}
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
>
<ThemeFamilyIcon className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label={`Theme mode, ${themeLabel} preset`}
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="user-trigger" type="button">
{avatar ? (
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
) : (
<span className="user-menu-fallback">{initial}</span>
<Avatar className="h-7 w-7">
{avatar && (
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
)}
<span className="mono">@{handle}</span>
<span className="user-menu-chevron"></span>
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
</Avatar>
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
<span className="text-xs text-[color:var(--ink-soft)]"></span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
role="alert"
>
{authError}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
>
&times;
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div className="error mr-2 text-[0.85rem]" role="alert">
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
>
&times;
</button>
</div>
) : null}
<Button
variant="primary"
size="sm"
type="button"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
</Button>
</>
)}
</div>
</div>
) : null}
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</SignInButton>
</>
)}
</div>
{/* Mobile search bar (expandable) */}
{mobileSearchOpen ? (
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
/>
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : 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}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{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>
</header>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function getThemeModeIcon(mode: "system" | "light" | "dark") {
switch (mode) {
case "light":
return Sun;
case "dark":
return Moon;
case "system":
default:
return Monitor;
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
type="button"
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
pm === entry.id
? "bg-accent text-accent-fg shadow-sm"
? "bg-[color:var(--accent)] text-white shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
role="tab"
-63
View File
@@ -1,63 +0,0 @@
import { FileText, Package, Plug, User } from "lucide-react";
type MarketplaceIconProps = {
kind: "skill" | "plugin" | "soul" | "user";
label: string;
imageUrl?: string | null;
size?: "sm" | "md";
};
const TONES = [
{ accent: "oklch(0.63 0.16 42)", wash: "oklch(0.95 0.04 42)" },
{ accent: "oklch(0.61 0.15 168)", wash: "oklch(0.95 0.04 168)" },
{ accent: "oklch(0.59 0.14 236)", wash: "oklch(0.95 0.04 236)" },
{ accent: "oklch(0.66 0.13 92)", wash: "oklch(0.96 0.04 92)" },
] as const;
function hashTone(label: string) {
let sum = 0;
for (const char of label) sum += char.charCodeAt(0);
return TONES[sum % TONES.length] ?? TONES[0];
}
function getIcon(kind: MarketplaceIconProps["kind"]) {
switch (kind) {
case "plugin":
return Plug;
case "soul":
return FileText;
case "user":
return User;
default:
return Package;
}
}
export function MarketplaceIcon({
kind,
label,
imageUrl,
size = "sm",
}: MarketplaceIconProps) {
const Icon = getIcon(kind);
const tone = hashTone(label);
return (
<span
className={`marketplace-icon marketplace-icon-${size}`}
style={
{
"--marketplace-icon-accent": tone.accent,
"--marketplace-icon-wash": tone.wash,
} as React.CSSProperties
}
aria-hidden="true"
>
{imageUrl ? (
<img className="marketplace-icon-image" src={imageUrl} alt="" loading="lazy" />
) : (
<Icon className="marketplace-icon-glyph" strokeWidth={1.8} />
)}
</span>
);
}
+1 -9
View File
@@ -8,9 +8,6 @@ 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;
@@ -158,12 +155,7 @@ 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={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
<a href="/plugins/sdk-setup#package-metadata" className="underline">
Plugin Setup and Config
</a>
.
-40
View File
@@ -1,40 +0,0 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { familyLabel } from "../lib/packageLabels";
import type { PackageListItem } from "../lib/packageApi";
type PluginListItemProps = {
item: PackageListItem;
};
export function PluginListItem({ item }: PluginListItemProps) {
return (
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
<MarketplaceIcon kind="plugin" label={item.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{item.ownerHandle ? (
<>
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{item.displayName}</span>
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
</div>
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Plugin</span>
{item.latestVersion ? (
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
</span>
</div>
</div>
</Link>
);
}
+28 -11
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
import { Badge } from "./ui/badge";
type SkillCardProps = {
skill: PublicSkill;
@@ -12,6 +12,7 @@ type SkillCardProps = {
summaryFallback: string;
meta: ReactNode;
href?: string;
verified?: boolean;
};
export function SkillCard({
@@ -22,6 +23,7 @@ export function SkillCard({
summaryFallback,
meta,
href,
verified,
}: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId));
const link = href ?? `/${owner}/${skill.slug}`;
@@ -29,28 +31,43 @@ export function SkillCard({
const hasTags = badges.length || chip || platformLabels?.length;
return (
<Link to={link} className="card skill-card">
<Link
to={link}
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
>
{hasTags ? (
<div className="skill-card-tags">
<div className="flex flex-wrap items-center gap-1.5">
{badges.map((label) => (
<Badge key={label}>
<Badge key={label} variant="default">
{label}
</Badge>
))}
{chip ? <Badge variant="accent">{chip}</Badge> : null}
{chip ? (
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
{chip}
</Badge>
) : null}
{platformLabels?.map((label) => (
<Badge key={label} variant="compact">
{label}
</Badge>
))}
{verified && (
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
) : null}
<div className="skill-card-header">
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
<h3 className="skill-card-title">{skill.displayName}</h3>
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{skill.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{skill.summary ?? summaryFallback}
</p>
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
{meta}
</div>
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
</Link>
);
}
+106 -111
View File
@@ -2,16 +2,19 @@ import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { EmptyState } from "./EmptyState";
import { Container } from "./layout/Container";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import { SkillDetailTabs } from "./SkillDetailTabs";
import {
buildSkillHref,
formatConfigSnippet,
@@ -22,6 +25,8 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
type SkillDetailPageProps = {
slug: string;
@@ -33,13 +38,11 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
if (hasOwnProperty(error, "data")) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
data &&
typeof data === "object" &&
"message" in data &&
hasOwnProperty(data, "message") &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
@@ -95,7 +98,7 @@ export function SkillDetailPage({
);
const [tagName, setTagName] = useState("latest");
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
@@ -212,6 +215,7 @@ export function SkillDetailPage({
?.clawdis;
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
const nixPlugin = clawdis?.nix?.plugin;
const nixSystems = clawdis?.nix?.systems ?? [];
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
const configRequirements = clawdis?.config;
const configExample = configRequirements?.example
@@ -295,10 +299,13 @@ export function SkillDetailPage({
const deleteTag = (tag: string) => {
if (!skill) return;
if (!window.confirm(`Delete tag "${tag}"?`)) return;
void deleteTags({
skillId: skill._id,
tags: [tag],
toast(`Delete tag "${tag}"?`, {
action: {
label: "Delete",
onClick: () => {
void deleteTags({ skillId: skill._id, tags: [tag] });
},
},
});
};
@@ -317,9 +324,9 @@ export function SkillDetailPage({
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
closeReportDialog();
if (submission.reported) {
window.alert("Thanks — your report has been submitted.");
toast.success("Thanks — your report has been submitted.");
} else {
window.alert("You have already reported this skill.");
toast.info("You have already reported this skill.");
}
} catch (error) {
console.error("Failed to report skill", error);
@@ -329,19 +336,19 @@ export function SkillDetailPage({
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
</main>
);
return <SkillDetailSkeleton />;
}
if (result === null || !skill) {
return (
<main className="section">
<Card>Skill not found.</Card>
<main className="py-10">
<Container>
<EmptyState
title="Skill not found"
description="The skill you're looking for doesn't exist or may have been removed."
action={{ label: "Browse skills", href: "/skills" }}
/>
</Container>
</main>
);
}
@@ -349,91 +356,80 @@ export function SkillDetailPage({
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
return (
<main className="section">
<div className="skill-detail-stack">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
<main className="py-10">
<Container>
<div className="flex flex-col gap-6">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
</p>
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
Starter config for this plugin bundle.
</p>
<pre className="hero-install-code mt-3">{configExample}</pre>
</Card>
) : null}
@@ -456,12 +452,11 @@ export function SkillDetailPage({
<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>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
<div className="flex flex-col gap-3 pt-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-20 w-full" />
</div>
</Card>
}
>
@@ -472,17 +467,17 @@ export function SkillDetailPage({
/>
</ClientOnly>
</div>
</div>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</Container>
</main>
);
}
+38 -83
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
@@ -14,11 +15,9 @@ const SkillFilesPanel = lazy(() =>
type SkillFile = Doc<"skillVersions">["files"][number];
export type DetailTab = "readme" | "files" | "compare" | "versions";
type SkillDetailTabsProps = {
activeTab: DetailTab;
setActiveTab: (tab: DetailTab) => void;
activeTab: "files" | "compare" | "versions";
setActiveTab: (tab: "files" | "compare" | "versions") => void;
onCompareIntent: () => void;
readmeContent: string | null;
readmeError: string | null;
@@ -47,30 +46,13 @@ export function SkillDetailTabs({
suppressVersionScanResults,
scanResultsSuppressedMessage,
}: SkillDetailTabsProps) {
const compareEnabled = (versions?.length ?? 0) > 1;
return (
<div className="card tab-card">
<div className="tab-header">
<button
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("readme")}
>
README
</button>
<button
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("files")}
>
Files
</button>
{compareEnabled ? (
<button
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("compare")}
<Card>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger
value="compare"
onMouseEnter={() => {
onCompareIntent();
void import("./SkillDiffCard");
@@ -81,64 +63,37 @@ export function SkillDetailTabs({
}}
>
Compare
</button>
) : null}
<button
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("versions")}
>
Versions
</button>
</div>
</TabsTrigger>
<TabsTrigger value="versions">Versions</TabsTrigger>
</TabsList>
{activeTab === "readme" ? (
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
<p className="empty-state-title">No README available</p>
<p className="empty-state-body">
This skill doesn't have a SKILL.md file yet.
</p>
</div>
) : (
<div className="stat p-4">
Loading README...
</div>
)}
</div>
) : null}
<TabsContent value="files">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillFilesPanel
versionId={latestVersionId}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
/>
</Suspense>
</TabsContent>
{activeTab === "files" ? (
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
<SkillFilesPanel
versionId={latestVersionId}
latestFiles={latestFiles}
/>
</Suspense>
) : null}
{activeTab === "compare" ? (
<div className="tab-body">
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
<TabsContent value="compare">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
</Suspense>
</div>
) : null}
</TabsContent>
{activeTab === "versions" ? (
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
) : null}
</div>
<TabsContent value="versions">
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
</TabsContent>
</Tabs>
</Card>
);
}
+2 -2
View File
@@ -83,7 +83,7 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("is-active");
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("shadow-sm");
expect(screen.getByTestId("diff-editor").getAttribute("data-inline-fallback")).toBe("false");
});
@@ -106,6 +106,6 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("true");
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("shadow-sm");
});
});
+138 -109
View File
@@ -13,9 +13,12 @@ import {
selectDefaultFilePath,
sortVersionsBySemver,
} from "../lib/diffing";
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
import { Button } from "./ui/button";
import { ClientOnly } from "./ClientOnly";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Label } from "./ui/label";
import { Skeleton } from "./ui/skeleton";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -233,18 +236,15 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
const observer = new MutationObserver(() => {
applyMonacoTheme(monaco);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
attributeFilter: ["data-theme"],
});
const removeThemeListener = onThemeChange(syncTheme);
syncTheme();
return () => {
observer.disconnect();
removeThemeListener();
};
applyMonacoTheme(monaco);
return () => observer.disconnect();
}, [monaco]);
useEffect(() => {
@@ -277,145 +277,174 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
const fileSelected = Boolean(selectedItem);
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
const Wrapper = variant === "card" ? Card : "div";
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
return (
<div className={containerClass}>
<div className="diff-header">
<Wrapper className={wrapperClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="section-title text-[1.2rem] m-0">
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
Compare versions
</h2>
<p className="section-subtitle m-0">
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
Inline or side-by-side diff for any file.
</p>
</div>
{!diffUnavailable ? (
<fieldset className="diff-toggle-group">
<legend className="sr-only">Diff layout</legend>
<button
className={`diff-toggle${viewMode === "split" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`diff-toggle${viewMode === "inline" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
) : null}
<fieldset className="inline-flex items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-[3px]">
<legend className="sr-only">Diff layout</legend>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "split"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "inline"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
</div>
{!diffUnavailable ? (
<>
<div className="diff-controls">
<div className="diff-select">
<label htmlFor="diff-left">Left</label>
<select
id="diff-left"
className="search-input"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
className="diff-swap"
type="button"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="diff-select">
<label htmlFor="diff-right">Right</label>
<select
id="diff-right"
className="search-input"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-left">Left</Label>
<select
id="diff-left"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-right">Right</Label>
<select
id="diff-right"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="diff-meta">
<span>
Left {leftLabel} Right {rightLabel}
</span>
</div>
</>
) : null}
<div className="flex flex-wrap items-center gap-3 text-sm text-[color:var(--ink-soft)]">
<span>
Left {leftLabel} Right {rightLabel}
</span>
{diffUnavailable ? <span>Need at least 2 versions.</span> : null}
</div>
<div className="diff-layout">
<div className="diff-files">
{diffUnavailable ? (
<div className="diff-empty">
Publish another version to compare changes side by side.
</div>
) : fileDiffItems.length === 0 ? (
<div className="diff-empty">No files to compare.</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(160px,220px)_1fr]">
<div className="flex max-h-[500px] flex-col overflow-y-auto border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] md:border-r md:border-b-0">
{fileDiffItems.length === 0 ? (
<div className="p-3 text-sm text-[color:var(--ink-soft)]">No files to compare.</div>
) : (
fileDiffItems.map((item) => (
<button
key={item.path}
type="button"
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
className={`flex w-full cursor-pointer items-center gap-2 border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface)] ${
item.path === selectedPath
? "bg-[color:var(--surface)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
onClick={() => setSelectedPath(item.path)}
>
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
<span className="diff-file-name">{item.path}</span>
<Badge
variant={
item.status === "added"
? "success"
: item.status === "removed"
? "destructive"
: item.status === "changed"
? "warning"
: "compact"
}
className="shrink-0 text-[0.65rem]"
>
{item.status}
</Badge>
<span className="truncate font-mono text-xs">{item.path}</span>
</button>
))
)}
</div>
<div className="diff-view">
<div className="relative min-h-[300px]">
{error ? (
<div className="diff-empty">{error}</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{error}
</div>
) : sizeWarning ? (
<div className="diff-empty">
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
{sizeWarning.path}
</div>
) : diffUnavailable ? (
<div className="diff-empty">Publish another version to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Publish another version to compare.
</div>
) : !selectionReady ? (
<div className="diff-empty">Select two versions to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select two versions to compare.
</div>
) : !fileSelected ? (
<div className="diff-empty">Select a file to compare.</div>
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select a file to compare.
</div>
) : (
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
<DiffEditor
key={`diff-${viewMode}`}
className={`diff-monaco diff-monaco-${viewMode}`}
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
original={leftText}
modified={rightText}
theme={getMonacoThemeName()}
loading={<div className="diff-empty">Loading diff</div>}
loading={<Skeleton className="h-full w-full" />}
options={diffOptions}
/>
{isLoading ? <div className="diff-loading">Loading</div> : null}
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
<Skeleton className="h-8 w-24" />
</div>
) : null}
</ClientOnly>
)}
</div>
</div>
</div>
</Wrapper>
);
}
@@ -443,7 +472,7 @@ function renderOptions(options: VersionOption[]) {
function getMonacoThemeName() {
if (typeof document === "undefined") return "clawhub-light";
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
}
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
@@ -479,7 +508,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
const background = surface;
const gutter = surfaceMuted;
const isDark = isDarkThemeResolved();
const isDark = document.documentElement.dataset.theme === "dark";
const base = isDark ? "vs-dark" : "vs";
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
+8
View File
@@ -9,6 +9,10 @@ vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
}));
vi.mock("./MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string }) => <div>{children}</div>,
}));
type SkillFile = Doc<"skillVersions">["files"][number];
function makeFile(path: string, size: number): SkillFile {
@@ -30,6 +34,8 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("scripts/run.sh", 10)]}
/>,
);
@@ -62,6 +68,8 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
/>,
);
+53 -21
View File
@@ -2,17 +2,23 @@ import { useAction } from "convex/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { MarkdownPreview } from "./MarkdownPreview";
import { formatBytes } from "./skillDetailUtils";
import { Skeleton } from "./ui/skeleton";
type SkillFile = Doc<"skillVersions">["files"][number];
type SkillFilesPanelProps = {
versionId: Id<"skillVersions"> | null;
readmeContent: string | null;
readmeError: string | null;
latestFiles: SkillFile[];
};
export function SkillFilesPanel({
versionId,
readmeContent,
readmeError,
latestFiles,
}: SkillFilesPanelProps) {
const getFileText = useAction(api.skills.getFileText);
@@ -85,56 +91,82 @@ export function SkillFilesPanel({
);
return (
<div className="tab-body">
<div className="file-browser">
<div className="file-list">
<div className="file-list-header">
<h3 className="section-title text-[1.05rem] m-0">
<div className="grid max-w-full gap-5 overflow-x-auto">
<div>
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
SKILL.md
</h2>
<div>
{readmeContent ? (
<MarkdownPreview>{readmeContent}</MarkdownPreview>
) : readmeError ? (
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load SKILL.md: {readmeError}
</div>
) : (
<Skeleton className="h-24 w-full" />
)}
</div>
</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(180px,280px)_1fr]">
<div className="flex flex-col border-b border-[color:var(--line)] md:border-r md:border-b-0">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<h3 className="m-0 font-display text-[1.05rem] font-bold text-[color:var(--ink)]">
Files
</h3>
<span className="section-subtitle m-0">
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
{latestFiles.length} total
</span>
</div>
<div className="file-list-body">
<div className="flex max-h-[400px] flex-col overflow-y-auto">
{latestFiles.length === 0 ? (
<div className="stat">No files available.</div>
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
No files available.
</div>
) : (
latestFiles.map((file) => (
<button
key={file.path}
className={`file-row file-row-button${
selectedPath === file.path ? " is-active" : ""
className={`flex w-full cursor-pointer items-center justify-between border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-muted)] ${
selectedPath === file.path
? "bg-[color:var(--surface-muted)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
type="button"
onClick={() => handleSelect(file.path)}
aria-current={selectedPath === file.path ? "true" : undefined}
>
<span className="file-path">{file.path}</span>
<span className="file-meta">{formatBytes(file.size)}</span>
<span className="truncate font-mono text-xs">{file.path}</span>
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(file.size)}
</span>
</button>
))
)}
</div>
</div>
<div className="file-viewer">
<div className="file-viewer-header">
<div className="file-path">{selectedPath ?? "Select a file"}</div>
<div className="flex flex-col">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<div className="truncate font-mono text-xs">{selectedPath ?? "Select a file"}</div>
{fileMeta ? (
<span className="file-meta">
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}
</span>
) : null}
</div>
<div className="file-viewer-body">
<div className="min-h-[200px] p-3">
{isLoading ? (
<div className="stat">Loading</div>
<Skeleton className="h-24 w-full" />
) : fileError ? (
<div className="stat">Failed to load file: {fileError}</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load file: {fileError}
</div>
) : fileContent ? (
<pre className="file-viewer-code">{fileContent}</pre>
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{fileContent}
</pre>
) : (
<div className="stat">Select a file to preview.</div>
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
)}
</div>
</div>
+209 -156
View File
@@ -1,14 +1,21 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Package } from "lucide-react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { SkillInstallCard } from "./SkillInstallCard";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Input } from "./ui/input";
import { UserBadge } from "./UserBadge";
export type SkillModerationInfo = {
@@ -110,9 +117,10 @@ export function SkillHeader({
onTagSubmit,
onTagDelete,
tagVersions,
clawdis: _clawdis,
osLabels: _osLabels,
clawdis,
osLabels,
}: SkillHeaderProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
@@ -126,8 +134,8 @@ export function SkillHeader({
return (
<>
{modInfo?.isPendingScan ? (
<div className="pending-banner">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
@@ -137,8 +145,8 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
@@ -147,15 +155,15 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<strong>Skill flagged suspicious patterns detected</strong>
<p>
ClawHub Security flagged this skill as suspicious. Review the scan results before
using.
</p>
{canManage ? (
<p className="pending-banner-appeal">
<p className="text-sm text-[color:var(--ink-soft)]">
If you believe this skill has been incorrectly flagged, please{" "}
<a
href="https://github.com/openclaw/clawhub/issues"
@@ -170,109 +178,142 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isRemoved ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill removed by moderator</strong>
<p>This skill has been removed and is not visible to others.</p>
</div>
</div>
) : modInfo?.isHiddenByMod ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
<Card>
<div className={`flex flex-col gap-5${hasPluginBundle ? " pb-2" : ""}`}>
<div className="flex flex-col gap-5 md:flex-row md:gap-8">
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<Badge variant="compact">v{latestVersion.version}</Badge>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</p>
{isStaff && staffModerationNote ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{staffModerationNote}
</div>
) : null}
{nixPlugin ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="flex flex-col gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.stars}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="flex items-center gap-1 text-sm text-[color:var(--ink-soft)]">
<Package size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.installsAllTime} all-time
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
<Badge variant="compact">{PLATFORM_SKILL_LICENSE}</Badge>
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-sidebar">
<div className="skill-actions">
<div className="flex w-full flex-col gap-3 md:w-[220px] md:shrink-0">
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
<a
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
className="inline-flex w-full items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
>
Download zip
</a>
) : null}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
License
</span>
<span className="text-sm text-[color:var(--ink)]">
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{isAuthenticated ? (
<button
className={`star-toggle${isStarred ? " is-active" : ""}`}
className={`flex h-9 w-9 items-center justify-center rounded-full border transition-all duration-200 ${isStarred ? "border-amber-400/60 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-950/40" : "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:text-amber-500"}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
@@ -281,16 +322,18 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
<Button variant="ghost" size="sm" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
<Link
to="/management"
search={{ skill: skill.slug }}
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-xs min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] transition-all duration-200 no-underline"
>
Manage
</Link>
) : null}
</div>
</div>
@@ -298,12 +341,13 @@ export function SkillHeader({
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="skill-hero-note">{overrideScanMessage}</div>
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{overrideScanMessage}
</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-2">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
@@ -311,67 +355,77 @@ export function SkillHeader({
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
/>
<p className="scan-disclaimer">
<p className="text-xs text-[color:var(--ink-soft)]">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
<div className="bundle-title">Plugin bundle (nix)</div>
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
</div>
<div className="bundle-includes">
<span>SKILL.md</span>
<span>CLI</span>
<span>Config</span>
</div>
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
<div className="bundle-meta">
{configRequirements.requiredEnv?.length ? (
<div className="stat">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="stat">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
<Card className="border-dashed">
<CardContent>
<div className="flex flex-col gap-1">
<div className="font-display text-base font-bold text-[color:var(--ink)]">
Plugin bundle (nix)
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Skill pack · CLI binary · Config
</div>
</div>
) : null}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge>SKILL.md</Badge>
<Badge>CLI</Badge>
<Badge>Config</Badge>
</div>
{configRequirements ? (
<div className="flex flex-col gap-2">
<div className="text-sm font-semibold text-[color:var(--ink)]">
Config requirements
</div>
<div className="flex flex-col gap-1">
{configRequirements.requiredEnv?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
) : null}
{cliHelp ? (
<details className="flex flex-col gap-2">
<summary className="cursor-pointer text-sm font-semibold text-[color:var(--ink)]">
CLI help (from plugin)
</summary>
<pre className="mt-2 overflow-x-auto rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] p-3 font-mono text-xs">
{cliHelp}
</pre>
</details>
) : null}
</CardContent>
</Card>
) : null}
</div>
<div className="skill-tag-row">
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
{tagEntries.length === 0 ? (
<span className="section-subtitle m-0">
No tags yet.
</span>
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
<Badge key={tag} className="gap-1.5">
{tag}
<span className="tag-meta">
<span className="text-[0.68rem] opacity-70">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
@@ -390,16 +444,16 @@ export function SkillHeader({
event.preventDefault();
onTagSubmit();
}}
className="tag-form"
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
>
<input
className="search-input"
<Input
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest"
className="w-auto max-w-[160px]"
/>
<select
className="search-input"
className="min-h-[44px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
@@ -409,13 +463,12 @@ export function SkillHeader({
</option>
))}
</select>
<Button type="submit">
Update tag
</Button>
<Button type="submit">Update tag</Button>
</form>
) : null}
</div>
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</Card>
</>
);
}
+217 -162
View File
@@ -1,6 +1,12 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Badge } from "./ui/badge";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
PLATFORM_SKILL_LICENSE_URL,
} from "clawhub-schema/licenseConstants";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
import { Badge } from "./ui/badge";
import { Card, CardContent } from "./ui/card";
type SkillInstallCardProps = {
clawdis: ClawdisSkillMetadata | undefined;
@@ -26,180 +32,229 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
const hasInstallSpecs = installSpecs.length > 0;
const hasDependencies = dependencies.length > 0;
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation);
const hasLicense = true;
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
return null;
}
return (
<div className="skill-hero-content">
<div className="skill-hero-panels">
<div className="border-t border-[color:var(--line)] pt-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
License
</h3>
<div className="flex flex-col gap-2">
<Badge variant="accent">{PLATFORM_SKILL_LICENSE}</Badge>
<div className="text-sm text-[color:var(--ink-soft)]">
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Terms</strong>
<a
href={PLATFORM_SKILL_LICENSE_URL}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{PLATFORM_SKILL_LICENSE_URL}
</a>
</div>
</div>
</CardContent>
</Card>
{hasRuntimeRequirements ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Runtime requirements
</h3>
<div className="skill-panel-body">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="stat">
<strong>OS</strong>
<span>{osLabels.join(" · ")}</span>
</div>
) : null}
{requirements?.bins?.length ? (
<div className="stat">
<strong>Bins</strong>
<span>{requirements.bins.join(", ")}</span>
</div>
) : null}
{requirements?.anyBins?.length ? (
<div className="stat">
<strong>Any bin</strong>
<span>{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="stat">
<strong>Env</strong>
<span>{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="stat">
<strong>Config</strong>
<span>{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="stat">
<strong>Primary env</strong>
<span>{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="stat">
<strong>Environment variables</strong>
<div className="flex flex-col gap-1 mt-1">
{envVars.map((env, index) => (
<div
key={`${env.name}-${index}`}
className="flex items-baseline gap-2"
>
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-ink-soft text-[0.75rem]">
optional
</span>
) : env.required === true ? (
<span className="text-ink-accent text-[0.75rem]">
required
</span>
) : null}
{env.description ? (
<span className="text-ink-soft text-[0.8rem]">
{env.description}
</span>
) : null}
</div>
))}
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Runtime requirements
</h3>
<div className="flex flex-col gap-2">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>OS</strong>
<span className="ml-1">{osLabels.join(" · ")}</span>
</div>
</div>
) : null}
</div>
</div>
) : null}
{hasDependencies ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Dependencies
</h3>
<div className="skill-panel-body">
{dependencies.map((dep, index) => (
<div key={`${dep.name}-${index}`} className="stat">
<div>
<strong>{dep.name}</strong>
<span className="text-ink-soft text-[0.85rem] ml-2">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="text-[0.8rem] break-all">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
) : null}
{requirements?.bins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Bins</strong>
<span className="ml-1">{requirements.bins.join(", ")}</span>
</div>
</div>
))}
</div>
</div>
) : null}
{hasInstallSpecs ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Install
</h3>
<div className="skill-panel-body">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-ink-soft text-[0.85rem]">
Bins: {spec.bins.join(", ")}
) : null}
{requirements?.anyBins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Any bin</strong>
<span className="ml-1">{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Env</strong>
<span className="ml-1">{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Config</strong>
<span className="ml-1">{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Primary env</strong>
<span className="ml-1">{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Environment variables</strong>
<div className="mt-1 flex flex-col gap-1">
{envVars.map((env, index) => (
<div key={`${env.name}-${index}`} className="flex items-baseline gap-2">
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-xs text-[color:var(--ink-soft)]">optional</span>
) : env.required === true ? (
<span className="text-xs text-[color:var(--accent)]">required</span>
) : null}
{env.description ? (
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
{env.description}
</span>
) : null}
</div>
) : null}
{command ? <code>{command}</code> : null}
))}
</div>
</div>
);
})}
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
{hasDependencies ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Dependencies
</h3>
<div className="flex flex-col gap-2">
{dependencies.map((dep, index) => (
<div
key={`${dep.name}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{dep.name}</strong>
<span className="ml-2 text-[0.85rem] text-[color:var(--ink-soft)]">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="break-all text-[0.8rem]">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
{hasInstallSpecs ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Install
</h3>
<div className="flex flex-col gap-2">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div
key={`${spec.id ?? spec.kind}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-[0.85rem] text-[color:var(--ink-soft)]">
Bins: {spec.bins.join(", ")}
</div>
) : null}
{command ? (
<code className="mt-0.5 block font-mono text-xs">{command}</code>
) : null}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
) : null}
{hasLinks ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Links
</h3>
<div className="skill-panel-body">
{links?.homepage ? (
<div className="stat">
<strong>Homepage</strong>
<a href={links.homepage} target="_blank" rel="noopener noreferrer" className="break-all">
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="stat">
<strong>Repository</strong>
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="break-all">
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="stat">
<strong>Docs</strong>
<a href={links.documentation} target="_blank" rel="noopener noreferrer">
{links.documentation}
</a>
</div>
) : null}
</div>
</div>
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Links
</h3>
<div className="flex flex-col gap-2">
{links?.homepage ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Homepage</strong>
<a
href={links.homepage}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Repository</strong>
<a
href={links.repository}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Docs</strong>
<a
href={links.documentation}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{links.documentation}
</a>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
</div>
</div>
-53
View File
@@ -1,53 +0,0 @@
import { Link } from "@tanstack/react-router";
import { Package, Star } from "lucide-react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
type SkillListItemProps = {
skill: PublicSkill;
ownerHandle?: string | null;
owner?: PublicPublisher | null;
};
export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) {
const handle = ownerHandle ?? owner?.handle ?? null;
const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
const href = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`;
const badges = getSkillBadges(skill);
return (
<Link to={href} className="skill-list-item">
<MarketplaceIcon kind="skill" label={skill.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{handle ? (
<>
<span className="skill-list-item-owner">@{handle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{skill.displayName}</span>
{badges.map((b) => (
<Badge key={b} variant="compact">
{b}
</Badge>
))}
</div>
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Updated {timeAgo(skill.updatedAt)}</span>
<span className="skill-list-item-meta-item">
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="skill-list-item-meta-item">
<Package size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
</div>
</div>
</Link>
);
}
-121
View File
@@ -1,121 +0,0 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
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";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
skill: PublicSkill;
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
owner: PublicPublisher | null;
ownerHandle: string | null;
clawdis?: ClawdisSkillMetadata;
osLabels: string[];
tagEntries: Array<[string, Id<"skillVersions">]>;
isMalwareBlocked?: boolean;
isRemoved?: boolean;
nixPlugin?: string;
};
export function SkillMetadataSidebar({
skill,
latestVersion,
owner,
ownerHandle,
clawdis: _clawdis,
osLabels,
tagEntries,
isMalwareBlocked,
isRemoved,
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<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>
<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>
{/* 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>
) : 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>
) : null}
</div>
{/* 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>
) : null}
{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>
</div>
);
}
+2 -3
View File
@@ -1,5 +1,4 @@
import { useState } from "react";
import { Badge } from "./ui/badge";
type LlmAnalysisDimension = {
name: string;
@@ -389,9 +388,9 @@ export function SecurityScanResults({
<div className="scan-findings-title">Capability signals</div>
<div className="scan-capability-tags">
{visibleCapabilityTags.map((tag) => (
<Badge key={tag} className="scan-capability-tag">
<span key={tag} className="tag scan-capability-tag">
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
</Badge>
</span>
))}
</div>
<div className="scan-capability-note">
+12 -8
View File
@@ -1,6 +1,5 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicSoul } from "../lib/publicUser";
type SoulCardProps = {
@@ -11,13 +10,18 @@ type SoulCardProps = {
export function SoulCard({ soul, summaryFallback, meta }: SoulCardProps) {
return (
<Link to="/souls/$slug" params={{ slug: soul.slug }} className="card skill-card">
<div className="skill-card-header">
<MarketplaceIcon kind="soul" label={soul.displayName} size="md" />
<h3 className="skill-card-title">{soul.displayName}</h3>
</div>
<p className="skill-card-summary">{soul.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
<Link
to="/souls/$slug"
params={{ slug: soul.slug }}
className="group flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)]"
>
<h3 className="font-display text-base font-bold text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{soul.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{soul.summary ?? summaryFallback}
</p>
<div className="mt-auto flex items-center gap-3 pt-2">{meta}</div>
</Link>
);
}
+1 -88
View File
@@ -1,12 +1,5 @@
import { Package, Star, Download } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { convexHttp } from "../convex/client";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type UserBadgeProps = {
user: PublicUser | PublicPublisher | null | undefined;
@@ -47,14 +40,7 @@ export function UserBadge({
displayName!.toLowerCase() !== handle!.toLowerCase();
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
// Resolve userId for stats query — PublicUser has _id directly,
// PublicPublisher has linkedUserId
const userId =
user && hasOwnProperty(user, "kind")
? (user as PublicPublisher).linkedUserId ?? null
: user?._id ?? null;
const badge = (
return (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
@@ -81,77 +67,4 @@ export function UserBadge({
)}
</span>
);
if (!userId) return badge;
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<UserStatsTooltipContent userId={userId} displayName={displayName} handle={handle} />
</Tooltip>
);
}
type HoverStats = { publishedSkills: number; totalStars: number; totalDownloads: number };
function UserStatsTooltipContent({
userId,
displayName,
handle,
}: {
userId: string;
displayName: string | null;
handle: string | null;
}) {
const [stats, setStats] = useState<HoverStats | null>(null);
const [fetched, setFetched] = useState(false);
// One-shot fetch on mount (tooltip content only mounts when open)
useEffect(() => {
if (fetched) return;
setFetched(true);
void convexHttp
.query(api.users.getHoverStats, { userId: userId as Id<"users"> })
.then(setStats)
.catch(() => {});
}, [userId, fetched]);
return (
<TooltipContent
side="top"
className="min-w-[140px] p-0"
onPointerDownOutside={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-space-1 px-3 py-2">
{displayName && (
<span className="text-fs-sm font-semibold text-ink truncate max-w-[180px]">
{displayName}
</span>
)}
{handle && (
<span className="text-fs-xs text-ink-soft">@{handle}</span>
)}
</div>
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
{stats === null ? (
<span className="text-fs-xs text-ink-soft">Loading...</span>
) : (
<>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
<Package size={12} />
{formatCompactStat(stats.publishedSkills)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
<Star size={12} />
{formatCompactStat(stats.totalStars)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
<Download size={12} />
{formatCompactStat(stats.totalDownloads)}
</span>
</>
)}
</div>
</TooltipContent>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicUser } from "../lib/publicUser";
type UserListItemProps = {
user: PublicUser;
};
export function UserListItem({ user }: UserListItemProps) {
const handle = user.handle?.trim();
if (!handle) return null;
const displayName = user.displayName ?? user.name ?? handle;
return (
<Link to="/u/$handle" params={{ handle }} className="skill-list-item user-list-item" aria-label={`User: ${displayName}`}>
<MarketplaceIcon kind="user" label={displayName} imageUrl={user.image} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<span className="skill-list-item-name">{displayName}</span>
<span className="skill-list-item-owner">@{handle}</span>
</div>
<p className="skill-list-item-summary">{user.bio?.trim() || "Builder on ClawHub."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">User</span>
<span className="skill-list-item-meta-item">Profile</span>
</div>
</div>
</Link>
);
}
+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",
size === "default" && "max-w-[1200px]",
size === "narrow" && "max-w-[900px]",
size === "wide" && "max-w-[1400px]",
className,
)}
{...props}
/>
),
);
Container.displayName = "Container";
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
<div className="mx-auto max-w-[1200px] px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
<div className="mx-auto max-w-[1200px] px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
+31 -10
View File
@@ -10,16 +10,37 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
<span
ref={ref}
className={cn(
// Base styles
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-fs-sm font-semibold",
// Variant styles — all token-driven, no dark: overrides needed
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
// Base styles matching .tag
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-[0.8rem] font-semibold",
// Variant styles
variant === "default" && [
"bg-[rgba(43,198,164,0.16)] px-3 py-1 text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "accent" && [
"bg-[rgba(255,107,74,0.16)] px-3 py-1 text-[color:var(--accent-deep)]",
"dark:bg-[rgba(232,106,71,0.24)] dark:text-[#ffd0bf]",
],
variant === "compact" && [
"bg-[rgba(43,198,164,0.16)] px-2.5 py-0.5 text-[0.72rem] text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "pending" && [
"bg-[rgba(240,196,106,0.2)] px-3 py-1 text-[#8a6914]",
"dark:bg-[rgba(243,201,122,0.18)] dark:text-[color:var(--gold)]",
],
variant === "success" && [
"bg-emerald-100 px-3 py-1 text-emerald-700",
"dark:bg-emerald-900/30 dark:text-emerald-300",
],
variant === "warning" && [
"bg-amber-100 px-3 py-1 text-amber-700",
"dark:bg-amber-900/30 dark:text-amber-300",
],
variant === "destructive" && [
"bg-red-100 px-3 py-1 text-red-700",
"dark:bg-red-900/30 dark:text-red-300",
],
className,
)}
{...props}
+4 -4
View File
@@ -33,15 +33,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
// Hover lift
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
// Hover lift (matches .btn:hover)
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]",
// Variant styles
variant === "default" &&
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
variant === "primary" &&
"border border-accent bg-accent/10 text-[color:var(--ink)]",
"border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white dark:from-[#c35640] dark:to-[#953827] dark:shadow-[0_10px_22px_rgba(58,23,16,0.42),inset_0_1px_0_rgba(255,201,184,0.18)]",
variant === "destructive" &&
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
"border border-red-300/40 bg-red-50 text-red-700 hover:not-disabled:bg-red-100 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300",
variant === "ghost" &&
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
variant === "outline" &&
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
ref={ref}
className={cn(
// Matches .card
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] transition-all duration-200 ease-out",
className,
)}
{...props}
+2 -2
View File
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog-backdrop
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg p-5 backdrop-blur-[3px]",
"fixed inset-0 z-80 grid place-items-center bg-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-[0_24px_50px_rgba(18,22,34,0.24)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
+2 -2
View File
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
className,
)}
{...props}
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+10 -6
View File
@@ -7,12 +7,16 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
ref={ref}
type={type}
className={cn(
// Base styles
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Base styles matching .form-input
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Focus styles matching .form-input:focus
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode matching [data-theme="dark"] .form-input
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+3 -2
View File
@@ -10,8 +10,9 @@ const Label = React.forwardRef<
ref={ref}
className={cn(
// Matches .form-label
"text-fs-xs font-bold uppercase tracking-[0.14em]",
"text-label-fg",
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
"text-[rgba(70,95,113,0.9)]",
"dark:text-[rgba(206,227,238,0.76)]",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
+8 -7
View File
@@ -14,11 +14,12 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
// Matches form input token styling
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Matches .form-input styling for consistency
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
@@ -68,7 +69,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -114,7 +115,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,

Some files were not shown because too many files have changed in this diff Show More