Compare commits

...
Author SHA1 Message Date
Val Alexander ff68b2e036 fix: satisfy skill install typecheck 2026-04-23 15:35:08 -05:00
Val Alexander 23a109c037 feat: add skill install prompt surface
Add a dedicated skill install surface that pairs OpenClaw prompt-driven install with visible CLI commands.

- add Install with OpenClaw and CLI Commands panels to the skill detail page
- add Copy Prompt modes for Install Only and Install & Setup plus package-manager switching for the ClawHub CLI command
- add regression coverage for the new surface and make the repo build path use the working Vite invocation
2026-04-23 15:29:43 -05:00
Patrick Erichsen f28c1745f9 Merge pull request #1794 from openclaw/fix/vercel-image-allow-svg
fix(security): allow SVGs through image optimizer so badges render
2026-04-22 22:52:27 -07:00
Patrick ErichsenandClaude Opus 4.7 4fe275eb50 fix(security): enable safe SVG handling so shields.io badges render
vercel.json currently allow-lists SVG-only hosts (img.shields.io,
shields.io, badgen.net, flat.badgen.net) while dangerouslyAllowSVG:
false rejects every SVG source. Those two settings are incompatible,
and every badge in every README on production is returning 400
INVALID_IMAGE_OPTIMIZE_REQUEST (e.g. the license badge on
/plugins/@opik/opik-openclaw).

Switch to the pattern Vercel documents for safely serving SVGs in
their NEXTJS_SAFE_SVG_IMAGES conformance rule:

- dangerouslyAllowSVG: true  — lets the optimizer accept SVG inputs
- contentDispositionType: attachment  — forces download instead of
  inline document rendering if someone navigates directly to the
  /_vercel/image URL (the only context where SVG scripts would run)
- contentSecurityPolicy: script-src 'none'; sandbox;  — blocks script
  execution in the response

Defense in depth: browsers already sandbox SVGs loaded through <img>
so scripts don't run there anyway; the CSP + attachment header cover
the edge case of someone opening the optimizer URL directly. Net
security is equivalent to rejecting SVGs, but badges actually render.

Docs: https://vercel.com/docs/conformance/rules/NEXTJS_SAFE_SVG_IMAGES

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:50:43 -07:00
Patrick Erichsen c0d2ac7ac0 Merge pull request #1793 from openclaw/fix/image-proxy-xss
fix(security): proxy README images via Vercel Image Optimization
2026-04-22 22:42:32 -07:00
Patrick ErichsenandClaude Opus 4.7 d6d4028660 refactor(security): swap ProxiedImg component for rehype plugin
Replaces the React <img> wrapper with a tiny rehype plugin that rewrites
image srcs in the HAST. Same behavior (external http(s) URLs routed
through /_vercel/image; local/relative/data: URIs pass through), less
surface area:

- One shared plugin wired into both MarkdownPreview and SkillDetailTabs
  via rehypePlugins instead of a components override at each call site
- Dropped ProxiedImg.tsx + its 7 unit tests; the two integration tests
  in MarkdownPreview.test.tsx still assert the proxy URL shape for both
  <img> and ![](url) syntax
- Stopped reading <img width="..."> for the proxy's w= param. Vercel
  requires w to match a value in vercel.json sizes, so arbitrary README
  widths (e.g. width="200") would have been rejected. Always w=1024 now;
  the HTML width attribute still drives layout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:32:21 -07:00
Patrick ErichsenandClaude Opus 4.7 82ae30d940 fix(security): proxy README images via Vercel Image Optimization
Closes the XSS / IP-leak surface from rendering third-party README
images directly on clawhub.ai. Routes external http(s) <img> sources
through Vercel's /_vercel/image endpoint, which enforces a host
allow-list, rejects SVG by default, and re-encodes rasters to webp.

Docs: https://vercel.com/docs/image-optimization

- vercel.json: add `images` config — host allow-list (raw.githubusercontent,
  shields.io, etc., based on NuGet's published README allow-list),
  dangerouslyAllowSVG=false, formats=[webp], 1d minimum cache TTL.
- src/components/ProxiedImg.tsx: small wrapper that rewrites external
  http(s) src URLs to /_vercel/image?url=...&w=...&q=75. Local paths,
  relative paths, and data: URIs pass through unchanged.
- MarkdownPreview + SkillDetailTabs: pass ProxiedImg as the `img`
  component override to react-markdown — covers both raw HTML <img>
  and markdown ![](url) syntax.
- package.json: drop unused `next` dep (vestigial from staging merge,
  zero imports anywhere; doesn't affect next-themes).

Tests: 1028/1028 (was 1017, added 11 — ProxiedImg unit tests +
markdown integration tests covering proxied vs passthrough paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:12:40 -07:00
Patrick Erichsen b53813a5a7 Merge pull request #1792 from openclaw/fix/lint-cleanup-staging-fallout
chore(lint): clean up 70 oxlint errors from staging merge #1573
2026-04-22 21:39:34 -07:00
Patrick ErichsenandClaude Opus 4.7 87469792d5 fix(typecheck): clear remaining tsc errors on main
8 typecheck errors that have been on main alongside the lint debt:

- convex/apiSurface.typecheck.ts: drop two stale @ts-expect-error
  directives. The `increment` references they guarded no longer
  exist (functions renamed to *Internal); runtime internal-only
  enforcement is preserved by `internalMutation`.
- src/components/MarkdownPreview.tsx: cast createHighlighter result
  to AnyHighlighter, narrow loadHighlighter return via the local
  promise variable, type baseRehype + memoized rehypePlugins as
  PluggableList (drops `as const` readonly mismatch with
  ReactMarkdown's prop type).
- src/lib/theme.test.tsx: rename remaining "hub" usages to "claw"
  (theme families collapsed to one in PR #1573 — the last "hub"
  references in the harness button + applyTheme call would never
  compile under the current ThemeName type).
- src/lib/packageApi.test.ts: add `?.` on the nullable result.

Full suite: lint 0, tests 1017/1017, typecheck 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:33:17 -07:00
Patrick ErichsenandClaude Opus 4.7 9e7407cd84 test: update stale assertions left over from staging merge
Two pre-existing test failures on main, both caused by UI/data
changes in PR #1573 that the tests weren't updated for:

- theme.test.tsx: expected stored theme "hub" to round-trip, but
  the staging merge collapsed all families into a single "claw"
  theme — unknown families now fall back to "claw". Test now
  asserts the legacy fallback behavior it claims to test.
- skill-detail-page.test.tsx: gated on the platform license
  summary text, which was removed from SkillMetadataSidebar in
  4d1a08b. Drop the obsolete assertion; the report-button
  findByRole on the next line provides the same render-wait.

Full suite: 1017/1017 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:23:29 -07:00
Patrick ErichsenandClaude Opus 4.7 70fd9436cf chore(lint): clean up oxlint errors from staging-merge fallout (#1573)
Fixes 70 oxlint errors that landed in the 2026-04-18 staging merge and
have kept main red ever since. Three rule categories:

- typescript-eslint(no-unnecessary-type-conversion): drop redundant
  String/Number/Boolean wraps + 'as T' casts on values already typed.
- typescript-eslint(consistent-return): unify mixed return paths,
  mostly in useEffect callbacks (early-return vs cleanup-fn) and CLI
  command handlers.
- typescript-eslint(no-unnecessary-type-parameters): drop generics
  used only once in a signature; replace with concrete types.
- Plus a handful of no-unused-vars, no-shadow, and one
  no-redundant-type-constituents (JSX.Element -> ReactNode).

No runtime behavior changes. Full lint clean (0 errors); test suite
shows the same 2 pre-existing failures as main, no new regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:16:23 -07:00
Vincent Koc 5e7584e032 Merge pull request #1791 from openclaw/fix/markdown-html-passthrough 2026-04-22 20:59:27 -07:00
Patrick ErichsenandClaude Opus 4.7 ea0824878d fix(markdown): render raw HTML + GFM in MarkdownPreview, add shiki highlighting
Plugin/soul READMEs that use raw HTML (e.g. centered logos via
<h1 align="center">, <picture>, <br/>) were rendering as escaped
text because @create-markdown/preview escapes all HTML. Swap the
renderer for react-markdown + remark-gfm + rehype-raw +
rehype-sanitize (GitHub's stack), with rehype-shiki-from-highlighter
for fenced code block syntax highlighting.

Sanitize runs before shiki so user HTML is scrubbed, and shiki's
trusted styled output flows through untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:33:54 -07:00
Val Alexander da74b2a382 Update .gitignore 2026-04-22 14:22:53 -05:00
66 changed files with 1901 additions and 231 deletions
+5 -2
View File
@@ -10,7 +10,9 @@ dist-ssr
*.local
.vercel
count.txt
.env
.env*
!.env.local.example
!.env.example
.nitro
.tanstack
.wrangler
@@ -27,4 +29,5 @@ test-results
convex/_generated/
skills-lock.json
*/skills/*
skills/*
skills/*
.codex/*
+31 -1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -25,6 +26,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-router": "1.168.1",
@@ -43,12 +45,13 @@
"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",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
@@ -56,6 +59,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -591,6 +595,8 @@
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
"@shikijs/rehype": ["@shikijs/rehype@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "4.0.2", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA=="],
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
@@ -961,12 +967,26 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
@@ -1283,6 +1303,10 @@
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
@@ -1441,6 +1465,8 @@
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "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 || ^0.28.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-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
@@ -1453,6 +1479,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@@ -1595,6 +1623,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"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=="],
+2 -2
View File
@@ -91,6 +91,7 @@ import type * as lib_soulPublish from "../lib/soulPublish.js";
import type * as lib_staticPublishScan from "../lib/staticPublishScan.js";
import type * as lib_tokens from "../lib/tokens.js";
import type * as lib_userSearch from "../lib/userSearch.js";
import type * as lib_userSkillStats from "../lib/userSkillStats.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as llmEval from "../llmEval.js";
import type * as maintenance from "../maintenance.js";
@@ -100,7 +101,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";
@@ -208,6 +208,7 @@ declare const fullApi: ApiFromModules<{
"lib/staticPublishScan": typeof lib_staticPublishScan;
"lib/tokens": typeof lib_tokens;
"lib/userSearch": typeof lib_userSearch;
"lib/userSkillStats": typeof lib_userSkillStats;
"lib/webhooks": typeof lib_webhooks;
llmEval: typeof llmEval;
maintenance: typeof maintenance;
@@ -217,7 +218,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;
+4 -7
View File
@@ -1,10 +1,7 @@
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
// Asserts that the internal-only download counters remain internal-only.
// Public exposure is prevented at runtime by `internalMutation`; this file
// just pins the public references that *should* exist.
void internal.downloads.recordDownloadInternal;
void internal.soulDownloads.incrementInternal;
// @ts-expect-error download counters must not be publicly callable
void api.downloads.increment;
// @ts-expect-error soul download counters must not be publicly callable
void api.soulDownloads.increment;
+1 -1
View File
@@ -229,7 +229,7 @@ export async function applyCommentScamResultInternalHandler(
ok: true,
shouldBan,
banned: !banResult.alreadyBanned,
alreadyBanned: Boolean(banResult.alreadyBanned),
alreadyBanned: banResult.alreadyBanned,
protectedRole: false,
wouldBan: false,
};
+1 -1
View File
@@ -509,7 +509,7 @@ describe("comments mutations", () => {
if (id === "skills:1") {
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
}
if (String(id).startsWith("comments:reported-")) return reportedComment;
if (id.startsWith("comments:reported-")) return reportedComment;
if (id === "skills:active") {
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
}
+1 -1
View File
@@ -704,7 +704,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
summary: mod.summary,
engineVersion: mod.engineVersion,
updatedAt: mod.updatedAt,
evidence: sanitizeEvidence(mod.evidence, Boolean(isOwner || isStaff)),
evidence: sanitizeEvidence(mod.evidence, isOwner || isStaff),
legacyReason: isOwner || isStaff ? mod.reason : null,
}
: null,
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Scheduler } from "convex/server";
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
export function scheduleNextBatchIfNeeded(
scheduler: Scheduler,
fn: unknown,
args: TArgs,
args: { cursor?: string } & Record<string, unknown>,
isDone: boolean,
continueCursor: string | null,
) {
+4 -4
View File
@@ -279,8 +279,8 @@ function decodeJwt(jwt: string) {
const parts = jwt.trim().split(".");
if (parts.length !== 3) throw new Error("Invalid GitHub OIDC token format");
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = parseJsonSegment<JwtHeader>(encodedHeader, "header");
const payload = parseJsonSegment<JwtPayload>(encodedPayload, "payload");
const header = parseJsonSegment(encodedHeader, "header") as JwtHeader;
const payload = parseJsonSegment(encodedPayload, "payload") as JwtPayload;
return {
header,
payload,
@@ -289,9 +289,9 @@ function decodeJwt(jwt: string) {
};
}
function parseJsonSegment<T>(segment: string, label: string) {
function parseJsonSegment(segment: string, label: string): unknown {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T;
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
} catch {
throw new Error(`Invalid GitHub OIDC ${label}`);
}
+6 -6
View File
@@ -144,13 +144,13 @@ export async function deletePackageSearchDigests(
}
}
function hasDigestChanged<
TExisting extends Record<string, unknown>,
TFields extends Record<string, unknown>,
>(existing: TExisting, fields: TFields): boolean {
function hasDigestChanged(
existing: Record<string, unknown>,
fields: Record<string, unknown>,
): boolean {
for (const key of Object.keys(fields)) {
const oldValue = (existing as Record<string, unknown>)[key];
const newValue = (fields as Record<string, unknown>)[key];
const oldValue = existing[key];
const newValue = fields[key];
if (oldValue === newValue) continue;
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) return true;
}
+4 -4
View File
@@ -372,7 +372,7 @@ function parseDependencyDeclarations(input: unknown): Array<{
version?: string;
url?: string;
repository?: string;
} = { name: String(obj.name).trim(), type: depType };
} = { name: obj.name.trim(), type: depType };
if (typeof obj.version === "string") decl.version = obj.version.trim();
if (typeof obj.url === "string") decl.url = obj.url.trim();
if (typeof obj.repository === "string") decl.repository = obj.repository.trim();
@@ -432,7 +432,7 @@ function parseFrontmatterLevelDeclarations(
// Parse primaryEnv from top-level frontmatter
if (typeof frontmatter.primaryEnv === "string") {
metadata.primaryEnv = String(frontmatter.primaryEnv).trim();
metadata.primaryEnv = frontmatter.primaryEnv.trim();
}
const envVars = parseEnvVarDeclarations(frontmatter.env);
@@ -441,13 +441,13 @@ function parseFrontmatterLevelDeclarations(
const dependencies = parseDependencyDeclarations(frontmatter.dependencies);
if (dependencies.length > 0) metadata.dependencies = dependencies;
if (typeof frontmatter.author === "string") metadata.author = String(frontmatter.author).trim();
if (typeof frontmatter.author === "string") metadata.author = frontmatter.author.trim();
const links = parseSkillLinks(frontmatter.links);
if (links) metadata.links = links;
if (typeof frontmatter.homepage === "string") {
metadata.homepage = String(frontmatter.homepage).trim();
metadata.homepage = frontmatter.homepage.trim();
}
return Object.keys(metadata).length > 0
+1 -1
View File
@@ -191,7 +191,7 @@ describe("skills.getPendingScanSkillsInternal", () => {
const versionId = skill.latestVersionId as string;
return [
versionId,
{ _id: versionId, sha256hash: `${String(versionId).slice(-8)}${"f".repeat(56)}` },
{ _id: versionId, sha256hash: `${versionId.slice(-8)}${"f".repeat(56)}` },
];
}),
);
+4 -4
View File
@@ -653,7 +653,7 @@ describe("skills anti-spam guards", () => {
const runAfter = vi.fn();
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const key = String(maybeId ?? tableOrId);
const key = maybeId ?? tableOrId;
if (storedSkills.has(key)) return storedSkills.get(key);
if (key === "users:owner") {
return {
@@ -759,7 +759,7 @@ describe("skills anti-spam guards", () => {
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
String(id).startsWith(`${tableName}:`) ? id : null,
id.startsWith(`${tableName}:`) ? id : null,
),
};
@@ -840,7 +840,7 @@ describe("skills anti-spam guards", () => {
});
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const key = String(maybeId ?? tableOrId);
const key = maybeId ?? tableOrId;
if (storedSkills.has(key)) return storedSkills.get(key);
if (key === "users:owner") {
return {
@@ -948,7 +948,7 @@ describe("skills anti-spam guards", () => {
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
String(id).startsWith(`${tableName}:`) ? id : null,
id.startsWith(`${tableName}:`) ? id : null,
),
};
+2 -5
View File
@@ -4794,8 +4794,6 @@ export const escalateByVtInternal = internalMutation({
slug: skill.slug,
});
}
return { ok: true, skillId: version.skillId, versionId: version._id };
},
});
@@ -6264,9 +6262,8 @@ export const insertVersion = internalMutation({
// Trusted publishers (and moderators/admins) bypass auto-hide for pending scans.
// Keep moderationReason as pending.scan so the VT poller keeps working.
const isTrustedPublisher = Boolean(
user.trustedPublisher || user.role === "admin" || user.role === "moderator",
);
const isTrustedPublisher =
user.trustedPublisher || user.role === "admin" || user.role === "moderator";
const staticSnapshot = buildModerationSnapshot({
staticScan: args.staticScan,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

@@ -0,0 +1,308 @@
# Skill Install Surface Design
- Date: 2026-04-22
- Status: Approved for planning
- Scope: skill detail page install UX on ClawHub
## Problem
The skill detail page does not treat installation as a primary action. Plugin pages already surface a direct install command prominently, but skill pages bury install-related information inside a denser metadata block. This is weaker for users who arrive on a skill page intending to install immediately.
There is also no first-class prompt-based install flow for OpenClaw. That is especially awkward for remote sessions and server-side environments where the better user experience is often "copy one prompt into OpenClaw and let it handle the install and setup guidance" rather than manually piecing together commands and requirements.
## Goals
1. Make installation a prominent, above-the-fold action on the skill page.
2. Keep exact install commands visible, copyable, and trustworthy.
3. Add an OpenClaw prompt-based install flow that works through copyable prompts only.
4. Support two prompt scopes:
- `Install Only`
- `Install & Setup`
5. Keep the flow explicit enough that users can see what will be copied before they use it.
## Non-Goals
1. No direct handoff, deep link, or automatic execution into OpenClaw.
2. No backend or Convex changes.
3. No plugin detail page redesign in this slice.
4. No attempt to auto-complete setup inside ClawHub itself.
5. No hidden prompt generation that users cannot inspect.
## Final UX Direction
Add a new `Install` section near the top of the skill hero, using a split layout:
1. `Install with OpenClaw`
2. `CLI Commands`
Desktop should render these as two sibling panels inside one install surface. Mobile should stack them vertically with `Install with OpenClaw` first.
This section becomes the primary install surface for the page. Existing runtime requirements, dependency metadata, links, and install specs remain available below as supporting information, not as the primary call-to-action.
## Install With OpenClaw Panel
### Structure
The OpenClaw panel contains:
1. A short explanation that this path is best for remote or guided setup.
2. A `Copy Prompt` action with menu behavior.
3. A prompt option menu with two choices:
- `Install Only`
- `Install & Setup`
4. A prompt preview area showing the exact prompt that will be copied.
### Interaction Model
The `Copy Prompt` control should behave like a menu trigger, not a blind copy button.
When opened, it reveals:
1. `Install Only`
Copies a prompt that tells OpenClaw to install the skill and stop there.
2. `Install & Setup`
Copies a prompt that tells OpenClaw to install the skill, inspect the skill metadata, and help the user finish setup.
Selecting an option should:
1. Update the prompt preview area.
2. Copy the corresponding prompt to the clipboard.
3. Show clear success or failure feedback.
The preview area should remain visible after selection so the copied text is inspectable.
### Prompt Content Rules
Both prompts should include the concrete skill identity, not vague prose.
Prefer:
- canonical install target: `owner/slug`
- canonical skill page URL
- enough setup context to avoid ambiguous or unsafe follow-up behavior
#### Install Only Prompt
The prompt should instruct OpenClaw to:
1. Install the skill from ClawHub.
2. Keep the action narrowly scoped to that skill.
3. Stop after install rather than making setup changes.
#### Install & Setup Prompt
The prompt should instruct OpenClaw to:
1. Install the skill from ClawHub.
2. Inspect the skill metadata and installation requirements.
3. Help the user finish setup steps such as:
- required env vars
- required binaries
- config files or follow-up instructions
4. Avoid unrelated changes.
5. Ask before making broader environment changes.
This prompt is intentionally "full service" but still constrained. It should guide OpenClaw toward a narrow setup flow rather than a vague "do everything" request.
## CLI Commands Panel
The CLI panel keeps the raw install paths visible and copyable.
It should contain two command blocks:
1. `OpenClaw CLI`
2. `ClawHub CLI`
### OpenClaw CLI Command
Primary format:
```sh
openclaw skills install owner/slug
```
If the canonical owner handle is unavailable, fall back to the best canonical identifier already used by the page route. If no owner-qualified target can be built safely, fall back to the plain slug command rather than fabricating a broken owner path.
### ClawHub CLI Command
Preserve package-manager flexibility rather than hard-coding one package manager.
Expected behavior:
1. Reuse the existing package-manager switching pattern already present on the skill page.
2. Keep `npm` selected by default to preserve the current behavior of that switcher.
3. Keep the copied command fully explicit.
Example variants:
```sh
npx clawhub@latest install slug
pnpm dlx clawhub@latest install slug
bunx clawhub@latest install slug
```
Each command block should have its own copy action.
## Supporting Metadata
The current metadata-driven install details should not disappear. Instead, they should move into a clearly secondary role below the new install surface.
This includes:
1. runtime requirements
2. dependencies
3. install specs declared by the skill
4. relevant links
The user should be able to:
1. install immediately from the hero
2. then scroll into requirements and metadata if they need deeper operational detail
## Information Architecture
The page hierarchy after this change should be:
1. skill header and summary
2. primary install surface
3. security scan and other trust signals
4. supporting metadata panels
5. README, files, comments, versions, compare, owner tools
If the existing hero composition requires rearranging nearby blocks to avoid crowding, favor install clarity over preserving the current exact order.
## Component-Level Design
Recommended component split:
1. `SkillInstallSurface`
Owns the new install section, layout, copy interactions, and prompt preview state.
2. `SkillPromptMenu` or equivalent local subcomponent
Owns prompt option selection and menu rendering.
3. `InstallCopyButton`
Shared copy button behavior for commands and prompts, ideally reusing the plugin page copy feedback pattern.
4. Small pure helpers in `skillDetailUtils.ts`
Build canonical commands and prompt text.
The existing `SkillInstallCard` should be narrowed to supporting metadata panels only. The new
primary install surface should live in a separate component so prompt generation, copy state, and
hero layout are not tangled with dependency metadata rendering.
The goal is to avoid one oversized component that mixes hero layout, prompt generation, copy
logic, and metadata panels.
## Copy and Content Rules
### OpenClaw Copy
- Label: `Copy Prompt`
- Menu options:
- `Install Only`
- `Install & Setup`
- Preview should show exact copied text
### CLI Copy
- Keep labels literal: `Copy`
- Do not hide the actual command behind a tooltip-only surface
### Tone
The UI copy should be direct and operational, not marketing-heavy.
Good:
- `Install with OpenClaw`
- `Copy Prompt`
- `Install & Setup`
- `OpenClaw CLI`
- `ClawHub CLI`
Avoid:
- vague claims about automation
- promise-heavy language
- copy that suggests ClawHub will execute anything on the user's behalf
## Accessibility
The new surface must remain keyboard-usable and screen-reader-legible.
Requirements:
1. The prompt menu trigger is keyboard accessible.
2. Menu items expose clear labels and descriptions.
3. Copy success feedback does not rely on color alone.
4. Command text remains selectable and readable at small widths.
5. Mobile layout stacks cleanly without horizontal clipping.
## Error Handling
### Clipboard Failure
If clipboard write fails:
1. use the existing fallback copy path where available
2. show failure feedback if both copy mechanisms fail
### Missing Metadata
If prompt generation cannot build full setup guidance because some metadata is absent:
1. still allow prompt copy
2. generate the best constrained prompt available
3. do not invent requirements that the page does not know
### Missing Canonical Owner
If the owner-qualified target cannot be built reliably:
1. degrade gracefully to the safest install target available
2. keep copied text accurate
3. do not render misleading `owner/slug` syntax
## Testing
### Unit Tests
Add or update tests for:
1. command builders
2. prompt builders
3. canonical owner fallback behavior
4. copy state transitions if extracted into reusable helpers
### Component Tests
Add or update tests for:
1. the new install surface rendering on skill detail pages
2. prompt menu open and selection behavior
3. prompt preview updates
4. OpenClaw and ClawHub command visibility
5. copy success feedback paths
### Manual Verification
Verify:
1. desktop layout
2. mobile stacking
3. long owner/slug values
4. skill pages with sparse metadata
5. copy behavior in browsers with and without `navigator.clipboard`
## Rollout Notes
This feature should ship as a focused UI slice. Keep scope disciplined:
1. no direct OpenClaw handoff
2. no execution inside ClawHub
3. no broad refactor outside the skill detail install surface unless needed to keep the component boundary clean
## Open Questions Resolved
1. The primary layout is the split install surface, not a single merged box.
2. OpenClaw uses copyable prompts only.
3. The prompt flow supports both `Install Only` and `Install & Setup`.
4. The `Copy Prompt` control reveals those prompt options.
5. Raw CLI commands remain visible underneath the OpenClaw flow.
+5 -2
View File
@@ -6,7 +6,7 @@
],
"type": "module",
"scripts": {
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
"build": "vite build && bun scripts/copy-og-assets.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
@@ -52,6 +52,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-router": "1.168.1",
@@ -70,12 +71,13 @@
"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",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
@@ -83,6 +85,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
+1 -1
View File
@@ -178,7 +178,7 @@ function addConfigRoots(
const extraDirs = config.skills?.load?.extraDirs ?? [];
for (const dir of extraDirs) {
const resolved = resolveUserPath(String(dir));
const resolved = resolveUserPath(dir);
if (!resolved) continue;
const label = `${prefix}Extra: ${basename(resolved) || resolved}`;
pushRoot(roots, labels, resolved, label);
+1 -1
View File
@@ -22,7 +22,7 @@ export async function cmdLoginFlow(
fail("Token required (use --token or remove --no-browser)");
}
const label = String(options.label ?? "CLI token").trim() || "CLI token";
const label = (options.label ?? "CLI token").trim() || "CLI token";
const receiver = await startLoopbackAuthServer();
const discovery = await discoverRegistryFromSite(opts.site).catch(() => null);
const authBase = discovery?.authBase?.trim() || opts.site;
+2 -2
View File
@@ -54,7 +54,7 @@ export async function cmdDeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(formatPrompt(labels, slug));
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
@@ -88,7 +88,7 @@ export async function cmdUndeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(formatPrompt(labels, slug));
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
@@ -149,7 +149,7 @@ describe("github publish source helpers", () => {
const workdir = await makeTmpDir();
const restoreFetch =
input.includes("/tree/") || input.includes("/blob/")
? mockGitHubCommitLookup([String((expected as { ref?: string }).ref ?? "")])
? mockGitHubCommitLookup([(expected as { ref?: string }).ref ?? ""])
: null;
try {
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
@@ -33,13 +33,13 @@ export async function cmdBanUser(
{ id: options.id, fuzzy: options.fuzzy },
allowPrompt,
);
if (!resolved) return;
if (!resolved) return undefined;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(
`Ban ${resolved.label}? (requires moderator/admin; deletes owned skills)`,
);
if (!ok) return;
if (!ok) return undefined;
}
const spinner = createSpinner(`Banning ${resolved.label}`);
@@ -90,11 +90,11 @@ export async function cmdSetRole(
{ id: options.id, fuzzy: options.fuzzy },
allowPrompt,
);
if (!resolved) return;
if (!resolved) return undefined;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Set role for ${resolved.label} to ${role}? (admin only)`);
if (!ok) return;
if (!ok) return undefined;
}
const spinner = createSpinner(`Setting role for ${resolved.label}`);
@@ -218,7 +218,7 @@ function formatUserList(users: UserSearchItem[]) {
function normalizeRole(value: string) {
const role = value.trim().toLowerCase();
if (role === "user" || role === "moderator" || role === "admin") return role;
fail("Role must be user|moderator|admin");
return fail("Role must be user|moderator|admin");
}
function formatDeletedSkills(count: number) {
@@ -44,7 +44,7 @@ export async function cmdRenameSkill(
inputAllowed,
`Rename ${slug} to ${newSlug}? Old slug will redirect.`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -86,7 +86,7 @@ export async function cmdMergeSkill(
inputAllowed,
`Merge ${sourceSlug} into ${targetSlug}? Source slug will redirect and stop listing publicly.`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -77,7 +77,7 @@ function getPublishPayload() {
function getUploadedFileNames() {
const form = getPublishForm();
return (form.getAll("files") as Array<Blob & { name?: string }>)
.map((file) => String(file.name ?? ""))
.map((file) => file.name ?? "")
.sort();
}
@@ -782,7 +782,7 @@ function detectPackageFamily(
if (explicit) return explicit;
if (fileSet.has("openclaw.plugin.json")) return "code-plugin";
if (fileSet.has("openclaw.bundle.json")) return "bundle-plugin";
fail("Could not detect package family. Use --family.");
return fail("Could not detect package family. Use --family.");
}
function parseTags(value: string) {
@@ -79,7 +79,7 @@ describe("cmdPublish", () => {
expect(payload.acceptLicenseTerms).toBe(true);
expect(payload.tags).toEqual(["latest"]);
const files = publishForm.getAll("files") as Array<Blob & { name?: string }>;
expect(files.map((file) => String(file.name ?? "")).sort()).toEqual(["SKILL.md", "notes.md"]);
expect(files.map((file) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]);
} finally {
await rm(workdir, { recursive: true, force: true });
}
+1 -1
View File
@@ -478,7 +478,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
if (normalized === "trending") {
return { sort: "trending", apiSort: "trending" };
}
fail(
return fail(
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export async function cmdStarSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Star ${slug}?`);
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
@@ -282,7 +282,7 @@ export async function selectToUpload(
required: false,
});
if (isCancel(picked)) fail("Canceled");
const selected = picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as Candidate[];
const selected = picked.map((key) => valueByKey.get(key)).filter(Boolean) as Candidate[];
return selected;
}
@@ -75,7 +75,7 @@ export async function cmdTransferRequest(
inputAllowed,
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -160,7 +160,7 @@ async function runTransferDecision(
inputAllowed,
`${spec.verb} transfer of ${slug}?`,
);
if (!confirmed) return;
if (!confirmed) return undefined;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
+1 -1
View File
@@ -18,7 +18,7 @@ export async function cmdUnstarSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Unstar ${slug}?`);
if (!ok) return;
if (!ok) return undefined;
}
const token = await requireAuthToken();
+2 -2
View File
@@ -40,7 +40,7 @@ export async function promptHidden(prompt: string) {
export async function promptConfirm(prompt: string) {
const answer = await confirm({ message: prompt });
if (isCancel(answer)) return false;
return Boolean(answer);
return answer;
}
export function openInBrowser(url: string) {
@@ -70,7 +70,7 @@ export function openInBrowser(url: string) {
}
export function isInteractive() {
return Boolean(process.stdout.isTTY && stdin.isTTY);
return process.stdout.isTTY && stdin.isTTY;
}
export function createSpinner(text: string) {
+2 -2
View File
@@ -381,7 +381,7 @@ function getRetryDelayMs(attemptError: unknown, random: () => number): number {
cause?: unknown;
error?: unknown;
};
const attemptNumber = Math.max(1, Number(failed.attemptNumber ?? 1));
const attemptNumber = Math.max(1, failed.attemptNumber ?? 1);
const rootError = failed.cause ?? failed.error ?? attemptError;
if (rootError instanceof HttpStatusError && rootError.rateLimit.retryAfterSeconds !== undefined) {
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS, random);
@@ -568,7 +568,7 @@ async function fetchJsonFormViaCurl(
await deps.writeFileImpl(filePath, bytes);
formArgs.push("-F", `${key}=@${filePath};filename=${filename}`);
} else {
formArgs.push("-F", `${key}=${String(value)}`);
formArgs.push("-F", `${key}=${value}`);
}
}
+3 -3
View File
@@ -133,9 +133,9 @@ export async function readSkillOrigin(skillFolder: string): Promise<SkillOrigin
}
return {
version: 1,
registry: String(parsed.registry),
slug: String(parsed.slug),
installedVersion: String(parsed.installedVersion),
registry: parsed.registry,
slug: parsed.slug,
installedVersion: parsed.installedVersion,
installedAt: parsed.installedAt,
};
} catch {
+83 -7
View File
@@ -152,6 +152,89 @@ describe("SkillDetailPage", () => {
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
});
it("renders the install surface above the security scan with visible prompts and commands", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
return undefined;
});
render(
<SkillDetailPage
slug="weather"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
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: {},
clawdis: {
requires: {
env: ["WEATHER_API_KEY"],
bins: ["curl"],
},
},
},
files: [],
sha256hash: "abc123",
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# Weather",
readmeError: null,
}}
/>,
);
const installHeading = await screen.findByRole("heading", { name: "Install with OpenClaw" });
const scanDisclaimer = screen.getByText(
/Like a lobster shell, security has layers — review code before you run it\./i,
);
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.getByText("openclaw skills install steipete/weather")).toBeTruthy();
expect(screen.getByText("npx clawhub@latest install weather")).toBeTruthy();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(installHeading.compareDocumentPosition(scanDisclaimer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
@@ -412,13 +495,6 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(
(
await screen.findAllByText(
/free to use, modify, and redistribute\. no attribution required\./i,
)
).length,
).toBeGreaterThan(0);
expect(
screen.queryByText(/Reports require a reason\. Abuse may result in a ban\./i),
).toBeNull();
+100
View File
@@ -0,0 +1,100 @@
import { Check, Copy } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
type CopyState = "idle" | "copied" | "failed";
export async function copyText(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
if (typeof document === "undefined" || typeof document.execCommand !== "function") {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
try {
return document.execCommand("copy");
} finally {
document.body.removeChild(textarea);
}
}
export function InstallCopyButton({
text,
label = "Copy",
ariaLabel,
className,
}: {
text: string;
label?: string;
ariaLabel?: string;
className?: string;
}) {
const [copyState, setCopyState] = useState<CopyState>("idle");
const resetTimeoutRef = useRef<number | null>(null);
useEffect(
() => () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
},
[],
);
const scheduleReset = () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = window.setTimeout(() => {
setCopyState("idle");
resetTimeoutRef.current = null;
}, 2000);
};
const buttonLabel =
copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy Failed" : label;
return (
<Button
type="button"
size="sm"
variant="outline"
className={cn("skill-install-copy-button", className)}
aria-label={ariaLabel ?? label}
data-copy-state={copyState}
onClick={() => {
void copyText(text)
.then((didCopy) => {
setCopyState(didCopy ? "copied" : "failed");
scheduleReset();
})
.catch(() => {
setCopyState("failed");
scheduleReset();
});
}}
>
{copyState === "copied" ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span aria-live="polite">{buttonLabel}</span>
</Button>
);
}
+2
View File
@@ -23,6 +23,8 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
return `pnpm dlx clawhub@latest install ${exampleSlug}`;
case "bun":
return `bunx clawhub@latest install ${exampleSlug}`;
default:
return `npx clawhub@latest install ${exampleSlug}`;
}
}, [exampleSlug, pm]);
+195
View File
@@ -0,0 +1,195 @@
/* @vitest-environment jsdom */
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MarkdownPreview } from "./MarkdownPreview";
function renderMarkdown(source: string) {
// Disable Shiki highlighting to keep the tree synchronous for assertions.
const { container } = render(<MarkdownPreview highlight={false}>{source}</MarkdownPreview>);
return container;
}
describe("MarkdownPreview — raw HTML passthrough", () => {
it("renders an <h1 align=\"center\"> block as a real <h1>", () => {
const container = renderMarkdown(`<h1 align="center">Hello logo</h1>`);
const h1 = container.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.textContent).toBe("Hello logo");
});
it("renders a <div align=\"center\"> block as a real <div>", () => {
const container = renderMarkdown(`<div align="center">centered</div>`);
const div = container.querySelector("div[align=\"center\"]");
expect(div).not.toBeNull();
expect(div?.textContent).toBe("centered");
});
it("renders <picture> with <source> + <img> fallback", () => {
const container = renderMarkdown(
`<picture><source media="(prefers-color-scheme: dark)" srcset="dark.png"/><img alt="Logo" src="light.png"/></picture>`,
);
expect(container.querySelector("picture")).not.toBeNull();
expect(container.querySelector("picture source")).not.toBeNull();
const img = container.querySelector("picture img");
expect(img).not.toBeNull();
expect(img?.getAttribute("alt")).toBe("Logo");
expect(img?.getAttribute("src")).toBe("light.png");
});
it("renders standalone <img> tags with src and alt", () => {
const container = renderMarkdown(`<img src="screenshot.png" alt="Demo screenshot"/>`);
const img = container.querySelector("img");
expect(img).not.toBeNull();
// Relative paths render as-is — only external http(s) URLs get proxied.
expect(img?.getAttribute("src")).toBe("screenshot.png");
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
});
it("routes external https <img> URLs through /_vercel/image", () => {
const container = renderMarkdown(
`<img src="https://raw.githubusercontent.com/foo/bar/main/logo.png" alt="logo"/>`,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffoo%2Fbar%2Fmain%2Flogo.png&w=1024&q=75",
);
});
it("routes external markdown ![](url) images through /_vercel/image", () => {
const container = renderMarkdown(`![logo](https://img.shields.io/badge/x-y-blue.svg)`);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fimg.shields.io%2Fbadge%2Fx-y-blue.svg&w=1024&q=75",
);
});
it("renders <br/> as a real line break", () => {
const container = renderMarkdown(`line one<br/>line two`);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders the Opik README banner (centered h1 + picture + img)", () => {
const opikBanner = `<h1 align="center">
<a href="https://www.comet.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="dark.svg"/>
<img alt="Comet Opik logo" src="light.svg" width="200"/>
</picture>
</a>
<br/>OpenClaw Opik Observability Plugin
</h1>`;
const container = renderMarkdown(opikBanner);
expect(container.querySelector("h1")).not.toBeNull();
expect(container.querySelector("picture")).not.toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("alt")).toBe("Comet Opik logo");
// And the escaped tag must NOT be present as literal text anywhere.
expect(container.textContent ?? "").not.toContain("<picture>");
});
});
describe("MarkdownPreview — standard markdown still renders", () => {
it("renders ATX headings", () => {
const container = renderMarkdown(`## Why This Plugin`);
const h2 = container.querySelector("h2");
expect(h2?.textContent).toBe("Why This Plugin");
});
it("renders markdown links", () => {
const container = renderMarkdown(`[Opik](https://example.com/opik)`);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("https://example.com/opik");
expect(a?.textContent).toBe("Opik");
});
it("renders inline code", () => {
const container = renderMarkdown("Use `@opik/opik-openclaw` now.");
const code = container.querySelector("code");
expect(code?.textContent).toBe("@opik/opik-openclaw");
});
it("renders unordered lists", () => {
const container = renderMarkdown(`- one\n- two\n- three`);
const items = container.querySelectorAll("li");
expect(items.length).toBe(3);
expect(items[0].textContent).toBe("one");
});
it("renders GFM tables", () => {
const container = renderMarkdown(
[
"| Key | Value |",
"| --- | ----- |",
"| a | 1 |",
"| b | 2 |",
].join("\n"),
);
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelectorAll("tbody tr").length).toBe(2);
});
it("renders fenced code blocks as <pre><code>", () => {
const container = renderMarkdown("```ts\nconst x = 1;\n```");
const code = container.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — syntax highlighting", () => {
it("shiki-highlights fenced code blocks (produces colored <span> tokens)", async () => {
const { container } = render(
<MarkdownPreview>{"```ts\nconst x: number = 1;\n```"}</MarkdownPreview>,
);
await waitFor(
() => {
const pre = container.querySelector("pre");
// Shiki wraps the output in <pre class="shiki ..."> and tokens are
// <span style="color:#...">.
expect(pre?.className ?? "").toMatch(/shiki/);
const coloredSpans = container.querySelectorAll("pre span[style*='color']");
expect(coloredSpans.length).toBeGreaterThan(0);
},
{ timeout: 8000 },
);
// Raw code text must still be present after highlighting
expect(container.querySelector("pre")?.textContent).toContain("const x");
});
it("leaves the highlight prop honored — highlight={false} renders plain <pre><code>", () => {
const { container } = render(
<MarkdownPreview highlight={false}>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>,
);
const pre = container.querySelector("pre");
// No shiki class, no colored spans
expect(pre?.className ?? "").not.toMatch(/shiki/);
expect(container.querySelectorAll("pre span[style*='color']").length).toBe(0);
expect(pre?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — sanitization of malicious HTML", () => {
it("strips <script> tags", () => {
const container = renderMarkdown(`hello<script>window.__pwn = 1;</script>world`);
expect(container.querySelector("script")).toBeNull();
expect(container.textContent ?? "").not.toContain("window.__pwn");
});
it("strips onerror handlers on <img>", () => {
const container = renderMarkdown(`<img src="x" onerror="window.__pwn = 1" alt="x"/>`);
const img = container.querySelector("img");
// The img itself can render; the handler must be gone.
expect(img?.getAttribute("onerror")).toBeNull();
});
it("strips javascript: hrefs on anchors", () => {
const container = renderMarkdown(`<a href="javascript:alert(1)">click</a>`);
const a = container.querySelector("a");
// Either the href is removed entirely or rewritten — it must not start with javascript:
const href = a?.getAttribute("href") ?? "";
expect(href.toLowerCase().startsWith("javascript:")).toBe(false);
});
});
+92 -69
View File
@@ -1,94 +1,117 @@
import { parse } from "@create-markdown/core";
import { blocksToHTML, renderAsync, shikiPlugin } from "@create-markdown/preview";
import { useEffect, useRef, useState } from "react";
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import type { HighlighterGeneric } from "shiki";
import type { PluggableList } from "unified";
import { rehypeProxyImages } from "../lib/rehypeProxyImages";
import { cn } from "../lib/utils";
interface MarkdownPreviewProps {
children: string;
className?: string;
/** Enable Shiki syntax highlighting for code blocks (async). Default: true */
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
highlight?: boolean;
}
/**
* Auto-link bare URLs in HTML that aren't already inside anchor tags or attributes.
* Matches http/https URLs in text nodes only (not inside tags).
*/
function autolinkURLs(html: string): string {
// Split HTML into tags and text segments, then only linkify text segments
return html.replace(
/(<[^>]*>)|((https?:\/\/)[^\s<>"')\]]+)/gi,
(match, tag: string | undefined, url: string | undefined) => {
// If it's an HTML tag, leave it alone
if (tag) return tag;
// If it's a bare URL in text content, wrap it
if (url) {
// Trim trailing punctuation that's likely not part of the URL
const trailingPunct = /[.,;:!?)]+$/.exec(url);
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
const suffix = trailingPunct ? trailingPunct[0] : "";
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer">${cleanUrl}</a>${suffix}`;
}
return match;
},
);
const schema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), "picture", "source"],
attributes: {
...defaultSchema.attributes,
"*": [...(defaultSchema.attributes?.["*"] ?? []), "align"],
img: [...(defaultSchema.attributes?.img ?? []), "width", "height"],
source: ["media", "srcSet", "srcset", "type"],
picture: [],
},
};
// Order matters: rehype-sanitize runs BEFORE rehype-shiki so sanitize only
// sees user-authored HTML; shiki's trusted styled output flows through after.
// rehypeProxyImages rewrites after sanitize so we rewrite only already-safe
// <img src="..."> nodes (sanitize strips event handlers, javascript: URLs).
const baseRehype: PluggableList = [
rehypeRaw,
[rehypeSanitize, schema],
rehypeProxyImages,
];
const SHIKI_THEME = "github-dark";
const SHIKI_LANGS = [
"bash",
"sh",
"shell",
"ts",
"tsx",
"js",
"jsx",
"json",
"yaml",
"md",
"python",
"nix",
"http",
"html",
"css",
"toml",
"rust",
"go",
"dockerfile",
"diff",
];
type AnyHighlighter = HighlighterGeneric<string, string>;
let highlighterPromise: Promise<AnyHighlighter> | null = null;
function loadHighlighter(): Promise<AnyHighlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then(
({ createHighlighter }) =>
createHighlighter({
themes: [SHIKI_THEME],
langs: SHIKI_LANGS,
}) as Promise<AnyHighlighter>,
);
}
return highlighterPromise;
}
/**
* Rich markdown preview using @create-markdown/preview.
* Renders markdown HTML with optional Shiki syntax highlighting.
* Falls back to synchronous (unhighlighted) rendering while Shiki loads.
*/
export function MarkdownPreview({ children, className, highlight = true }: MarkdownPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Initial sync render (no highlighting) for instant display
const [html, setHtml] = useState(() => {
try {
const blocks = parse(children);
return autolinkURLs(blocksToHTML(blocks));
} catch {
return "";
}
});
const [highlighter, setHighlighter] = useState<AnyHighlighter | null>(null);
useEffect(() => {
let cancelled = false;
// Re-parse synchronously on content change
try {
const blocks = parse(children);
const syncHtml = autolinkURLs(blocksToHTML(blocks));
setHtml(syncHtml);
if (!highlight) return;
// Async render with Shiki syntax highlighting
void renderAsync(blocks, {
plugins: [shikiPlugin({ theme: "github-dark" })],
})
.then((highlighted) => {
if (!cancelled) {
setHtml(autolinkURLs(highlighted));
}
if (highlight) {
loadHighlighter()
.then((h) => {
if (!cancelled) setHighlighter(h);
})
.catch(() => {
// Shiki failed to load — keep the sync render
// Shiki failed to initialize — keep plain rendering.
});
} catch {
// Parse failed — clear
setHtml("");
}
return () => {
cancelled = true;
};
}, [children, highlight]);
}, [highlight]);
const rehypePlugins = useMemo<PluggableList>(() => {
if (highlight && highlighter) {
return [
...baseRehype,
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }],
];
}
return baseRehype;
}, [highlight, highlighter]);
return (
<div
ref={containerRef}
className={cn("markdown", className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
<div className={cn("markdown", className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
{children}
</ReactMarkdown>
</div>
);
}
+17 -20
View File
@@ -236,28 +236,25 @@ export function SkillDetailPage({
}, [navigate, ownerParam, slug, wantsCanonicalRedirect]);
useEffect(() => {
if (!latestVersion) return;
if (loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null)) {
return;
}
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
let cancelled = false;
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
setLoadedReadmeVersionId(latestVersion._id);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load README");
setReadme(null);
setLoadedReadmeVersionId(latestVersion._id);
});
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
setLoadedReadmeVersionId(latestVersion._id);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load README");
setReadme(null);
setLoadedReadmeVersionId(latestVersion._id);
});
}
return () => {
cancelled = true;
+6 -1
View File
@@ -2,8 +2,11 @@ import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { rehypeProxyImages } from "../lib/rehypeProxyImages";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
const REHYPE_PLUGINS = [rehypeProxyImages];
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
);
@@ -96,7 +99,9 @@ export function SkillDetailTabs({
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={REHYPE_PLUGINS}>
{readmeContent}
</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
+2 -2
View File
@@ -232,7 +232,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
}, [getFileText, leftVersionId, rightVersionId, selectedItem]);
useEffect(() => {
if (!monaco || typeof document === "undefined") return;
if (!monaco || typeof document === "undefined") return () => {};
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
@@ -248,7 +248,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
}, [monaco]);
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof window === "undefined") return () => {};
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_DIFF_BREAKPOINT}px)`);
const syncViewMode = () => {
if (!userSelectedViewModeRef.current) {
+16 -2
View File
@@ -8,6 +8,7 @@ import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { SkillInstallCard } from "./SkillInstallCard";
import { SkillInstallSurface } from "./SkillInstallSurface";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
@@ -123,6 +124,7 @@ export function SkillHeader({
const overrideScanMessage = suppressScanResults
? "Security findings were reviewed by staff and cleared for public use."
: null;
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
return (
<>
@@ -297,6 +299,14 @@ export function SkillHeader({
</div>
</div>
<SkillInstallSurface
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="skill-hero-note">{overrideScanMessage}</div>
@@ -395,13 +405,17 @@ export function SkillHeader({
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest"
placeholder="latest"
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
@@ -412,7 +426,7 @@ export function SkillHeader({
))}
</select>
<Button type="submit">
Update tag
Update Tag
</Button>
</form>
) : null}
@@ -0,0 +1,94 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SkillInstallSurface } from "./SkillInstallSurface";
const writeTextMock = vi.fn();
vi.mock("./ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
}: {
children: ReactNode;
onSelect?: () => void;
}) => (
<button type="button" onClick={() => onSelect?.()}>
{children}
</button>
),
}));
describe("SkillInstallSurface", () => {
const ownerPublisherId = "publishers:1" as never;
beforeEach(() => {
writeTextMock.mockReset();
writeTextMock.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: writeTextMock,
},
});
});
it("renders the default install-and-setup preview and copies the selected prompt", async () => {
render(
<SkillInstallSurface
slug="weather"
displayName="Weather"
ownerHandle="steipete"
ownerId={ownerPublisherId}
clawdis={
{
requires: {
env: ["WEATHER_API_KEY"],
bins: ["curl"],
},
} as never
}
/>,
);
expect(screen.getByRole("heading", { name: "Install with OpenClaw" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(screen.getAllByText("Install & Setup").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: /Install Only/i }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("Stop after the skill is installed."),
);
});
expect(screen.getByText(/Stop after the skill is installed\./i)).toBeTruthy();
expect(screen.getAllByText("Install Only").length).toBeGreaterThan(0);
});
it("switches the ClawHub command and copies the visible CLI command", async () => {
render(
<SkillInstallSurface
slug="weather"
displayName="Weather"
ownerHandle="steipete"
ownerId={ownerPublisherId}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Use pnpm for ClawHub install command" }));
expect(screen.getByText("pnpm dlx clawhub@latest install weather")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Copy ClawHub CLI command" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("pnpm dlx clawhub@latest install weather");
});
});
});
+238
View File
@@ -0,0 +1,238 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { ChevronDown } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import { copyText, InstallCopyButton } from "./InstallCopyButton";
import {
buildSkillInstallTarget,
formatClawHubInstallCommand,
formatOpenClawInstallCommand,
formatOpenClawPrompt,
type SkillPackageManager,
type SkillPromptMode,
} from "./skillDetailUtils";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
const PACKAGE_MANAGERS: SkillPackageManager[] = ["npm", "pnpm", "bun"];
const PROMPT_OPTIONS: Array<{
description: string;
label: string;
mode: SkillPromptMode;
}> = [
{
mode: "install-only",
label: "Install Only",
description: "Install the skill and stop there.",
},
{
mode: "install-and-setup",
label: "Install & Setup",
description: "Install first, then help finish setup from skill metadata.",
},
];
type PromptCopyState = "idle" | "copied" | "failed";
type SkillInstallSurfaceProps = {
slug: string;
displayName: string;
ownerHandle: string | null;
ownerId: Id<"users"> | Id<"publishers"> | null;
clawdis?: ClawdisSkillMetadata;
};
export function SkillInstallSurface({
slug,
displayName,
ownerHandle,
ownerId,
clawdis,
}: SkillInstallSurfaceProps) {
const headingId = useId();
const [packageManager, setPackageManager] = useState<SkillPackageManager>("npm");
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
const [promptCopyState, setPromptCopyState] = useState<PromptCopyState>("idle");
const promptResetTimeoutRef = useRef<number | null>(null);
useEffect(
() => () => {
if (promptResetTimeoutRef.current !== null) {
window.clearTimeout(promptResetTimeoutRef.current);
}
},
[],
);
const schedulePromptReset = () => {
if (promptResetTimeoutRef.current !== null) {
window.clearTimeout(promptResetTimeoutRef.current);
}
promptResetTimeoutRef.current = window.setTimeout(() => {
setPromptCopyState("idle");
promptResetTimeoutRef.current = null;
}, 2000);
};
const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const openClawCommand = formatOpenClawInstallCommand(ownerHandle, ownerId, slug);
const clawHubCommand = formatClawHubInstallCommand(slug, packageManager);
const promptPreview = formatOpenClawPrompt({
mode: promptMode,
skillName: displayName,
slug,
ownerHandle,
ownerId,
clawdis,
});
const promptFeedback =
promptCopyState === "copied"
? `${selectedPrompt.label} prompt copied.`
: promptCopyState === "failed"
? "Copy failed. Try again."
: `Previewing ${selectedPrompt.label}.`;
const selectPromptMode = (mode: SkillPromptMode) => {
const promptText = formatOpenClawPrompt({
mode,
skillName: displayName,
slug,
ownerHandle,
ownerId,
clawdis,
});
setPromptMode(mode);
void copyText(promptText)
.then((didCopy) => {
setPromptCopyState(didCopy ? "copied" : "failed");
schedulePromptReset();
})
.catch(() => {
setPromptCopyState("failed");
schedulePromptReset();
});
};
return (
<section className="skill-install-surface" aria-labelledby={headingId}>
<h2 id={headingId} className="sr-only">
Install
</h2>
<div className="skill-install-grid">
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
<p className="skill-install-panel-copy">
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
for <code translate="no">{installTarget}</code>.
</p>
</div>
<div className="skill-install-actions">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" className="skill-install-prompt-trigger">
<span>Copy Prompt</span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="skill-install-menu">
{PROMPT_OPTIONS.map((option) => (
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
<div className="skill-install-menu-copy">
<span className="skill-install-menu-label">{option.label}</span>
<span className="skill-install-menu-description">{option.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="skill-install-copy-feedback" aria-live="polite">
{promptFeedback}
</span>
</div>
<div className="skill-install-preview-meta">
<span className="skill-install-preview-label">Prompt Preview</span>
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
</div>
<pre className="skill-install-prompt-preview">
<code translate="no">{promptPreview}</code>
</pre>
</article>
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">Command Line</p>
<h3 className="skill-install-panel-title">CLI Commands</h3>
<p className="skill-install-panel-copy">
Use the direct CLI path if you want to install manually and keep every step visible.
</p>
</div>
<div className="skill-install-command-card">
<div className="skill-install-command-header">
<div className="skill-install-command-copy">
<p className="skill-install-command-label">OpenClaw CLI</p>
<p className="skill-install-command-caption">Canonical install target</p>
</div>
<InstallCopyButton
text={openClawCommand}
ariaLabel="Copy OpenClaw CLI command"
/>
</div>
<pre className="skill-install-command">
<code translate="no">{openClawCommand}</code>
</pre>
</div>
<div className="skill-install-command-card">
<div className="skill-install-command-header">
<div className="skill-install-command-copy">
<p className="skill-install-command-label">ClawHub CLI</p>
<p className="skill-install-command-caption">Package manager switcher</p>
</div>
<InstallCopyButton
text={clawHubCommand}
ariaLabel="Copy ClawHub CLI command"
/>
</div>
<div className="install-switcher-toggle" aria-label="ClawHub install command">
{PACKAGE_MANAGERS.map((entry) => (
<button
key={entry}
type="button"
aria-label={`Use ${entry} for ClawHub install command`}
aria-pressed={packageManager === entry}
className={`install-switcher-pill${packageManager === entry ? " is-active" : ""}`}
onClick={() => setPackageManager(entry)}
>
{entry}
</button>
))}
</div>
<pre className="skill-install-command">
<code translate="no">{clawHubCommand}</code>
</pre>
</div>
</article>
</div>
</section>
);
}
+1 -4
View File
@@ -1,8 +1,5 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { PLATFORM_SKILL_LICENSE } 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";
+14 -13
View File
@@ -95,20 +95,21 @@ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
}, [ensureSoulSeeds]);
useEffect(() => {
if (!latestVersion) return;
setReadme(null);
setReadmeError(null);
let cancelled = false;
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load SOUL.md");
setReadme(null);
});
if (latestVersion) {
setReadme(null);
setReadmeError(null);
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load SOUL.md");
setReadme(null);
});
}
return () => {
cancelled = true;
};
+76
View File
@@ -0,0 +1,76 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { describe, expect, it } from "vitest";
import type { Id } from "../../convex/_generated/dataModel";
import {
buildSkillInstallTarget,
buildSkillPageUrl,
formatClawHubInstallCommand,
formatOpenClawInstallCommand,
formatOpenClawPrompt,
} from "./skillDetailUtils";
describe("skill detail install helpers", () => {
const ownerPublisherId = "publishers:1" as Id<"publishers">;
it("prefers the owner handle for install targets", () => {
expect(buildSkillInstallTarget("steipete", ownerPublisherId, "weather")).toBe("steipete/weather");
});
it("falls back to owner id and then plain slug", () => {
expect(buildSkillInstallTarget(null, ownerPublisherId, "weather")).toBe("publishers:1/weather");
expect(buildSkillInstallTarget(null, null, "weather")).toBe("weather");
});
it("formats the OpenClaw and ClawHub commands", () => {
expect(formatOpenClawInstallCommand("steipete", ownerPublisherId, "weather")).toBe(
"openclaw skills install steipete/weather",
);
expect(formatClawHubInstallCommand("weather", "npm")).toBe("npx clawhub@latest install weather");
expect(formatClawHubInstallCommand("weather", "pnpm")).toBe(
"pnpm dlx clawhub@latest install weather",
);
expect(formatClawHubInstallCommand("weather", "bun")).toBe("bunx clawhub@latest install weather");
});
it("builds the install-and-setup prompt from known metadata only", () => {
const clawdis = {
requires: {
env: ["WEATHER_API_KEY"],
bins: ["curl"],
config: ["~/.weatherrc"],
},
} satisfies Partial<ClawdisSkillMetadata>;
const prompt = formatOpenClawPrompt({
mode: "install-and-setup",
skillName: "Weather",
slug: "weather",
ownerHandle: "steipete",
ownerId: ownerPublisherId,
clawdis: clawdis as ClawdisSkillMetadata,
});
expect(prompt).toContain("steipete/weather");
expect(prompt).toContain("https://clawhub.ai/steipete/weather");
expect(prompt).toContain("WEATHER_API_KEY");
expect(prompt).toContain("curl");
expect(prompt).toContain("~/.weatherrc");
expect(prompt).not.toContain("unknown");
});
it("avoids fabricating unknown owner URLs when the owner is missing", () => {
expect(buildSkillPageUrl(null, null, "weather")).toBeNull();
const prompt = formatOpenClawPrompt({
mode: "install-only",
skillName: "Weather",
slug: "weather",
ownerHandle: null,
ownerId: null,
});
expect(prompt).toContain('Install the skill "Weather" (weather) from ClawHub.');
expect(prompt).not.toContain("Skill page:");
expect(prompt).not.toContain("unknown");
});
});
+115 -1
View File
@@ -1,5 +1,24 @@
import type { SkillInstallSpec } from "clawhub-schema";
import type { ClawdisSkillMetadata, SkillInstallSpec } from "clawhub-schema";
import type { Id } from "../../convex/_generated/dataModel";
import { getClawHubSiteUrl } from "../lib/site";
export type SkillPromptMode = "install-only" | "install-and-setup";
export type SkillPackageManager = "npm" | "pnpm" | "bun";
function assertNever(value: never): never {
throw new Error(`Unsupported package manager: ${String(value)}`);
}
type SkillOwnerId = Id<"users"> | Id<"publishers">;
type SkillPromptContext = {
mode: SkillPromptMode;
skillName: string;
slug: string;
ownerHandle: string | null;
ownerId: SkillOwnerId | null;
clawdis?: ClawdisSkillMetadata;
};
export function buildSkillHref(
ownerHandle: string | null,
@@ -151,6 +170,101 @@ export function formatInstallCommand(spec: SkillInstallSpec) {
return null;
}
export function buildSkillInstallTarget(
ownerHandle: string | null,
ownerId: SkillOwnerId | null,
slug: string,
) {
const handle = ownerHandle?.trim();
if (handle) return `${handle}/${slug}`;
if (ownerId) return `${String(ownerId)}/${slug}`;
return slug;
}
export function buildSkillPageUrl(
ownerHandle: string | null,
ownerId: SkillOwnerId | null,
slug: string,
) {
const handle = ownerHandle?.trim();
const owner = handle || (ownerId ? String(ownerId) : null);
if (!owner) return null;
const path = `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
return new URL(path, getClawHubSiteUrl()).toString();
}
export function formatOpenClawInstallCommand(
ownerHandle: string | null,
ownerId: SkillOwnerId | null,
slug: string,
) {
return `openclaw skills install ${buildSkillInstallTarget(ownerHandle, ownerId, slug)}`;
}
export function formatClawHubInstallCommand(slug: string, pm: SkillPackageManager) {
switch (pm) {
case "npm":
return `npx clawhub@latest install ${slug}`;
case "pnpm":
return `pnpm dlx clawhub@latest install ${slug}`;
case "bun":
return `bunx clawhub@latest install ${slug}`;
}
return assertNever(pm);
}
export function formatOpenClawPrompt({
mode,
skillName,
slug,
ownerHandle,
ownerId,
clawdis,
}: SkillPromptContext) {
const target = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const pageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug);
const displayName = skillName.trim() || slug;
const requiredEnvVars = new Set(clawdis?.requires?.env ?? []);
for (const envVar of clawdis?.envVars ?? []) {
const name = envVar.name?.trim();
if (!name) continue;
if (envVar.required === false) continue;
requiredEnvVars.add(name);
}
const lines = [`Install the skill "${displayName}" (${target}) from ClawHub.`];
if (pageUrl) {
lines.push(`Skill page: ${pageUrl}`);
}
lines.push("Keep the work scoped to this skill only.");
if (mode === "install-only") {
lines.push("Stop after the skill is installed.");
return lines.join("\n");
}
lines.push("After install, inspect the skill metadata and help me finish setup.");
if (requiredEnvVars.size > 0) {
lines.push(`Required env vars: ${Array.from(requiredEnvVars).join(", ")}`);
}
if (clawdis?.requires?.bins?.length) {
lines.push(`Required binaries: ${clawdis.requires.bins.join(", ")}`);
}
if (clawdis?.requires?.config?.length) {
lines.push(`Config paths to check: ${clawdis.requires.config.join(", ")}`);
}
lines.push("Use only the metadata you can verify from ClawHub; do not invent missing requirements.");
lines.push("Ask before making any broader environment changes.");
return lines.join("\n");
}
export function formatBytes(bytes: number) {
if (!Number.isFinite(bytes)) return "—";
if (bytes < 1024) return `${bytes} B`;
-3
View File
@@ -141,9 +141,6 @@ function rhex(n: number) {
}
function hex(x: number[]) {
for (let i = 0; i < x.length; i += 1) {
x[i] = Number(x[i]);
}
return x.map(rhex).join("");
}
+1 -1
View File
@@ -368,7 +368,7 @@ describe("fetchPackages", () => {
const result = await fetchPackageVersion("demo-plugin", "1.2.3+build/meta");
expect(result.version?.version).toBe("1.2.3");
expect(result?.version?.version).toBe("1.2.3");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://registry.example/api/v1/packages/demo-plugin/versions/1.2.3%2Bbuild%2Fmeta",
);
+4 -4
View File
@@ -52,18 +52,18 @@ export function normalizePackageUploadPath(
return parts.slice(1).join("/") || (parts.at(-1) ?? "");
}
function getRawUploadPath<TFile extends UploadablePackageFile>(file: TFile) {
function getRawUploadPath(file: UploadablePackageFile) {
return file.webkitRelativePath?.trim() || file.name;
}
function getNormalizedUploadPath<TFile extends UploadablePackageFile>(
file: TFile,
function getNormalizedUploadPath(
file: UploadablePackageFile,
options: NormalizePackageUploadPathOptions = {},
) {
return normalizePackageUploadPath(getRawUploadPath(file), options) || file.name;
}
function shouldStripSharedTopLevelFolder<TFile extends UploadablePackageFile>(files: TFile[]) {
function shouldStripSharedTopLevelFolder(files: UploadablePackageFile[]) {
if (files.length === 0) return false;
const partsList = files
.map((file) => getNormalizedUploadPath(file))
+26
View File
@@ -0,0 +1,26 @@
import type { Root } from "hast";
import { visit } from "unist-util-visit";
/**
* Routes external http(s) <img> sources through Vercel's image optimizer at
* /_vercel/image, which enforces the allow-list, SVG rejection, and caching
* declared in vercel.json. Local paths, relative paths, and data: URIs pass
* through unchanged only external schemes are treated as untrusted.
*
* `w` is required by the optimizer and must match a value in the `sizes`
* array in vercel.json, so we always pass 1024. The <img width="..."> HTML
* attribute still drives layout this only controls served resolution.
*/
export function rehypeProxyImages() {
return (tree: Root) => {
visit(tree, "element", (node) => {
if (node.tagName !== "img") return;
const src = node.properties?.src;
if (typeof src !== "string" || !/^https?:\/\//i.test(src)) return;
node.properties = {
...node.properties,
src: `/_vercel/image?url=${encodeURIComponent(src)}&w=1024&q=75`,
};
});
};
}
+1 -1
View File
@@ -29,5 +29,5 @@ export function isDevRuntime() {
if (nodeEnv) {
return nodeEnv !== "production";
}
return Boolean(import.meta.env.DEV);
return import.meta.env.DEV;
}
+9 -9
View File
@@ -14,8 +14,8 @@ describe("theme", () => {
<button type="button" onClick={() => setMode("dark")}>
dark
</button>
<button type="button" onClick={() => setFamily("hub")}>
hub
<button type="button" onClick={() => setFamily("claw")}>
claw
</button>
</div>
);
@@ -27,7 +27,7 @@ describe("theme", () => {
value: {
getItem: (key: string) => (key in store ? store[key] : null),
setItem: (key: string, value: string) => {
store[key] = String(value);
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
@@ -58,7 +58,7 @@ describe("theme", () => {
"clawhub-theme-selection",
JSON.stringify({ theme: "hub", mode: "light" }),
);
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "light" });
window.localStorage.clear();
window.localStorage.setItem("clawhub-theme", "dark");
@@ -70,10 +70,10 @@ describe("theme", () => {
});
it("applies family and resolved mode to the document", () => {
applyTheme("dark", "hub");
applyTheme("dark", "claw");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light", "claw");
@@ -104,15 +104,15 @@ describe("theme", () => {
expect(screen.getByTestId("mode").textContent).toBe("system");
expect(screen.getByTestId("family").textContent).toBe("claw");
fireEvent.click(screen.getByRole("button", { name: "hub" }));
fireEvent.click(screen.getByRole("button", { name: "claw" }));
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("claw");
});
});
+2 -2
View File
@@ -166,13 +166,13 @@ export function useThemeMode() {
}, []);
useEffect(() => {
if (!isHydrated) return;
if (!isHydrated) return () => {};
applyThemeSelection(selection);
persistThemeSelection(selection);
syncCustomThemeFromStorage();
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
return () => {};
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
+1 -1
View File
@@ -55,7 +55,7 @@ export function useUnifiedSearch(
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return;
return () => {};
}
requestRef.current += 1;
+2 -1
View File
@@ -9,11 +9,12 @@ import {
ShieldOff,
UserX,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
export function renderWithInlineCode(text: string): ReactNode[] {
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
+1 -1
View File
@@ -463,7 +463,7 @@ export function ImportGitHub() {
>
<input
type="checkbox"
checked={Boolean(selected[file.path])}
checked={selected[file.path]}
onChange={() =>
setSelected((prev) => ({ ...prev, [file.path]: !prev[file.path] }))
}
+11 -12
View File
@@ -284,19 +284,18 @@ function SkillsHome() {
});
}
const drawClaw = (ctx: CanvasRenderingContext2D, size: number) => {
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
ctx.beginPath();
ctx.moveTo(0, size * 0.5);
ctx.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
ctx.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
ctx.closePath();
ctx.fill();
context.beginPath();
context.moveTo(0, size * 0.5);
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
context.closePath();
context.fill();
};
let raf: number;
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
@@ -332,13 +331,13 @@ function SkillsHome() {
ctx.restore();
}
if (alive) {
raf = requestAnimationFrame(draw);
requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
raf = requestAnimationFrame(draw);
requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
+1 -1
View File
@@ -438,7 +438,7 @@ function PluginDetailRoute() {
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{String(value)}
{value}
</dd>
</div>
))}
+3 -3
View File
@@ -146,7 +146,7 @@ export function useSkillsBrowseModel({
}, [searchKey]);
useEffect(() => {
if (!hasQuery) return;
if (!hasQuery) return () => {};
searchRequest.current += 1;
const requestId = searchRequest.current;
setIsSearching(true);
@@ -269,9 +269,9 @@ export function useSkillsBrowseModel({
}, [isLoadingMore]);
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === "undefined") return;
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
const target = loadMoreRef.current;
if (!target) return;
if (!target) return () => {};
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
+1 -1
View File
@@ -244,7 +244,7 @@ function InstalledSection(props: {
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
<a
className="telemetry-skill-link"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
href={`/${encodeURIComponent(entry.skill.ownerUserId)}/${entry.skill.slug}`}
>
<span>{entry.skill.displayName}</span>
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
+351
View File
@@ -3373,6 +3373,323 @@ code {
gap: 6px;
}
.skill-install-surface {
display: grid;
gap: 16px;
}
.skill-install-grid {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
}
.skill-install-panel {
display: grid;
gap: 16px;
padding: 20px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(25, 21, 19, 0.96), rgba(13, 13, 15, 0.98)),
linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0));
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
min-width: 0;
}
[data-theme-resolved="light"] .skill-install-panel {
border-color: rgba(78, 49, 28, 0.1);
background:
linear-gradient(180deg, rgba(255, 250, 244, 0.98), rgba(248, 240, 230, 0.98)),
radial-gradient(circle at top right, rgba(197, 126, 69, 0.14), rgba(197, 126, 69, 0));
box-shadow: 0 18px 42px rgba(58, 35, 20, 0.08);
}
.skill-install-panel-header {
display: grid;
gap: 6px;
min-width: 0;
}
.skill-install-kicker {
margin: 0;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
}
.skill-install-panel-title {
margin: 0;
font-size: 1.08rem;
font-weight: 700;
color: var(--ink);
text-wrap: balance;
}
.skill-install-panel-copy {
margin: 0;
max-width: 52ch;
font-size: 0.92rem;
line-height: 1.55;
color: var(--ink-soft);
}
.skill-install-panel-copy code {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
font-family: var(--font-mono);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
[data-theme-resolved="light"] .skill-install-panel-copy code {
background: rgba(15, 12, 10, 0.08);
}
.skill-install-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.skill-install-prompt-trigger {
min-height: 46px;
border: 1px solid rgba(0, 0, 0, 0.7);
border-radius: 999px;
background: #0e0e10;
color: #f8f2e9;
padding-inline: 18px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.16);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease,
background-color 0.18s ease;
touch-action: manipulation;
}
.skill-install-prompt-trigger:hover {
border-color: rgba(0, 0, 0, 0.82);
background: #161619;
}
.skill-install-copy-button {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.02);
color: var(--ink);
transition:
transform 0.18s ease,
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
touch-action: manipulation;
}
[data-theme-resolved="light"] .skill-install-copy-button {
border-color: rgba(15, 12, 10, 0.12);
background: rgba(255, 255, 255, 0.62);
}
.skill-install-copy-button[data-copy-state="copied"] {
border-color: rgba(34, 197, 94, 0.36);
color: var(--status-success-fg);
}
.skill-install-copy-button[data-copy-state="failed"] {
border-color: rgba(239, 68, 68, 0.32);
color: var(--status-error-fg);
}
.skill-install-menu {
max-width: 280px;
}
.skill-install-menu-copy {
display: grid;
gap: 2px;
}
.skill-install-menu-label {
font-size: 0.9rem;
}
.skill-install-menu-description {
font-size: 0.78rem;
font-weight: 500;
line-height: 1.4;
color: var(--ink-soft);
}
.skill-install-copy-feedback {
min-height: 1.25rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-soft);
}
.skill-install-preview-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.skill-install-preview-label,
.skill-install-command-label {
margin: 0;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
}
.skill-install-preview-mode {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
font-size: 0.82rem;
font-weight: 700;
}
[data-theme-resolved="light"] .skill-install-preview-mode {
background: rgba(15, 12, 10, 0.08);
}
.skill-install-prompt-preview,
.skill-install-command {
margin: 0;
min-width: 0;
overflow-x: auto;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(12, 12, 14, 0.96), rgba(19, 19, 22, 0.98)),
radial-gradient(circle at top right, rgba(220, 38, 38, 0.14), rgba(220, 38, 38, 0));
color: #f8f2e9;
padding: 16px 18px;
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
font-variant-numeric: tabular-nums;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.skill-install-command-card {
display: grid;
gap: 12px;
min-width: 0;
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
[data-theme-resolved="light"] .skill-install-command-card {
border-color: rgba(15, 12, 10, 0.08);
background: rgba(255, 255, 255, 0.42);
}
.skill-install-command-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.skill-install-command-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.skill-install-command-caption {
margin: 0;
font-size: 0.84rem;
line-height: 1.45;
color: var(--ink-soft);
}
.install-switcher-toggle {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
padding: 4px;
}
[data-theme-resolved="light"] .install-switcher-toggle {
border-color: rgba(15, 12, 10, 0.08);
background: rgba(15, 12, 10, 0.04);
}
.install-switcher-pill {
border: none;
border-radius: 999px;
background: transparent;
color: var(--ink-soft);
cursor: pointer;
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
text-transform: lowercase;
transition:
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
touch-action: manipulation;
}
.install-switcher-pill:hover {
color: var(--ink);
}
.install-switcher-pill:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.install-switcher-pill.is-active,
.install-switcher-pill[aria-selected="true"] {
background: #0f0f11;
color: #f8f2e9;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
}
@media (prefers-reduced-motion: reduce) {
.skill-install-prompt-trigger,
.skill-install-copy-button,
.install-switcher-pill {
transition: none;
transform: none;
}
}
@media (max-width: 860px) {
.skill-install-grid {
grid-template-columns: 1fr;
}
}
/* Legacy compatibility — keep for any existing references */
.skill-hero-cta {
display: flex;
@@ -4210,6 +4527,14 @@ code {
min-width: 200px;
}
.skill-install-panel {
padding: 18px;
}
.skill-install-command-card {
padding: 14px;
}
.skill-hero-cta {
flex-direction: row;
flex-wrap: wrap;
@@ -5996,6 +6321,32 @@ html.theme-transition::view-transition-new(theme) {
font-size: 0.82rem;
}
.skill-install-actions,
.skill-install-preview-meta,
.skill-install-command-header {
align-items: stretch;
}
.skill-install-copy-feedback {
width: 100%;
}
.skill-install-prompt-trigger,
.skill-install-copy-button {
width: 100%;
justify-content: center;
}
.install-switcher-toggle {
width: 100%;
justify-content: space-between;
}
.install-switcher-pill {
flex: 1 1 0;
justify-content: center;
}
.card {
padding: 16px;
}
+27 -1
View File
@@ -30,5 +30,31 @@
"source": "/api/:path*",
"destination": "https://wry-manatee-359.convex.site/api/:path*"
}
]
],
"images": {
"sizes": [256, 640, 1024, 1920],
"formats": ["image/webp"],
"minimumCacheTTL": 86400,
"dangerouslyAllowSVG": true,
"contentDispositionType": "attachment",
"contentSecurityPolicy": "default-src 'self'; script-src 'none'; sandbox;",
"remotePatterns": [
{ "protocol": "https", "hostname": "raw.githubusercontent.com" },
{ "protocol": "https", "hostname": "user-images.githubusercontent.com" },
{ "protocol": "https", "hostname": "avatars.githubusercontent.com" },
{ "protocol": "https", "hostname": "camo.githubusercontent.com" },
{ "protocol": "https", "hostname": "github.com" },
{ "protocol": "https", "hostname": "raw.github.com" },
{ "protocol": "https", "hostname": "img.shields.io" },
{ "protocol": "https", "hostname": "shields.io" },
{ "protocol": "https", "hostname": "cdn.jsdelivr.net" },
{ "protocol": "https", "hostname": "i.imgur.com" },
{ "protocol": "https", "hostname": "codecov.io" },
{ "protocol": "https", "hostname": "coveralls.io" },
{ "protocol": "https", "hostname": "codefactor.io" },
{ "protocol": "https", "hostname": "badgen.net" },
{ "protocol": "https", "hostname": "flat.badgen.net" },
{ "protocol": "https", "hostname": "gitlab.com" }
]
}
}